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..11e85b89f 100644 --- a/apps/admin/src/components/common/GNB.tsx +++ b/apps/admin/src/components/common/GNB.tsx @@ -14,6 +14,10 @@ import { Tags, MessageCircle, Bell, + Network, + Circle, + Activity, + Settings, } from 'lucide-react'; import { getStudent } from '@apis'; import { useSelectedStudent } from '@hooks'; @@ -264,6 +268,39 @@ const GNB = () => { /> + {/* Concept Graph Section */} +
+ 개념 그래프 + + } + label='개념 노드' + isCollapsed={isCollapsed} + /> + + } + label='개념 그래프' + isCollapsed={isCollapsed} + /> + + } + 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..5789f9193 --- /dev/null +++ b/apps/admin/src/components/conceptGraph/AddActionRowModal.tsx @@ -0,0 +1,299 @@ +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, getNodeType, 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 ACTION_NODE_TYPE_CODE = 'Action'; + +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 { data: nodeTypeData } = getNodeType(); + const actionNodeTypeId = useMemo(() => { + const match = nodeTypeData?.data.find((t) => t.code === ACTION_NODE_TYPE_CODE); + return match?.id; + }, [nodeTypeData]); + + 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 ( +
+
+
+
+ +
+

액션 노드 행 추가

+
+ +
+ +
+ + + {nodeTypeData && actionNodeTypeId === undefined && ( +
+ {ACTION_NODE_TYPE_CODE} 코드의 노드 타입이 없습니다. + 먼저 타입 관리 탭에서 액션 노드 타입을 추가해주세요. +
+ )} + +
+ + { + setValue('actionNodeId', id !== undefined ? String(id) : '', { + shouldValidate: true, + }); + setActionNodeCache(node); + }} + initialNode={actionNodeCache} + placeholder='액션 노드 선택' + hasError={Boolean(errors.actionNodeId)} + nodeTypeId={actionNodeTypeId} + disabled={actionNodeTypeId === undefined} + /> + {errors.actionNodeId && ( +

{errors.actionNodeId.message}

+ )} +

+ {ACTION_NODE_TYPE_CODE} 타입의 노드만 후보로 + 표시됩니다. +

+
+ +
+ + + {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 ( + <> +