From 9096f33d55903d469c2a58252070c10b1f19028b Mon Sep 17 00:00:00 2001 From: jaemin Date: Fri, 8 May 2026 21:26:12 +0900 Subject: [PATCH 01/11] =?UTF-8?q?feat(admin):=20=EA=B0=9C=EB=85=90?= =?UTF-8?q?=ED=83=9C=EA=B7=B8=20=EA=B7=B8=EB=9E=98=ED=94=84=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MAT-614. 신규 BE API (/api/admin/concept/graph/*) 위에 시트형 관리자 페이지 /concept-graph 를 추가한다. 기존 /concept-tags 레거시 페이지는 그대로 유지. - /concept-graph/node — 개념 노드 시트 (필터·정렬·페이지네이션 + CRUD, payload JSON 검증) - /concept-graph/edge — 개념 엣지 시트 (NodeSearchSelect 신설로 from/to 노드 검색 선택) - /concept-graph/action-edge — 액션 그래프 pivot 시트, 셀 클릭 시 우측 사이드 패널에서 다중 선택 후 일괄 PUT - /concept-graph/types — 노드/엣지/액션엣지 타입 코드 CRUD - 공통 시트 컴포넌트(SheetTable, PaginationControls, SearchFilterBar, RowActions) + ConceptGraphTabs - 24개 openapi-react-query wrapper, useInvalidate 5개 메서드 확장, GNB "개념 그래프" 메뉴 추가 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../conceptGraph/deleteActionEdge.ts | 7 + .../conceptGraph/deleteActionEdgeType.ts | 7 + .../controller/conceptGraph/deleteEdge.ts | 7 + .../controller/conceptGraph/deleteEdgeType.ts | 7 + .../controller/conceptGraph/deleteNode.ts | 7 + .../controller/conceptGraph/deleteNodeType.ts | 7 + .../controller/conceptGraph/getActionEdge.ts | 7 + .../conceptGraph/getActionEdgeType.ts | 7 + .../apis/controller/conceptGraph/getEdge.ts | 7 + .../controller/conceptGraph/getEdgeType.ts | 7 + .../apis/controller/conceptGraph/getNode.ts | 7 + .../controller/conceptGraph/getNodeType.ts | 7 + .../conceptGraph/getSheetActionEdge.ts | 12 + .../controller/conceptGraph/getSheetEdge.ts | 12 + .../controller/conceptGraph/getSheetNode.ts | 12 + .../src/apis/controller/conceptGraph/index.ts | 59 + .../controller/conceptGraph/postActionEdge.ts | 7 + .../conceptGraph/postActionEdgeType.ts | 7 + .../apis/controller/conceptGraph/postEdge.ts | 7 + .../controller/conceptGraph/postEdgeType.ts | 7 + .../apis/controller/conceptGraph/postNode.ts | 7 + .../controller/conceptGraph/postNodeType.ts | 7 + .../controller/conceptGraph/putActionEdge.ts | 7 + .../conceptGraph/putActionEdgeType.ts | 7 + .../apis/controller/conceptGraph/putEdge.ts | 7 + .../controller/conceptGraph/putEdgeType.ts | 7 + .../apis/controller/conceptGraph/putNode.ts | 7 + .../controller/conceptGraph/putNodeType.ts | 7 + .../conceptGraph/putSheetActionEdgeCell.ts | 7 + apps/admin/src/apis/index.ts | 1 + apps/admin/src/components/common/GNB.tsx | 8 + .../conceptGraph/AddActionRowModal.tsx | 275 ++ .../components/conceptGraph/CellEditPanel.tsx | 295 ++ .../conceptGraph/ConceptGraphTabs.tsx | 31 + .../conceptGraph/EditConceptEdgeModal.tsx | 210 + .../conceptGraph/EditConceptNodeModal.tsx | 219 + .../conceptGraph/EditTypeCodeModal.tsx | 218 + .../conceptGraph/NodeSearchSelect.tsx | 199 + .../conceptGraph/PaginationControls.tsx | 69 + .../components/conceptGraph/RowActions.tsx | 29 + .../conceptGraph/SearchFilterBar.tsx | 102 + .../components/conceptGraph/SheetTable.tsx | 146 + .../src/components/conceptGraph/index.ts | 28 + apps/admin/src/hooks/useInvalidate.ts | 81 + .../_GNBLayout/concept-graph/action-edge.tsx | 412 ++ .../routes/_GNBLayout/concept-graph/edge.tsx | 289 ++ .../routes/_GNBLayout/concept-graph/index.tsx | 7 + .../routes/_GNBLayout/concept-graph/node.tsx | 339 ++ .../routes/_GNBLayout/concept-graph/types.tsx | 237 + apps/admin/src/types/api/queryParams.ts | 14 + apps/admin/src/types/api/schema.d.ts | 3826 ++++++++++++++--- 51 files changed, 6687 insertions(+), 618 deletions(-) create mode 100644 apps/admin/src/apis/controller/conceptGraph/deleteActionEdge.ts create mode 100644 apps/admin/src/apis/controller/conceptGraph/deleteActionEdgeType.ts create mode 100644 apps/admin/src/apis/controller/conceptGraph/deleteEdge.ts create mode 100644 apps/admin/src/apis/controller/conceptGraph/deleteEdgeType.ts create mode 100644 apps/admin/src/apis/controller/conceptGraph/deleteNode.ts create mode 100644 apps/admin/src/apis/controller/conceptGraph/deleteNodeType.ts create mode 100644 apps/admin/src/apis/controller/conceptGraph/getActionEdge.ts create mode 100644 apps/admin/src/apis/controller/conceptGraph/getActionEdgeType.ts create mode 100644 apps/admin/src/apis/controller/conceptGraph/getEdge.ts create mode 100644 apps/admin/src/apis/controller/conceptGraph/getEdgeType.ts create mode 100644 apps/admin/src/apis/controller/conceptGraph/getNode.ts create mode 100644 apps/admin/src/apis/controller/conceptGraph/getNodeType.ts create mode 100644 apps/admin/src/apis/controller/conceptGraph/getSheetActionEdge.ts create mode 100644 apps/admin/src/apis/controller/conceptGraph/getSheetEdge.ts create mode 100644 apps/admin/src/apis/controller/conceptGraph/getSheetNode.ts create mode 100644 apps/admin/src/apis/controller/conceptGraph/index.ts create mode 100644 apps/admin/src/apis/controller/conceptGraph/postActionEdge.ts create mode 100644 apps/admin/src/apis/controller/conceptGraph/postActionEdgeType.ts create mode 100644 apps/admin/src/apis/controller/conceptGraph/postEdge.ts create mode 100644 apps/admin/src/apis/controller/conceptGraph/postEdgeType.ts create mode 100644 apps/admin/src/apis/controller/conceptGraph/postNode.ts create mode 100644 apps/admin/src/apis/controller/conceptGraph/postNodeType.ts create mode 100644 apps/admin/src/apis/controller/conceptGraph/putActionEdge.ts create mode 100644 apps/admin/src/apis/controller/conceptGraph/putActionEdgeType.ts create mode 100644 apps/admin/src/apis/controller/conceptGraph/putEdge.ts create mode 100644 apps/admin/src/apis/controller/conceptGraph/putEdgeType.ts create mode 100644 apps/admin/src/apis/controller/conceptGraph/putNode.ts create mode 100644 apps/admin/src/apis/controller/conceptGraph/putNodeType.ts create mode 100644 apps/admin/src/apis/controller/conceptGraph/putSheetActionEdgeCell.ts create mode 100644 apps/admin/src/components/conceptGraph/AddActionRowModal.tsx create mode 100644 apps/admin/src/components/conceptGraph/CellEditPanel.tsx create mode 100644 apps/admin/src/components/conceptGraph/ConceptGraphTabs.tsx create mode 100644 apps/admin/src/components/conceptGraph/EditConceptEdgeModal.tsx create mode 100644 apps/admin/src/components/conceptGraph/EditConceptNodeModal.tsx create mode 100644 apps/admin/src/components/conceptGraph/EditTypeCodeModal.tsx create mode 100644 apps/admin/src/components/conceptGraph/NodeSearchSelect.tsx create mode 100644 apps/admin/src/components/conceptGraph/PaginationControls.tsx create mode 100644 apps/admin/src/components/conceptGraph/RowActions.tsx create mode 100644 apps/admin/src/components/conceptGraph/SearchFilterBar.tsx create mode 100644 apps/admin/src/components/conceptGraph/SheetTable.tsx create mode 100644 apps/admin/src/components/conceptGraph/index.ts create mode 100644 apps/admin/src/routes/_GNBLayout/concept-graph/action-edge.tsx create mode 100644 apps/admin/src/routes/_GNBLayout/concept-graph/edge.tsx create mode 100644 apps/admin/src/routes/_GNBLayout/concept-graph/index.tsx create mode 100644 apps/admin/src/routes/_GNBLayout/concept-graph/node.tsx create mode 100644 apps/admin/src/routes/_GNBLayout/concept-graph/types.tsx diff --git a/apps/admin/src/apis/controller/conceptGraph/deleteActionEdge.ts b/apps/admin/src/apis/controller/conceptGraph/deleteActionEdge.ts new file mode 100644 index 000000000..708ba1b0e --- /dev/null +++ b/apps/admin/src/apis/controller/conceptGraph/deleteActionEdge.ts @@ -0,0 +1,7 @@ +import { $api } from '@apis'; + +const deleteActionEdge = () => { + return $api.useMutation('delete', '/api/admin/concept/graph/action-edge/{id}'); +}; + +export default deleteActionEdge; diff --git a/apps/admin/src/apis/controller/conceptGraph/deleteActionEdgeType.ts b/apps/admin/src/apis/controller/conceptGraph/deleteActionEdgeType.ts new file mode 100644 index 000000000..72629bf70 --- /dev/null +++ b/apps/admin/src/apis/controller/conceptGraph/deleteActionEdgeType.ts @@ -0,0 +1,7 @@ +import { $api } from '@apis'; + +const deleteActionEdgeType = () => { + return $api.useMutation('delete', '/api/admin/concept/graph/action-edge-type/{id}'); +}; + +export default deleteActionEdgeType; diff --git a/apps/admin/src/apis/controller/conceptGraph/deleteEdge.ts b/apps/admin/src/apis/controller/conceptGraph/deleteEdge.ts new file mode 100644 index 000000000..433f5abed --- /dev/null +++ b/apps/admin/src/apis/controller/conceptGraph/deleteEdge.ts @@ -0,0 +1,7 @@ +import { $api } from '@apis'; + +const deleteEdge = () => { + return $api.useMutation('delete', '/api/admin/concept/graph/edge/{id}'); +}; + +export default deleteEdge; diff --git a/apps/admin/src/apis/controller/conceptGraph/deleteEdgeType.ts b/apps/admin/src/apis/controller/conceptGraph/deleteEdgeType.ts new file mode 100644 index 000000000..e4e1e4d7b --- /dev/null +++ b/apps/admin/src/apis/controller/conceptGraph/deleteEdgeType.ts @@ -0,0 +1,7 @@ +import { $api } from '@apis'; + +const deleteEdgeType = () => { + return $api.useMutation('delete', '/api/admin/concept/graph/edge-type/{id}'); +}; + +export default deleteEdgeType; diff --git a/apps/admin/src/apis/controller/conceptGraph/deleteNode.ts b/apps/admin/src/apis/controller/conceptGraph/deleteNode.ts new file mode 100644 index 000000000..b8bb84d2b --- /dev/null +++ b/apps/admin/src/apis/controller/conceptGraph/deleteNode.ts @@ -0,0 +1,7 @@ +import { $api } from '@apis'; + +const deleteNode = () => { + return $api.useMutation('delete', '/api/admin/concept/graph/node/{id}'); +}; + +export default deleteNode; diff --git a/apps/admin/src/apis/controller/conceptGraph/deleteNodeType.ts b/apps/admin/src/apis/controller/conceptGraph/deleteNodeType.ts new file mode 100644 index 000000000..ffa3f8090 --- /dev/null +++ b/apps/admin/src/apis/controller/conceptGraph/deleteNodeType.ts @@ -0,0 +1,7 @@ +import { $api } from '@apis'; + +const deleteNodeType = () => { + return $api.useMutation('delete', '/api/admin/concept/graph/node-type/{id}'); +}; + +export default deleteNodeType; diff --git a/apps/admin/src/apis/controller/conceptGraph/getActionEdge.ts b/apps/admin/src/apis/controller/conceptGraph/getActionEdge.ts new file mode 100644 index 000000000..d3f439eec --- /dev/null +++ b/apps/admin/src/apis/controller/conceptGraph/getActionEdge.ts @@ -0,0 +1,7 @@ +import { $api } from '@apis'; + +const getActionEdge = () => { + return $api.useQuery('get', '/api/admin/concept/graph/action-edge'); +}; + +export default getActionEdge; diff --git a/apps/admin/src/apis/controller/conceptGraph/getActionEdgeType.ts b/apps/admin/src/apis/controller/conceptGraph/getActionEdgeType.ts new file mode 100644 index 000000000..42dc426c3 --- /dev/null +++ b/apps/admin/src/apis/controller/conceptGraph/getActionEdgeType.ts @@ -0,0 +1,7 @@ +import { $api } from '@apis'; + +const getActionEdgeType = () => { + return $api.useQuery('get', '/api/admin/concept/graph/action-edge-type'); +}; + +export default getActionEdgeType; diff --git a/apps/admin/src/apis/controller/conceptGraph/getEdge.ts b/apps/admin/src/apis/controller/conceptGraph/getEdge.ts new file mode 100644 index 000000000..db1b343ec --- /dev/null +++ b/apps/admin/src/apis/controller/conceptGraph/getEdge.ts @@ -0,0 +1,7 @@ +import { $api } from '@apis'; + +const getEdge = () => { + return $api.useQuery('get', '/api/admin/concept/graph/edge'); +}; + +export default getEdge; diff --git a/apps/admin/src/apis/controller/conceptGraph/getEdgeType.ts b/apps/admin/src/apis/controller/conceptGraph/getEdgeType.ts new file mode 100644 index 000000000..f7d3de991 --- /dev/null +++ b/apps/admin/src/apis/controller/conceptGraph/getEdgeType.ts @@ -0,0 +1,7 @@ +import { $api } from '@apis'; + +const getEdgeType = () => { + return $api.useQuery('get', '/api/admin/concept/graph/edge-type'); +}; + +export default getEdgeType; diff --git a/apps/admin/src/apis/controller/conceptGraph/getNode.ts b/apps/admin/src/apis/controller/conceptGraph/getNode.ts new file mode 100644 index 000000000..dddecee26 --- /dev/null +++ b/apps/admin/src/apis/controller/conceptGraph/getNode.ts @@ -0,0 +1,7 @@ +import { $api } from '@apis'; + +const getNode = () => { + return $api.useQuery('get', '/api/admin/concept/graph/node'); +}; + +export default getNode; diff --git a/apps/admin/src/apis/controller/conceptGraph/getNodeType.ts b/apps/admin/src/apis/controller/conceptGraph/getNodeType.ts new file mode 100644 index 000000000..b1a0cc62c --- /dev/null +++ b/apps/admin/src/apis/controller/conceptGraph/getNodeType.ts @@ -0,0 +1,7 @@ +import { $api } from '@apis'; + +const getNodeType = () => { + return $api.useQuery('get', '/api/admin/concept/graph/node-type'); +}; + +export default getNodeType; diff --git a/apps/admin/src/apis/controller/conceptGraph/getSheetActionEdge.ts b/apps/admin/src/apis/controller/conceptGraph/getSheetActionEdge.ts new file mode 100644 index 000000000..22ab9a2fb --- /dev/null +++ b/apps/admin/src/apis/controller/conceptGraph/getSheetActionEdge.ts @@ -0,0 +1,12 @@ +import { $api } from '@apis'; +import { ActionGraphSheetSearchOptions } from '@types'; + +const getSheetActionEdge = (params: ActionGraphSheetSearchOptions = {}) => { + return $api.useQuery('get', '/api/admin/concept/graph/sheet/action-edge', { + params: { + query: params, + }, + }); +}; + +export default getSheetActionEdge; diff --git a/apps/admin/src/apis/controller/conceptGraph/getSheetEdge.ts b/apps/admin/src/apis/controller/conceptGraph/getSheetEdge.ts new file mode 100644 index 000000000..a5847df06 --- /dev/null +++ b/apps/admin/src/apis/controller/conceptGraph/getSheetEdge.ts @@ -0,0 +1,12 @@ +import { $api } from '@apis'; +import { ConceptEdgeSheetSearchOptions } from '@types'; + +const getSheetEdge = (params: ConceptEdgeSheetSearchOptions = {}) => { + return $api.useQuery('get', '/api/admin/concept/graph/sheet/edge', { + params: { + query: params, + }, + }); +}; + +export default getSheetEdge; diff --git a/apps/admin/src/apis/controller/conceptGraph/getSheetNode.ts b/apps/admin/src/apis/controller/conceptGraph/getSheetNode.ts new file mode 100644 index 000000000..d99f5432d --- /dev/null +++ b/apps/admin/src/apis/controller/conceptGraph/getSheetNode.ts @@ -0,0 +1,12 @@ +import { $api } from '@apis'; +import { ConceptNodeSheetSearchOptions } from '@types'; + +const getSheetNode = (params: ConceptNodeSheetSearchOptions = {}) => { + return $api.useQuery('get', '/api/admin/concept/graph/sheet/node', { + params: { + query: params, + }, + }); +}; + +export default getSheetNode; diff --git a/apps/admin/src/apis/controller/conceptGraph/index.ts b/apps/admin/src/apis/controller/conceptGraph/index.ts new file mode 100644 index 000000000..66410bc90 --- /dev/null +++ b/apps/admin/src/apis/controller/conceptGraph/index.ts @@ -0,0 +1,59 @@ +import deleteActionEdge from './deleteActionEdge'; +import deleteActionEdgeType from './deleteActionEdgeType'; +import deleteEdge from './deleteEdge'; +import deleteEdgeType from './deleteEdgeType'; +import deleteNode from './deleteNode'; +import deleteNodeType from './deleteNodeType'; +import getActionEdge from './getActionEdge'; +import getActionEdgeType from './getActionEdgeType'; +import getEdge from './getEdge'; +import getEdgeType from './getEdgeType'; +import getNode from './getNode'; +import getNodeType from './getNodeType'; +import getSheetActionEdge from './getSheetActionEdge'; +import getSheetEdge from './getSheetEdge'; +import getSheetNode from './getSheetNode'; +import postActionEdge from './postActionEdge'; +import postActionEdgeType from './postActionEdgeType'; +import postEdge from './postEdge'; +import postEdgeType from './postEdgeType'; +import postNode from './postNode'; +import postNodeType from './postNodeType'; +import putActionEdge from './putActionEdge'; +import putActionEdgeType from './putActionEdgeType'; +import putEdge from './putEdge'; +import putEdgeType from './putEdgeType'; +import putNode from './putNode'; +import putNodeType from './putNodeType'; +import putSheetActionEdgeCell from './putSheetActionEdgeCell'; + +export { + deleteActionEdge, + deleteActionEdgeType, + deleteEdge, + deleteEdgeType, + deleteNode, + deleteNodeType, + getActionEdge, + getActionEdgeType, + getEdge, + getEdgeType, + getNode, + getNodeType, + getSheetActionEdge, + getSheetEdge, + getSheetNode, + postActionEdge, + postActionEdgeType, + postEdge, + postEdgeType, + postNode, + postNodeType, + putActionEdge, + putActionEdgeType, + putEdge, + putEdgeType, + putNode, + putNodeType, + putSheetActionEdgeCell, +}; diff --git a/apps/admin/src/apis/controller/conceptGraph/postActionEdge.ts b/apps/admin/src/apis/controller/conceptGraph/postActionEdge.ts new file mode 100644 index 000000000..bd129d5c4 --- /dev/null +++ b/apps/admin/src/apis/controller/conceptGraph/postActionEdge.ts @@ -0,0 +1,7 @@ +import { $api } from '@apis'; + +const postActionEdge = () => { + return $api.useMutation('post', '/api/admin/concept/graph/action-edge'); +}; + +export default postActionEdge; diff --git a/apps/admin/src/apis/controller/conceptGraph/postActionEdgeType.ts b/apps/admin/src/apis/controller/conceptGraph/postActionEdgeType.ts new file mode 100644 index 000000000..ae065e2e2 --- /dev/null +++ b/apps/admin/src/apis/controller/conceptGraph/postActionEdgeType.ts @@ -0,0 +1,7 @@ +import { $api } from '@apis'; + +const postActionEdgeType = () => { + return $api.useMutation('post', '/api/admin/concept/graph/action-edge-type'); +}; + +export default postActionEdgeType; diff --git a/apps/admin/src/apis/controller/conceptGraph/postEdge.ts b/apps/admin/src/apis/controller/conceptGraph/postEdge.ts new file mode 100644 index 000000000..6bc11e7c8 --- /dev/null +++ b/apps/admin/src/apis/controller/conceptGraph/postEdge.ts @@ -0,0 +1,7 @@ +import { $api } from '@apis'; + +const postEdge = () => { + return $api.useMutation('post', '/api/admin/concept/graph/edge'); +}; + +export default postEdge; diff --git a/apps/admin/src/apis/controller/conceptGraph/postEdgeType.ts b/apps/admin/src/apis/controller/conceptGraph/postEdgeType.ts new file mode 100644 index 000000000..0c61cde5b --- /dev/null +++ b/apps/admin/src/apis/controller/conceptGraph/postEdgeType.ts @@ -0,0 +1,7 @@ +import { $api } from '@apis'; + +const postEdgeType = () => { + return $api.useMutation('post', '/api/admin/concept/graph/edge-type'); +}; + +export default postEdgeType; diff --git a/apps/admin/src/apis/controller/conceptGraph/postNode.ts b/apps/admin/src/apis/controller/conceptGraph/postNode.ts new file mode 100644 index 000000000..ceff3918e --- /dev/null +++ b/apps/admin/src/apis/controller/conceptGraph/postNode.ts @@ -0,0 +1,7 @@ +import { $api } from '@apis'; + +const postNode = () => { + return $api.useMutation('post', '/api/admin/concept/graph/node'); +}; + +export default postNode; diff --git a/apps/admin/src/apis/controller/conceptGraph/postNodeType.ts b/apps/admin/src/apis/controller/conceptGraph/postNodeType.ts new file mode 100644 index 000000000..fd446e2e0 --- /dev/null +++ b/apps/admin/src/apis/controller/conceptGraph/postNodeType.ts @@ -0,0 +1,7 @@ +import { $api } from '@apis'; + +const postNodeType = () => { + return $api.useMutation('post', '/api/admin/concept/graph/node-type'); +}; + +export default postNodeType; diff --git a/apps/admin/src/apis/controller/conceptGraph/putActionEdge.ts b/apps/admin/src/apis/controller/conceptGraph/putActionEdge.ts new file mode 100644 index 000000000..cb2c0bac7 --- /dev/null +++ b/apps/admin/src/apis/controller/conceptGraph/putActionEdge.ts @@ -0,0 +1,7 @@ +import { $api } from '@apis'; + +const putActionEdge = () => { + return $api.useMutation('put', '/api/admin/concept/graph/action-edge/{id}'); +}; + +export default putActionEdge; diff --git a/apps/admin/src/apis/controller/conceptGraph/putActionEdgeType.ts b/apps/admin/src/apis/controller/conceptGraph/putActionEdgeType.ts new file mode 100644 index 000000000..4b77c5fb7 --- /dev/null +++ b/apps/admin/src/apis/controller/conceptGraph/putActionEdgeType.ts @@ -0,0 +1,7 @@ +import { $api } from '@apis'; + +const putActionEdgeType = () => { + return $api.useMutation('put', '/api/admin/concept/graph/action-edge-type/{id}'); +}; + +export default putActionEdgeType; diff --git a/apps/admin/src/apis/controller/conceptGraph/putEdge.ts b/apps/admin/src/apis/controller/conceptGraph/putEdge.ts new file mode 100644 index 000000000..f014c9aa3 --- /dev/null +++ b/apps/admin/src/apis/controller/conceptGraph/putEdge.ts @@ -0,0 +1,7 @@ +import { $api } from '@apis'; + +const putEdge = () => { + return $api.useMutation('put', '/api/admin/concept/graph/edge/{id}'); +}; + +export default putEdge; diff --git a/apps/admin/src/apis/controller/conceptGraph/putEdgeType.ts b/apps/admin/src/apis/controller/conceptGraph/putEdgeType.ts new file mode 100644 index 000000000..1b8315c03 --- /dev/null +++ b/apps/admin/src/apis/controller/conceptGraph/putEdgeType.ts @@ -0,0 +1,7 @@ +import { $api } from '@apis'; + +const putEdgeType = () => { + return $api.useMutation('put', '/api/admin/concept/graph/edge-type/{id}'); +}; + +export default putEdgeType; diff --git a/apps/admin/src/apis/controller/conceptGraph/putNode.ts b/apps/admin/src/apis/controller/conceptGraph/putNode.ts new file mode 100644 index 000000000..6d924e98d --- /dev/null +++ b/apps/admin/src/apis/controller/conceptGraph/putNode.ts @@ -0,0 +1,7 @@ +import { $api } from '@apis'; + +const putNode = () => { + return $api.useMutation('put', '/api/admin/concept/graph/node/{id}'); +}; + +export default putNode; diff --git a/apps/admin/src/apis/controller/conceptGraph/putNodeType.ts b/apps/admin/src/apis/controller/conceptGraph/putNodeType.ts new file mode 100644 index 000000000..d2ea16bfd --- /dev/null +++ b/apps/admin/src/apis/controller/conceptGraph/putNodeType.ts @@ -0,0 +1,7 @@ +import { $api } from '@apis'; + +const putNodeType = () => { + return $api.useMutation('put', '/api/admin/concept/graph/node-type/{id}'); +}; + +export default putNodeType; diff --git a/apps/admin/src/apis/controller/conceptGraph/putSheetActionEdgeCell.ts b/apps/admin/src/apis/controller/conceptGraph/putSheetActionEdgeCell.ts new file mode 100644 index 000000000..0b368bacf --- /dev/null +++ b/apps/admin/src/apis/controller/conceptGraph/putSheetActionEdgeCell.ts @@ -0,0 +1,7 @@ +import { $api } from '@apis'; + +const putSheetActionEdgeCell = () => { + return $api.useMutation('put', '/api/admin/concept/graph/sheet/action-edge/cell'); +}; + +export default putSheetActionEdgeCell; diff --git a/apps/admin/src/apis/index.ts b/apps/admin/src/apis/index.ts index 7fb406d16..9b8afd77c 100644 --- a/apps/admin/src/apis/index.ts +++ b/apps/admin/src/apis/index.ts @@ -4,6 +4,7 @@ export { $api } from './client'; // controllers export * from './controller/auth'; export * from './controller/concept'; +export * from './controller/conceptGraph'; export * from './controller/diagnosis'; export * from './controller/file'; export * from './controller/notice'; diff --git a/apps/admin/src/components/common/GNB.tsx b/apps/admin/src/components/common/GNB.tsx index ed2c4ed08..bd696c68e 100644 --- a/apps/admin/src/components/common/GNB.tsx +++ b/apps/admin/src/components/common/GNB.tsx @@ -14,6 +14,7 @@ import { Tags, MessageCircle, Bell, + Network, } from 'lucide-react'; import { getStudent } from '@apis'; import { useSelectedStudent } from '@hooks'; @@ -262,6 +263,13 @@ const GNB = () => { label='개념 태그' isCollapsed={isCollapsed} /> + + } + label='개념 그래프' + isCollapsed={isCollapsed} + /> {/* Teacher Info */} diff --git a/apps/admin/src/components/conceptGraph/AddActionRowModal.tsx b/apps/admin/src/components/conceptGraph/AddActionRowModal.tsx new file mode 100644 index 000000000..f4239223e --- /dev/null +++ b/apps/admin/src/components/conceptGraph/AddActionRowModal.tsx @@ -0,0 +1,275 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { toast } from 'react-toastify'; +import { Plus, Search, Sparkles, X } from 'lucide-react'; +import { Button } from '@components'; +import { getActionEdgeType, getSheetNode, putSheetActionEdgeCell } from '@apis'; +import { useInvalidate } from '@hooks'; + +import NodeSearchSelect from './NodeSearchSelect'; + +import type { components } from '@/types/api/schema'; + +type ConceptNodeResp = components['schemas']['ConceptNodeResp']; + +interface Props { + onClose: () => void; + onSaved: () => void; +} + +const formSchema = z.object({ + actionNodeId: z.string().min(1, '액션 노드를 선택해주세요'), + roleId: z.string().min(1, 'role 을 선택해주세요'), +}); + +type FormValues = z.infer; + +const extractErrorMessage = (error: unknown): string => { + const fallback = '요청에 실패했습니다'; + if (!error || typeof error !== 'object') return fallback; + const responseData = (error as { response?: { data?: { message?: unknown } } }).response?.data + ?.message; + if (typeof responseData === 'string' && responseData.length > 0) return responseData; + const maybeMessage = (error as { message?: unknown }).message; + if (typeof maybeMessage === 'string' && maybeMessage.length > 0) return maybeMessage; + return fallback; +}; + +const formatNodeLabel = (node: ConceptNodeResp): string => { + const name = node.name ?? ''; + const typeLabel = node.nodeType?.label; + return typeLabel ? `${name} (${typeLabel})` : name; +}; + +const AddActionRowModal = ({ onClose, onSaved }: Props) => { + const { invalidateConceptGraphActionEdges } = useInvalidate(); + + const { data: actionEdgeTypeData } = getActionEdgeType(); + const roleOptions = useMemo(() => actionEdgeTypeData?.data ?? [], [actionEdgeTypeData]); + + const putCellMutation = putSheetActionEdgeCell(); + + const [actionNodeCache, setActionNodeCache] = useState(undefined); + const [selectedNodes, setSelectedNodes] = useState([]); + const [query, setQuery] = useState(''); + const [debouncedQuery, setDebouncedQuery] = useState(''); + + const { + register, + handleSubmit, + watch, + setValue, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + actionNodeId: '', + roleId: '', + }, + }); + + const actionNodeId = watch('actionNodeId'); + const actionValueNum = actionNodeId ? Number(actionNodeId) : undefined; + + useEffect(() => { + const timer = setTimeout(() => setDebouncedQuery(query.trim()), 200); + return () => clearTimeout(timer); + }, [query]); + + const sheetQuery = getSheetNode({ + page: 0, + size: 50, + name: debouncedQuery.length > 0 ? debouncedQuery : undefined, + }); + + const candidates: ConceptNodeResp[] = sheetQuery.data?.data ?? []; + + const filtered = useMemo(() => { + const selectedIds = new Set(selectedNodes.map((n) => n.id)); + return candidates.filter((c) => c.id !== undefined && !selectedIds.has(c.id)); + }, [candidates, selectedNodes]); + + const handleAdd = (node: ConceptNodeResp) => { + if (node.id === undefined) return; + if (selectedNodes.some((n) => n.id === node.id)) return; + setSelectedNodes((prev) => [...prev, node]); + }; + + const handleRemove = (id: number | undefined) => { + if (id === undefined) return; + setSelectedNodes((prev) => prev.filter((n) => n.id !== id)); + }; + + const onSubmit = async (values: FormValues) => { + const actionId = Number(values.actionNodeId); + const roleId = Number(values.roleId); + const conceptNodeIds = selectedNodes + .map((n) => n.id) + .filter((id): id is number => id !== undefined); + + try { + await putCellMutation.mutateAsync({ + params: { + query: { actionNodeId: actionId, roleId }, + }, + body: { conceptNodeIds }, + }); + await invalidateConceptGraphActionEdges(); + toast.success('저장되었습니다'); + onSaved(); + onClose(); + } catch (error) { + toast.error(extractErrorMessage(error)); + } + }; + + return ( +
+
+
+
+ +
+

액션 노드 행 추가

+
+ +
+ +
+ + +
+ + { + setValue('actionNodeId', id !== undefined ? String(id) : '', { + shouldValidate: true, + }); + setActionNodeCache(node); + }} + initialNode={actionNodeCache} + placeholder='액션 노드 선택' + hasError={Boolean(errors.actionNodeId)} + /> + {errors.actionNodeId && ( +

{errors.actionNodeId.message}

+ )} +
+ +
+ + + {errors.roleId && ( +

{errors.roleId.message}

+ )} +
+ +
+
+ + {selectedNodes.length}개 +
+ + {selectedNodes.length > 0 && ( +
+ {selectedNodes.map((node) => ( + + {formatNodeLabel(node)} + + + ))} +
+ )} + +
+ + setQuery(e.target.value)} + placeholder='노드명 검색...' + className='focus:border-main h-10 w-full rounded-xl border border-gray-200 bg-white pr-3 pl-9 text-sm placeholder-gray-400 focus:outline-none' + /> +
+ +
+ {sheetQuery.isLoading ? ( +
불러오는 중...
+ ) : filtered.length === 0 ? ( +
+ 일치하는 노드가 없습니다. +
+ ) : ( +
    + {filtered.map((node) => ( +
  • +
    +

    + {node.name ?? ''} +

    + {node.nodeType?.label && ( +

    {node.nodeType.label}

    + )} +
    + +
  • + ))} +
+ )} +
+ +

+ 최소 1개 이상의 conceptNode 를 선택해야 시트에 새 행이 노출됩니다. +

+
+ +
+ + +
+
+
+ ); +}; + +export default AddActionRowModal; diff --git a/apps/admin/src/components/conceptGraph/CellEditPanel.tsx b/apps/admin/src/components/conceptGraph/CellEditPanel.tsx new file mode 100644 index 000000000..d6a661e4d --- /dev/null +++ b/apps/admin/src/components/conceptGraph/CellEditPanel.tsx @@ -0,0 +1,295 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Plus, Search, X } from 'lucide-react'; +import { toast } from 'react-toastify'; +import { Button, Modal, TwoButtonModalTemplate } from '@components'; +import { getSheetNode, putSheetActionEdgeCell } from '@apis'; +import { useInvalidate } from '@hooks'; + +import type { components } from '@/types/api/schema'; + +type ConceptNodeResp = components['schemas']['ConceptNodeResp']; +type ActionEdgeTypeCodeResp = components['schemas']['ActionEdgeTypeCodeResp']; + +interface CellEditPanelProps { + open: boolean; + actionNode: ConceptNodeResp; + role: ActionEdgeTypeCodeResp; + currentNodes: ConceptNodeResp[]; + onClose: () => void; + onSaved: () => void; +} + +const extractErrorMessage = (error: unknown): string => { + const fallback = '요청에 실패했습니다'; + if (!error || typeof error !== 'object') return fallback; + const responseData = (error as { response?: { data?: { message?: unknown } } }).response?.data + ?.message; + if (typeof responseData === 'string' && responseData.length > 0) return responseData; + const maybeMessage = (error as { message?: unknown }).message; + if (typeof maybeMessage === 'string' && maybeMessage.length > 0) return maybeMessage; + return fallback; +}; + +const sameIdSet = (a: ConceptNodeResp[], b: ConceptNodeResp[]): boolean => { + if (a.length !== b.length) return false; + const idsA = new Set(a.map((n) => n.id)); + for (const n of b) { + if (!idsA.has(n.id)) return false; + } + return true; +}; + +const formatNodeLabel = (node: ConceptNodeResp): string => { + const name = node.name ?? ''; + const typeLabel = node.nodeType?.label; + return typeLabel ? `${name} (${typeLabel})` : name; +}; + +const CellEditPanel = ({ + open, + actionNode, + role, + currentNodes, + onClose, + onSaved, +}: CellEditPanelProps) => { + const { invalidateConceptGraphActionEdges } = useInvalidate(); + + const [selectedNodes, setSelectedNodes] = useState(currentNodes); + const [query, setQuery] = useState(''); + const [debouncedQuery, setDebouncedQuery] = useState(''); + const [confirmCloseOpen, setConfirmCloseOpen] = useState(false); + + const putCellMutation = putSheetActionEdgeCell(); + + useEffect(() => { + if (open) { + setSelectedNodes(currentNodes); + setQuery(''); + setDebouncedQuery(''); + setConfirmCloseOpen(false); + } + }, [open, currentNodes]); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedQuery(query.trim()), 200); + return () => clearTimeout(timer); + }, [query]); + + const isDirty = useMemo( + () => !sameIdSet(selectedNodes, currentNodes), + [selectedNodes, currentNodes] + ); + + const sheetQuery = getSheetNode({ + page: 0, + size: 50, + name: debouncedQuery.length > 0 ? debouncedQuery : undefined, + }); + + const candidates: ConceptNodeResp[] = sheetQuery.data?.data ?? []; + + const filtered = useMemo(() => { + const selectedIds = new Set(selectedNodes.map((n) => n.id)); + return candidates.filter((c) => c.id !== undefined && !selectedIds.has(c.id)); + }, [candidates, selectedNodes]); + + const requestClose = () => { + if (isDirty) { + setConfirmCloseOpen(true); + return; + } + onClose(); + }; + + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + requestClose(); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [open, isDirty]); + + const handleAdd = (node: ConceptNodeResp) => { + if (node.id === undefined) return; + if (selectedNodes.some((n) => n.id === node.id)) return; + setSelectedNodes((prev) => [...prev, node]); + }; + + const handleRemove = (id: number | undefined) => { + if (id === undefined) return; + setSelectedNodes((prev) => prev.filter((n) => n.id !== id)); + }; + + const handleReset = () => { + setSelectedNodes(currentNodes); + }; + + const handleSave = async () => { + if (actionNode.id === undefined || role.id === undefined) return; + try { + await putCellMutation.mutateAsync({ + params: { + query: { actionNodeId: actionNode.id, roleId: role.id }, + }, + body: { + conceptNodeIds: selectedNodes + .map((n) => n.id) + .filter((id): id is number => id !== undefined), + }, + }); + await invalidateConceptGraphActionEdges(); + toast.success('저장되었습니다'); + onSaved(); + onClose(); + } catch (error) { + toast.error(extractErrorMessage(error)); + } + }; + + if (!open) return null; + + return ( + <> +