diff --git a/apps/admin/src/apis/controller/analytics/getConceptHistory.ts b/apps/admin/src/apis/controller/analytics/getConceptHistory.ts
new file mode 100644
index 000000000..ae3cab720
--- /dev/null
+++ b/apps/admin/src/apis/controller/analytics/getConceptHistory.ts
@@ -0,0 +1,16 @@
+import { $api } from '@apis';
+
+const getConceptHistory = (studentId: number, options?: { enabled?: boolean }) => {
+ return $api.useQuery(
+ 'get',
+ '/api/admin/analytics/concept-history/{studentId}',
+ {
+ params: {
+ path: { studentId },
+ },
+ },
+ options
+ );
+};
+
+export default getConceptHistory;
diff --git a/apps/admin/src/apis/controller/analytics/index.ts b/apps/admin/src/apis/controller/analytics/index.ts
new file mode 100644
index 000000000..3cff2fbdc
--- /dev/null
+++ b/apps/admin/src/apis/controller/analytics/index.ts
@@ -0,0 +1,3 @@
+import getConceptHistory from './getConceptHistory';
+
+export { getConceptHistory };
diff --git a/apps/admin/src/apis/controller/conceptGraph/getNode.ts b/apps/admin/src/apis/controller/conceptGraph/getNode.ts
index dddecee26..77cb70d07 100644
--- a/apps/admin/src/apis/controller/conceptGraph/getNode.ts
+++ b/apps/admin/src/apis/controller/conceptGraph/getNode.ts
@@ -1,7 +1,15 @@
import { $api } from '@apis';
-const getNode = () => {
- return $api.useQuery('get', '/api/admin/concept/graph/node');
+interface GetNodeParams {
+ onlyFocusCardCandidates?: boolean;
+}
+
+const getNode = (params: GetNodeParams = {}) => {
+ return $api.useQuery('get', '/api/admin/concept/graph/node', {
+ params: {
+ query: params,
+ },
+ });
};
export default getNode;
diff --git a/apps/admin/src/apis/controller/focusCard/deleteFocusCard.ts b/apps/admin/src/apis/controller/focusCard/deleteFocusCard.ts
new file mode 100644
index 000000000..08b527fa3
--- /dev/null
+++ b/apps/admin/src/apis/controller/focusCard/deleteFocusCard.ts
@@ -0,0 +1,7 @@
+import { $api } from '@apis';
+
+const deleteFocusCard = () => {
+ return $api.useMutation('delete', '/api/admin/focus-card/{id}');
+};
+
+export default deleteFocusCard;
diff --git a/apps/admin/src/apis/controller/focusCard/deleteFocusCardIssuance.ts b/apps/admin/src/apis/controller/focusCard/deleteFocusCardIssuance.ts
new file mode 100644
index 000000000..27653036d
--- /dev/null
+++ b/apps/admin/src/apis/controller/focusCard/deleteFocusCardIssuance.ts
@@ -0,0 +1,7 @@
+import { $api } from '@apis';
+
+const deleteFocusCardIssuance = () => {
+ return $api.useMutation('delete', '/api/admin/focus-card/issuance/{id}');
+};
+
+export default deleteFocusCardIssuance;
diff --git a/apps/admin/src/apis/controller/focusCard/getFocusCardById.ts b/apps/admin/src/apis/controller/focusCard/getFocusCardById.ts
new file mode 100644
index 000000000..78748767e
--- /dev/null
+++ b/apps/admin/src/apis/controller/focusCard/getFocusCardById.ts
@@ -0,0 +1,11 @@
+import { $api } from '@apis';
+
+const getFocusCardById = (id: number) => {
+ return $api.useQuery('get', '/api/admin/focus-card/{id}', {
+ params: {
+ path: { id },
+ },
+ });
+};
+
+export default getFocusCardById;
diff --git a/apps/admin/src/apis/controller/focusCard/getFocusCardIssuanceByDate.ts b/apps/admin/src/apis/controller/focusCard/getFocusCardIssuanceByDate.ts
new file mode 100644
index 000000000..6edc04e25
--- /dev/null
+++ b/apps/admin/src/apis/controller/focusCard/getFocusCardIssuanceByDate.ts
@@ -0,0 +1,23 @@
+import { $api } from '@apis';
+import { GetFocusCardIssuanceByDateParams } from '@types';
+
+const getFocusCardIssuanceByDate = (
+ { studentId, issuedDate }: GetFocusCardIssuanceByDateParams,
+ options?: { enabled?: boolean }
+) => {
+ return $api.useQuery(
+ 'get',
+ '/api/admin/focus-card/issuance/by-date',
+ {
+ params: {
+ query: {
+ studentId,
+ ...(issuedDate ? { issuedDate } : {}),
+ },
+ },
+ },
+ options
+ );
+};
+
+export default getFocusCardIssuanceByDate;
diff --git a/apps/admin/src/apis/controller/focusCard/getFocusCardIssuanceCandidates.ts b/apps/admin/src/apis/controller/focusCard/getFocusCardIssuanceCandidates.ts
new file mode 100644
index 000000000..50dd70831
--- /dev/null
+++ b/apps/admin/src/apis/controller/focusCard/getFocusCardIssuanceCandidates.ts
@@ -0,0 +1,24 @@
+import { $api } from '@apis';
+import { GetFocusCardIssuanceCandidatesParams } from '@types';
+
+const getFocusCardIssuanceCandidates = (
+ { studentId, problemId, targetDate }: GetFocusCardIssuanceCandidatesParams,
+ options?: { enabled?: boolean }
+) => {
+ return $api.useQuery(
+ 'get',
+ '/api/admin/focus-card/issuance/candidates',
+ {
+ params: {
+ query: {
+ studentId,
+ problemId,
+ ...(targetDate ? { targetDate } : {}),
+ },
+ },
+ },
+ options
+ );
+};
+
+export default getFocusCardIssuanceCandidates;
diff --git a/apps/admin/src/apis/controller/focusCard/getFocusCardList.ts b/apps/admin/src/apis/controller/focusCard/getFocusCardList.ts
new file mode 100644
index 000000000..da0176773
--- /dev/null
+++ b/apps/admin/src/apis/controller/focusCard/getFocusCardList.ts
@@ -0,0 +1,7 @@
+import { $api } from '@apis';
+
+const getFocusCardList = () => {
+ return $api.useQuery('get', '/api/admin/focus-card');
+};
+
+export default getFocusCardList;
diff --git a/apps/admin/src/apis/controller/focusCard/index.ts b/apps/admin/src/apis/controller/focusCard/index.ts
new file mode 100644
index 000000000..fd2eaa6fd
--- /dev/null
+++ b/apps/admin/src/apis/controller/focusCard/index.ts
@@ -0,0 +1,23 @@
+import deleteFocusCard from './deleteFocusCard';
+import deleteFocusCardIssuance from './deleteFocusCardIssuance';
+import getFocusCardById from './getFocusCardById';
+import getFocusCardIssuanceByDate from './getFocusCardIssuanceByDate';
+import getFocusCardIssuanceCandidates from './getFocusCardIssuanceCandidates';
+import getFocusCardList from './getFocusCardList';
+import postFocusCard from './postFocusCard';
+import postFocusCardAutoIssue from './postFocusCardAutoIssue';
+import postFocusCardContent from './postFocusCardContent';
+import postFocusCardIssuance from './postFocusCardIssuance';
+
+export {
+ deleteFocusCard,
+ deleteFocusCardIssuance,
+ getFocusCardById,
+ getFocusCardIssuanceByDate,
+ getFocusCardIssuanceCandidates,
+ getFocusCardList,
+ postFocusCard,
+ postFocusCardAutoIssue,
+ postFocusCardContent,
+ postFocusCardIssuance,
+};
diff --git a/apps/admin/src/apis/controller/focusCard/postFocusCard.ts b/apps/admin/src/apis/controller/focusCard/postFocusCard.ts
new file mode 100644
index 000000000..73355b456
--- /dev/null
+++ b/apps/admin/src/apis/controller/focusCard/postFocusCard.ts
@@ -0,0 +1,7 @@
+import { $api } from '@apis';
+
+const postFocusCard = () => {
+ return $api.useMutation('post', '/api/admin/focus-card');
+};
+
+export default postFocusCard;
diff --git a/apps/admin/src/apis/controller/focusCard/postFocusCardAutoIssue.ts b/apps/admin/src/apis/controller/focusCard/postFocusCardAutoIssue.ts
new file mode 100644
index 000000000..cc2dfa2aa
--- /dev/null
+++ b/apps/admin/src/apis/controller/focusCard/postFocusCardAutoIssue.ts
@@ -0,0 +1,7 @@
+import { $api } from '@apis';
+
+const postFocusCardAutoIssue = () => {
+ return $api.useMutation('post', '/api/admin/focus-card/auto-issue');
+};
+
+export default postFocusCardAutoIssue;
diff --git a/apps/admin/src/apis/controller/focusCard/postFocusCardContent.ts b/apps/admin/src/apis/controller/focusCard/postFocusCardContent.ts
new file mode 100644
index 000000000..ed9c46804
--- /dev/null
+++ b/apps/admin/src/apis/controller/focusCard/postFocusCardContent.ts
@@ -0,0 +1,7 @@
+import { $api } from '@apis';
+
+const postFocusCardContent = () => {
+ return $api.useMutation('post', '/api/admin/focus-card/{id}/content');
+};
+
+export default postFocusCardContent;
diff --git a/apps/admin/src/apis/controller/focusCard/postFocusCardIssuance.ts b/apps/admin/src/apis/controller/focusCard/postFocusCardIssuance.ts
new file mode 100644
index 000000000..8ae53b5e6
--- /dev/null
+++ b/apps/admin/src/apis/controller/focusCard/postFocusCardIssuance.ts
@@ -0,0 +1,7 @@
+import { $api } from '@apis';
+
+const postFocusCardIssuance = () => {
+ return $api.useMutation('post', '/api/admin/focus-card/issuance');
+};
+
+export default postFocusCardIssuance;
diff --git a/apps/admin/src/apis/controller/publish/deletePublishFocusCardLink.ts b/apps/admin/src/apis/controller/publish/deletePublishFocusCardLink.ts
new file mode 100644
index 000000000..fdfb1e160
--- /dev/null
+++ b/apps/admin/src/apis/controller/publish/deletePublishFocusCardLink.ts
@@ -0,0 +1,7 @@
+import { $api } from '@apis';
+
+const deletePublishFocusCardLink = () => {
+ return $api.useMutation('delete', '/api/admin/publish-focus-card-links/{linkId}');
+};
+
+export default deletePublishFocusCardLink;
diff --git a/apps/admin/src/apis/controller/publish/getPublishFocusCardLinkCandidates.ts b/apps/admin/src/apis/controller/publish/getPublishFocusCardLinkCandidates.ts
new file mode 100644
index 000000000..dee69641a
--- /dev/null
+++ b/apps/admin/src/apis/controller/publish/getPublishFocusCardLinkCandidates.ts
@@ -0,0 +1,24 @@
+import { $api } from '@apis';
+import { PostPublishFocusCardLinkCandidatesParams } from '@types';
+
+// HTTP 메서드는 POST지만 read-only 후보 조회 — body 파라미터 크기가 커서 BE가 POST 로 노출.
+// 의미상 query 이므로 useQuery 사용 (enabled / 캐시 / staleness 정상 동작).
+const getPublishFocusCardLinkCandidates = (
+ { studentId, problemSetId, targetDate }: PostPublishFocusCardLinkCandidatesParams,
+ options?: { enabled?: boolean }
+) => {
+ return $api.useQuery(
+ 'post',
+ '/api/admin/publish/focus-card-link-candidates',
+ {
+ body: {
+ studentId,
+ problemSetId,
+ ...(targetDate ? { targetDate } : {}),
+ },
+ },
+ options
+ );
+};
+
+export default getPublishFocusCardLinkCandidates;
diff --git a/apps/admin/src/apis/controller/publish/index.ts b/apps/admin/src/apis/controller/publish/index.ts
index 446c609f5..d2225d3ef 100644
--- a/apps/admin/src/apis/controller/publish/index.ts
+++ b/apps/admin/src/apis/controller/publish/index.ts
@@ -1,6 +1,17 @@
import deletePublish from './deletePublish';
+import deletePublishFocusCardLink from './deletePublishFocusCardLink';
import getPublish from './getPublish';
import getPublishById from './getPublishById';
+import getPublishFocusCardLinkCandidates from './getPublishFocusCardLinkCandidates';
import postPublish from './postPublish';
+import postPublishFocusCardLink from './postPublishFocusCardLink';
-export { deletePublish, getPublish, getPublishById, postPublish };
+export {
+ deletePublish,
+ deletePublishFocusCardLink,
+ getPublish,
+ getPublishById,
+ getPublishFocusCardLinkCandidates,
+ postPublish,
+ postPublishFocusCardLink,
+};
diff --git a/apps/admin/src/apis/controller/publish/postPublishFocusCardLink.ts b/apps/admin/src/apis/controller/publish/postPublishFocusCardLink.ts
new file mode 100644
index 000000000..e80d7dc0c
--- /dev/null
+++ b/apps/admin/src/apis/controller/publish/postPublishFocusCardLink.ts
@@ -0,0 +1,7 @@
+import { $api } from '@apis';
+
+const postPublishFocusCardLink = () => {
+ return $api.useMutation('post', '/api/admin/publishes/{publishId}/focus-card-links');
+};
+
+export default postPublishFocusCardLink;
diff --git a/apps/admin/src/apis/index.ts b/apps/admin/src/apis/index.ts
index 968deeacd..6b3e47b67 100644
--- a/apps/admin/src/apis/index.ts
+++ b/apps/admin/src/apis/index.ts
@@ -2,12 +2,14 @@
export { $api } from './client';
// controllers
+export * from './controller/analytics';
export * from './controller/auth';
export * from './controller/concept';
export * from './controller/conceptGraph';
export * from './controller/dailyComment';
export * from './controller/diagnosis';
export * from './controller/file';
+export * from './controller/focusCard';
export * from './controller/menu';
export * from './controller/mockExam';
export * from './controller/notice';
diff --git a/apps/admin/src/components/common/GNB.tsx b/apps/admin/src/components/common/GNB.tsx
index a2a1dc369..a6cde86ae 100644
--- a/apps/admin/src/components/common/GNB.tsx
+++ b/apps/admin/src/components/common/GNB.tsx
@@ -1,11 +1,11 @@
import { useState, useEffect, useRef } from 'react';
-import { Link } from '@tanstack/react-router';
+import { Link, useLocation } from '@tanstack/react-router';
import { GraduationCap, Search, ChevronDown, ChevronRight, LogOut } from 'lucide-react';
import { getStudent } from '@apis';
import { useAdminSession, useSelectedStudent } from '@hooks';
import { components } from '@schema';
-import { getAccessibleNavSections } from '@/constants/adminPermissions';
+import { getAccessibleNavSections, getMostSpecificNavItem } from '@/constants/adminPermissions';
import { useSidebar } from '@/contexts/SidebarContext';
import { logout } from '@/utils';
@@ -14,27 +14,22 @@ interface NavItemProps {
icon: React.ReactNode;
label: string;
isCollapsed: boolean;
+ isActive: boolean;
}
-const NavItem = ({ to, icon, label, isCollapsed }: NavItemProps) => {
+const NavItem = ({ to, icon, label, isCollapsed, isActive }: NavItemProps) => {
return (
-
- {({ isActive }) => (
+
+
-
- {icon}
-
-
{label}
+ className={`flex h-5 w-5 flex-shrink-0 items-center justify-center transition-transform duration-300`}>
+ {icon}
- )}
+
{label}
+
);
};
@@ -64,6 +59,8 @@ const GNB = () => {
const searchInputRef = useRef(null);
const session = useAdminSession();
const navSections = getAccessibleNavSections(session);
+ const { pathname } = useLocation();
+ const activeNavMenuName = getMostSpecificNavItem(pathname)?.menuName ?? null;
const studentManagementSection = navSections.find(
(section) => section.title === '개별 학생 관리'
);
@@ -221,6 +218,7 @@ const GNB = () => {
icon={}
label={item.label}
isCollapsed={isCollapsed}
+ isActive={activeNavMenuName === item.menuName}
/>
);
})}
@@ -242,6 +240,7 @@ const GNB = () => {
icon={}
label={item.label}
isCollapsed={isCollapsed}
+ isActive={activeNavMenuName === item.menuName}
/>
);
})}
diff --git a/apps/admin/src/components/focusCard/FocusCardActionNodePicker.tsx b/apps/admin/src/components/focusCard/FocusCardActionNodePicker.tsx
new file mode 100644
index 000000000..11bd02bad
--- /dev/null
+++ b/apps/admin/src/components/focusCard/FocusCardActionNodePicker.tsx
@@ -0,0 +1,143 @@
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { ChevronDown, Search, X } from 'lucide-react';
+import { getNode } from '@apis';
+import type { components } from '@schema';
+
+type ConceptNodeResp = components['schemas']['ConceptNodeResp'];
+
+interface FocusCardActionNodePickerProps {
+ value: number | undefined;
+ onChange: (id: number | undefined, node?: ConceptNodeResp) => void;
+ placeholder?: string;
+ hasError?: boolean;
+}
+
+const FocusCardActionNodePicker = ({
+ value,
+ onChange,
+ placeholder = 'Action Node 검색',
+ hasError,
+}: FocusCardActionNodePickerProps) => {
+ const [query, setQuery] = useState('');
+ const [isOpen, setIsOpen] = useState(false);
+ const containerRef = useRef(null);
+ const searchInputRef = useRef(null);
+
+ // 활성 집중학습 카드가 없는 Action 노드만 반환 — 신규 카드 생성 후보 목록.
+ const { data, isLoading } = getNode({ onlyFocusCardCandidates: true });
+ const candidates: ConceptNodeResp[] = data?.data ?? [];
+
+ useEffect(() => {
+ if (!isOpen) return;
+ const handler = (e: MouseEvent) => {
+ if (!containerRef.current?.contains(e.target as Node)) setIsOpen(false);
+ };
+ window.addEventListener('mousedown', handler);
+ return () => window.removeEventListener('mousedown', handler);
+ }, [isOpen]);
+
+ useEffect(() => {
+ if (isOpen) {
+ const t = setTimeout(() => searchInputRef.current?.focus(), 0);
+ return () => clearTimeout(t);
+ }
+ }, [isOpen]);
+
+ const filtered = useMemo(() => {
+ const trimmed = query.trim().toLowerCase();
+ if (!trimmed) return candidates;
+ return candidates.filter((n) => (n.name ?? '').toLowerCase().includes(trimmed));
+ }, [candidates, query]);
+
+ const selectedNode = useMemo(
+ () => (value !== undefined ? candidates.find((n) => n.id === value) : undefined),
+ [candidates, value]
+ );
+
+ const handleSelect = (node: ConceptNodeResp) => {
+ if (node.id === undefined) return;
+ onChange(node.id, node);
+ setIsOpen(false);
+ setQuery('');
+ };
+
+ const handleClear = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ onChange(undefined, undefined);
+ };
+
+ const triggerClassName = `flex h-12 w-full items-center justify-between gap-2 rounded-xl border bg-white px-4 text-sm transition-all duration-200 ${
+ hasError
+ ? 'border-red-300 focus:border-red-500'
+ : 'border-gray-200 hover:border-gray-300 focus:border-main'
+ } ${isOpen ? 'ring-main/20 ring-2' : ''}`;
+
+ return (
+
+
+
+ {isOpen && (
+
+
+
+
+ setQuery(e.target.value)}
+ placeholder='이름으로 검색...'
+ className='w-full py-3 pr-4 pl-10 text-sm font-medium focus:ring-0 focus:outline-none'
+ />
+
+
+
+ {isLoading ? (
+
불러오는 중...
+ ) : filtered.length === 0 ? (
+
+ {candidates.length === 0
+ ? '카드를 만들 수 있는 Action 노드가 없습니다.'
+ : '일치하는 노드가 없습니다.'}
+
+ ) : (
+ filtered.map((node, idx) => {
+ const isSelected = node.id !== undefined && node.id === value;
+ return (
+
handleSelect(node)}
+ className={`mb-1 cursor-pointer rounded-xl px-4 py-3 text-sm font-medium transition-all duration-200 ${
+ isSelected ? 'bg-main text-white' : 'text-gray-700 hover:bg-gray-100'
+ }`}>
+ {node.name ?? ''}
+
+ );
+ })
+ )}
+
+
+ )}
+
+ );
+};
+
+export default FocusCardActionNodePicker;
diff --git a/apps/admin/src/components/focusCard/index.ts b/apps/admin/src/components/focusCard/index.ts
new file mode 100644
index 000000000..01c9d622e
--- /dev/null
+++ b/apps/admin/src/components/focusCard/index.ts
@@ -0,0 +1,3 @@
+import FocusCardActionNodePicker from './FocusCardActionNodePicker';
+
+export { FocusCardActionNodePicker };
diff --git a/apps/admin/src/constants/adminPermissions.ts b/apps/admin/src/constants/adminPermissions.ts
index 50c77ada1..1d25573d2 100644
--- a/apps/admin/src/constants/adminPermissions.ts
+++ b/apps/admin/src/constants/adminPermissions.ts
@@ -6,6 +6,7 @@ import {
Circle,
ClipboardList,
FileText,
+ Layers,
ListChecks,
Megaphone,
MessageCircle,
@@ -13,6 +14,7 @@ import {
NotebookPen,
Package,
Settings,
+ Sparkles,
Tags,
Users,
} from 'lucide-react';
@@ -25,11 +27,13 @@ export type AdminMenuName =
| 'DIAGNOSIS'
| 'MOCK_EXAM_RESULT'
| 'MOCK_EXAM_TYPE'
+ | 'FOCUS_CARD_ISSUANCE'
| 'QNA'
| 'DAILY_COMMENT'
| 'PROBLEM_ITEM'
| 'PROBLEM_SET'
| 'CONCEPT_TAG'
+ | 'FOCUS_CARD'
| 'GRAPH_NODE'
| 'GRAPH_EDGE'
| 'GRAPH_ACTION'
@@ -154,6 +158,25 @@ export const ADMIN_NAV_SECTIONS: AdminNavSection[] = [
},
],
},
+ {
+ title: '집중학습',
+ items: [
+ {
+ menuName: 'FOCUS_CARD',
+ to: '/focus-card',
+ label: '집중학습 카드',
+ icon: Sparkles,
+ routePrefixes: ['/focus-card'],
+ },
+ {
+ menuName: 'FOCUS_CARD_ISSUANCE',
+ to: '/focus-card/issuance',
+ label: '집중학습 카드 발급',
+ icon: Layers,
+ routePrefixes: ['/focus-card/issuance'],
+ },
+ ],
+ },
{
title: '개념 그래프',
items: [
@@ -245,18 +268,31 @@ export const getFirstAccessibleRoute = (session: AdminSession | null) => {
return getAccessibleNavSections(session)[0]?.items[0]?.to ?? null;
};
-export const canAccessPath = (session: AdminSession | null, pathname: string) => {
- if (pathname === '/') return true;
+// 현재 pathname 에 대해 가장 구체적(prefix 가 가장 긴) routePrefix 를 가진 nav item 을 반환.
+// 그렇지 않으면 `/focus-card` 가 `/focus-card/issuance` 까지 흡수해 권한 누수/active 표시 중복이 발생한다.
+export const getMostSpecificNavItem = (pathname: string): AdminNavItem | null => {
+ const matches: { item: AdminNavItem; prefix: string }[] = [];
+ ADMIN_NAV_SECTIONS.forEach((section) => {
+ section.items.forEach((item) => {
+ item.routePrefixes.forEach((prefix) => {
+ if (pathname === prefix || pathname.startsWith(`${prefix}/`)) {
+ matches.push({ item, prefix });
+ }
+ });
+ });
+ });
- return ADMIN_NAV_SECTIONS.some((section) =>
- section.items.some((item) => {
- if (!hasMenuPermission(session, item.menuName)) return false;
+ if (matches.length === 0) return null;
- return item.routePrefixes.some(
- (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`)
- );
- })
- );
+ matches.sort((a, b) => b.prefix.length - a.prefix.length);
+ return matches[0].item;
+};
+
+export const canAccessPath = (session: AdminSession | null, pathname: string) => {
+ if (pathname === '/') return true;
+ const item = getMostSpecificNavItem(pathname);
+ if (!item) return false;
+ return hasMenuPermission(session, item.menuName);
};
export const toAdminSession = (data: components['schemas']['AdminTokenResp']): AdminSession => ({
diff --git a/apps/admin/src/hooks/index.ts b/apps/admin/src/hooks/index.ts
index 76aa292de..b9b29dd64 100644
--- a/apps/admin/src/hooks/index.ts
+++ b/apps/admin/src/hooks/index.ts
@@ -1,3 +1,5 @@
+import useActionNodeDetail from './useActionNodeDetail';
+import useActionNodeTypeId from './useActionNodeTypeId';
import useAdminSession from './useAdminSession';
import useModal from './useModal';
import useNavigation from './useNavigation';
@@ -8,6 +10,8 @@ import useEditor from './useEditor';
import { useSelectedStudent } from '@/contexts/SelectedStudentContext';
export {
+ useActionNodeDetail,
+ useActionNodeTypeId,
useAdminSession,
useModal,
useNavigation,
diff --git a/apps/admin/src/hooks/useActionNodeDetail.ts b/apps/admin/src/hooks/useActionNodeDetail.ts
new file mode 100644
index 000000000..db0cd5370
--- /dev/null
+++ b/apps/admin/src/hooks/useActionNodeDetail.ts
@@ -0,0 +1,18 @@
+import { useMemo } from 'react';
+import { getNode } from '@apis';
+import type { components } from '@schema';
+
+type ConceptNodeResp = components['schemas']['ConceptNodeResp'];
+
+// FocusCardResp.actionNode 는 { id, name } 만 담은 요약이라 description / payload 등의 상세 정보를
+// 얻으려면 별도 lookup 이 필요하다. BE 에 단건 GET 이 없어 전체 노드 목록에서 id 매칭으로 찾는다.
+// 결과는 TanStack Query 캐시로 공유되므로 페이지 간 재호출 비용은 낮다.
+const useActionNodeDetail = (id: number | undefined): ConceptNodeResp | undefined => {
+ const { data } = getNode();
+ return useMemo(() => {
+ if (id === undefined) return undefined;
+ return data?.data?.find((n) => n.id === id);
+ }, [data, id]);
+};
+
+export default useActionNodeDetail;
diff --git a/apps/admin/src/hooks/useActionNodeTypeId.ts b/apps/admin/src/hooks/useActionNodeTypeId.ts
new file mode 100644
index 000000000..6f14bbe59
--- /dev/null
+++ b/apps/admin/src/hooks/useActionNodeTypeId.ts
@@ -0,0 +1,11 @@
+import { getNodeType } from '@apis';
+
+import { ACTION_NODE_TYPE_CODE } from '@/components/conceptGraph';
+
+const useActionNodeTypeId = (): number | undefined => {
+ const { data } = getNodeType();
+ const action = data?.data?.find((t) => t.code === ACTION_NODE_TYPE_CODE);
+ return action?.id;
+};
+
+export default useActionNodeTypeId;
diff --git a/apps/admin/src/hooks/useInvalidate.ts b/apps/admin/src/hooks/useInvalidate.ts
index 22c9e1c94..d6589ded6 100644
--- a/apps/admin/src/hooks/useInvalidate.ts
+++ b/apps/admin/src/hooks/useInvalidate.ts
@@ -167,6 +167,55 @@ const useInvalidate = () => {
]);
}, [queryClient]);
+ const invalidateFocusCardList = useCallback(() => {
+ return queryClient.invalidateQueries({
+ queryKey: $api.queryOptions('get', '/api/admin/focus-card').queryKey,
+ });
+ }, [queryClient]);
+
+ const invalidateFocusCard = useCallback(
+ (id: number) => {
+ return Promise.all([
+ queryClient.invalidateQueries({
+ queryKey: $api.queryOptions('get', '/api/admin/focus-card/{id}', {
+ params: { path: { id } },
+ }).queryKey,
+ }),
+ queryClient.invalidateQueries({
+ queryKey: $api.queryOptions('get', '/api/admin/focus-card').queryKey,
+ }),
+ ]);
+ },
+ [queryClient]
+ );
+
+ const invalidateFocusCardIssuanceByDate = useCallback(
+ (studentId: number, issuedDate?: string) => {
+ return queryClient.invalidateQueries({
+ queryKey: $api.queryOptions('get', '/api/admin/focus-card/issuance/by-date', {
+ params: {
+ query: {
+ studentId,
+ ...(issuedDate ? { issuedDate } : {}),
+ },
+ },
+ }).queryKey,
+ });
+ },
+ [queryClient]
+ );
+
+ const invalidatePublishDetail = useCallback(
+ (publishId: number) => {
+ return queryClient.invalidateQueries({
+ queryKey: $api.queryOptions('get', '/api/admin/publish/{id}', {
+ params: { path: { id: publishId } },
+ }).queryKey,
+ });
+ },
+ [queryClient]
+ );
+
const invalidateConceptGraphTypes = useCallback(() => {
return Promise.all([
queryClient.invalidateQueries({
@@ -207,6 +256,10 @@ const useInvalidate = () => {
invalidateConceptGraphEdges,
invalidateConceptGraphActionEdges,
invalidateConceptGraphTypes,
+ invalidateFocusCardList,
+ invalidateFocusCard,
+ invalidateFocusCardIssuanceByDate,
+ invalidatePublishDetail,
};
};
diff --git a/apps/admin/src/routes/_GNBLayout/focus-card/$focusCardId/index.tsx b/apps/admin/src/routes/_GNBLayout/focus-card/$focusCardId/index.tsx
new file mode 100644
index 000000000..55a61f10e
--- /dev/null
+++ b/apps/admin/src/routes/_GNBLayout/focus-card/$focusCardId/index.tsx
@@ -0,0 +1,241 @@
+import { deleteFocusCard, getFocusCardById, postFocusCardContent } from '@apis';
+import { Button, Header, Modal, TwoButtonModalTemplate } from '@components';
+import { useActionNodeDetail, useInvalidate, useModal } from '@hooks';
+import { InlineProblemViewer } from '@repo/pointer-editor-v2';
+import { createFileRoute, useNavigate } from '@tanstack/react-router';
+import { Save, Trash2 } from 'lucide-react';
+import { useEffect } from 'react';
+import { useForm } from 'react-hook-form';
+import { Slide, toast, ToastContainer } from 'react-toastify';
+import { getEmptyContentString } from '@utils';
+
+import EditorField from '@/components/problem/EditorField';
+
+import '@repo/pointer-editor-v2/style.css';
+
+export const Route = createFileRoute('/_GNBLayout/focus-card/$focusCardId/')({
+ component: RouteComponent,
+});
+
+interface FormValues {
+ title: string;
+ description: string;
+ content: string;
+}
+
+function RouteComponent() {
+ const navigate = useNavigate();
+ const { focusCardId } = Route.useParams();
+ const id = Number(focusCardId);
+
+ const { invalidateFocusCard, invalidateFocusCardList } = useInvalidate();
+ const { data: card, isLoading } = getFocusCardById(id);
+ const actionNodeDetail = useActionNodeDetail(card?.actionNode.id);
+ const { mutate: mutateUpdate, isPending: isUpdating } = postFocusCardContent();
+ const { mutate: mutateDelete } = deleteFocusCard();
+
+ const {
+ isOpen: isDeleteModalOpen,
+ openModal: openDeleteModal,
+ closeModal: closeDeleteModal,
+ } = useModal();
+
+ const { control, handleSubmit, reset } = useForm({
+ defaultValues: {
+ title: getEmptyContentString(),
+ description: getEmptyContentString(),
+ content: getEmptyContentString(),
+ },
+ });
+
+ useEffect(() => {
+ if (!card) return;
+ reset({
+ title: card.title,
+ description: card.description,
+ content: card.content,
+ });
+ }, [card, reset]);
+
+ const onSubmit = handleSubmit((data) => {
+ mutateUpdate(
+ {
+ params: { path: { id } },
+ body: {
+ title: data.title,
+ description: data.description,
+ content: data.content,
+ },
+ },
+ {
+ onSuccess: () => {
+ invalidateFocusCard(id);
+ toast.success('카드 내용이 저장되었습니다');
+ },
+ onError: (error: Error) => {
+ toast.error(error.message);
+ },
+ }
+ );
+ });
+
+ const handleConfirmDelete = () => {
+ mutateDelete(
+ { params: { path: { id } } },
+ {
+ onSuccess: () => {
+ invalidateFocusCardList();
+ toast.success('카드가 삭제되었습니다');
+ navigate({ to: '/focus-card' });
+ },
+ onError: (error: Error) => {
+ toast.error(error.message);
+ },
+ }
+ );
+ closeDeleteModal();
+ };
+
+ if (isLoading || !card) {
+ return (
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/admin/src/routes/_GNBLayout/focus-card/index.tsx b/apps/admin/src/routes/_GNBLayout/focus-card/index.tsx
new file mode 100644
index 000000000..3d3e483f4
--- /dev/null
+++ b/apps/admin/src/routes/_GNBLayout/focus-card/index.tsx
@@ -0,0 +1,142 @@
+import { deleteFocusCard, getFocusCardList } from '@apis';
+import { Header, Modal, TwoButtonModalTemplate } from '@components';
+import { useInvalidate, useModal } from '@hooks';
+import { InlineProblemViewer } from '@repo/pointer-editor-v2';
+import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';
+import { Plus, Sparkles, Trash2 } from 'lucide-react';
+import { useState } from 'react';
+import { Slide, toast, ToastContainer } from 'react-toastify';
+
+import '@repo/pointer-editor-v2/style.css';
+
+export const Route = createFileRoute('/_GNBLayout/focus-card/')({
+ component: RouteComponent,
+});
+
+function RouteComponent() {
+ const navigate = useNavigate();
+ const { invalidateFocusCardList } = useInvalidate();
+ const { data: listResp, isLoading } = getFocusCardList();
+ const { mutate: mutateDelete } = deleteFocusCard();
+
+ const {
+ isOpen: isDeleteModalOpen,
+ openModal: openDeleteModal,
+ closeModal: closeDeleteModal,
+ } = useModal();
+ const [deleteTarget, setDeleteTarget] = useState<{ id: number; name: string } | null>(null);
+
+ const handleClickDelete = (e: React.MouseEvent, id: number, name: string) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setDeleteTarget({ id, name });
+ openDeleteModal();
+ };
+
+ const handleConfirmDelete = () => {
+ if (!deleteTarget) return;
+ mutateDelete(
+ { params: { path: { id: deleteTarget.id } } },
+ {
+ onSuccess: () => {
+ invalidateFocusCardList();
+ toast.success('카드가 삭제되었습니다');
+ },
+ onError: (error: Error) => {
+ toast.error(error.message);
+ },
+ }
+ );
+ closeDeleteModal();
+ setDeleteTarget(null);
+ };
+
+ const cards = listResp?.data ?? [];
+
+ return (
+ <>
+
+
+
+ navigate({ to: '/focus-card/register' })}>
+ 신규 생성
+
+
+
+
+ {isLoading ? (
+
+ ) : cards.length === 0 ? (
+
+
+
아직 등록된 카드가 없습니다
+
+ 우측 상단 '신규 생성' 버튼으로 첫 카드를 만들어 보세요.
+
+
+ ) : (
+
+ {cards.map((card) => (
+
+
+
+ {card.actionNode.name}
+
+
+
+
+
+
설명
+
{card.description}
+
+
+ ))}
+
+ )}
+
+
+
+
+ {
+ closeDeleteModal();
+ setDeleteTarget(null);
+ }}
+ handleClickRightButton={handleConfirmDelete}
+ />
+
+ >
+ );
+}
diff --git a/apps/admin/src/routes/_GNBLayout/focus-card/issuance/index.tsx b/apps/admin/src/routes/_GNBLayout/focus-card/issuance/index.tsx
new file mode 100644
index 000000000..e72275a12
--- /dev/null
+++ b/apps/admin/src/routes/_GNBLayout/focus-card/issuance/index.tsx
@@ -0,0 +1,392 @@
+import {
+ deleteFocusCardIssuance,
+ getConceptHistory,
+ getFocusCardIssuanceByDate,
+ postFocusCardAutoIssue,
+ postFocusCardIssuance,
+} from '@apis';
+import { Button, Header, Modal, TwoButtonModalTemplate } from '@components';
+import { useActionNodeTypeId, useInvalidate, useModal, useSelectedStudent } from '@hooks';
+import { InlineProblemViewer } from '@repo/pointer-editor-v2';
+import { createFileRoute } from '@tanstack/react-router';
+import dayjs from 'dayjs';
+import { AlertTriangle, Layers, Plus, Trash2, Wand2 } from 'lucide-react';
+import { useMemo, useState } from 'react';
+import { Slide, toast, ToastContainer } from 'react-toastify';
+
+import { NodeSearchSelect } from '@/components/conceptGraph';
+
+import '@repo/pointer-editor-v2/style.css';
+
+export const Route = createFileRoute('/_GNBLayout/focus-card/issuance/')({
+ component: RouteComponent,
+});
+
+function RouteComponent() {
+ const { selectedStudent } = useSelectedStudent();
+ const { invalidateFocusCardIssuanceByDate } = useInvalidate();
+ const actionNodeTypeId = useActionNodeTypeId();
+
+ const [issuedDate, setIssuedDate] = useState(dayjs().format('YYYY-MM-DD'));
+
+ const studentId = selectedStudent?.id ?? 0;
+ const isStudentSelected = !!selectedStudent;
+
+ const { data: issuanceResp, isLoading } = getFocusCardIssuanceByDate(
+ { studentId, issuedDate },
+ { enabled: isStudentSelected }
+ );
+
+ const { data: conceptHistory, isLoading: isHistoryLoading } = getConceptHistory(studentId, {
+ enabled: isStudentSelected,
+ });
+
+ const vulnerableConcepts = useMemo(() => {
+ const stats = conceptHistory?.conceptStats ?? [];
+ // 시도 횟수 5회 이상 + 정답률 낮은 순. 시도 횟수가 너무 적으면 노이즈라 제외.
+ return [...stats]
+ .filter((s) => (s.totalAttempts ?? 0) >= 5)
+ .sort((a, b) => (a.correctRate ?? 100) - (b.correctRate ?? 100))
+ .slice(0, 5);
+ }, [conceptHistory]);
+
+ const { mutate: mutateIssue, isPending: isIssuing } = postFocusCardIssuance();
+ const { mutate: mutateRevoke } = deleteFocusCardIssuance();
+ const { mutate: mutateAutoIssue, isPending: isAutoIssuing } = postFocusCardAutoIssue();
+
+ const { isOpen: isIssueOpen, openModal: openIssue, closeModal: closeIssue } = useModal();
+ const { isOpen: isRevokeOpen, openModal: openRevoke, closeModal: closeRevoke } = useModal();
+ const {
+ isOpen: isAutoIssueOpen,
+ openModal: openAutoIssue,
+ closeModal: closeAutoIssue,
+ } = useModal();
+
+ const [revokeTarget, setRevokeTarget] = useState<{ id: number; name: string } | null>(null);
+ const [issueActionNodeId, setIssueActionNodeId] = useState(undefined);
+ const [issueActionNodeError, setIssueActionNodeError] = useState(false);
+
+ const issuances = issuanceResp?.data ?? [];
+
+ const handleClickRevoke = (id: number, name: string) => {
+ setRevokeTarget({ id, name });
+ openRevoke();
+ };
+
+ const handleConfirmRevoke = () => {
+ if (!revokeTarget) return;
+ mutateRevoke(
+ { params: { path: { id: revokeTarget.id } } },
+ {
+ onSuccess: () => {
+ invalidateFocusCardIssuanceByDate(studentId, issuedDate);
+ toast.success('발급이 취소되었습니다');
+ },
+ onError: (error: Error) => {
+ toast.error(error.message);
+ },
+ }
+ );
+ closeRevoke();
+ setRevokeTarget(null);
+ };
+
+ const handleSubmitIssue = () => {
+ if (!issueActionNodeId) {
+ setIssueActionNodeError(true);
+ return;
+ }
+ setIssueActionNodeError(false);
+ mutateIssue(
+ {
+ body: {
+ studentId,
+ actionNodeId: issueActionNodeId,
+ issuedDate,
+ },
+ },
+ {
+ onSuccess: () => {
+ invalidateFocusCardIssuanceByDate(studentId, issuedDate);
+ toast.success('카드가 발급되었습니다');
+ closeIssue();
+ setIssueActionNodeId(undefined);
+ },
+ onError: (error: Error) => {
+ toast.error(error.message);
+ },
+ }
+ );
+ };
+
+ const handleConfirmAutoIssue = () => {
+ mutateAutoIssue(
+ { params: { query: { studentId } } },
+ {
+ onSuccess: (data) => {
+ invalidateFocusCardIssuanceByDate(studentId, issuedDate);
+ toast.success(`자동 발급 완료: ${data?.total ?? 0}개 카드 발급`);
+ },
+ onError: (error: Error) => {
+ toast.error(error.message);
+ },
+ }
+ );
+ closeAutoIssue();
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
+
학생
+
+ {selectedStudent ? selectedStudent.name : '좌측에서 학생을 선택해 주세요'}
+
+
+
+
+ setIssuedDate(e.target.value)}
+ className='focus:border-main focus:ring-main/20 rounded-xl border border-gray-200 px-4 py-2.5 text-sm font-medium focus:ring-2 focus:outline-none'
+ />
+
+
+
+ {isStudentSelected && (
+
+
+
+
학생 취약점
+
+ (시도 5회 이상 · 정답률 낮은 순 상위 5개)
+
+
+
+ {isHistoryLoading ? (
+ 취약점 분석 중...
+ ) : vulnerableConcepts.length === 0 ? (
+
+ 분석할 만큼 풀이 데이터가 충분하지 않습니다. (개념별 5회 이상 시도 필요)
+
+ ) : (
+
+ {vulnerableConcepts.map((stat) => {
+ const rate = stat.correctRate ?? 0;
+ const isCritical = rate < 40;
+ const isWarning = rate >= 40 && rate < 60;
+ return (
+
+
+ {stat.conceptName ?? '이름 없음'}
+
+
+
+ {Math.round(rate)}%
+
+ 정답률
+
+
+ {stat.correctCount ?? 0} / {stat.totalAttempts ?? 0}회 정답
+
+
+ );
+ })}
+
+ )}
+
+ {vulnerableConcepts.length > 0 && (
+
+ ※ 개념(Concept)과 카드의 Action Node는 다른 축이므로, 위 취약 개념을 참고해 직접
+ '카드 발급'으로 적절한 Action Node를 골라 발급하세요.
+
+ )}
+
+ )}
+
+ {!isStudentSelected ? (
+
+
+
학생을 선택해 주세요
+
+ 사이드바의 '학생 선택'에서 학생을 고른 뒤 발급 내역을 확인할 수 있어요.
+
+
+ ) : isLoading ? (
+
+ ) : issuances.length === 0 ? (
+
+
+ 해당 일자에 발급된 카드가 없습니다
+
+
+ 상단 '카드 발급' 또는 '자동 발급 실행' 버튼을 눌러 보세요.
+
+
+ ) : (
+
+ {issuances.map((issuance) => (
+
+
+
+
+ {issuance.card.actionNode.name}
+
+
+ {issuance.issuedType === 'ADMIN' ? '관리자 발급' : '자동 발급'}
+
+
+
+
+
+
제목
+
{issuance.card.title}
+
+
+
설명
+
+ {issuance.card.description}
+
+
+
+ ))}
+
+ )}
+
+
+
+ {
+ closeIssue();
+ setIssueActionNodeId(undefined);
+ setIssueActionNodeError(false);
+ }}>
+
+
카드 발급
+
+
학생
+
{selectedStudent?.name}
+
+
+
+
+ {actionNodeTypeId === undefined ? (
+
+ Action Node 타입 정보를 불러오는 중입니다...
+
+ ) : (
+
{
+ setIssueActionNodeId(id);
+ if (id !== undefined) setIssueActionNodeError(false);
+ }}
+ nodeTypeId={actionNodeTypeId}
+ placeholder='Action Node 검색'
+ hasError={issueActionNodeError}
+ />
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/admin/src/routes/_GNBLayout/focus-card/register/index.tsx b/apps/admin/src/routes/_GNBLayout/focus-card/register/index.tsx
new file mode 100644
index 000000000..b0d7995d5
--- /dev/null
+++ b/apps/admin/src/routes/_GNBLayout/focus-card/register/index.tsx
@@ -0,0 +1,137 @@
+import { postFocusCard } from '@apis';
+import { Button, Header } from '@components';
+import { useInvalidate } from '@hooks';
+import { getEmptyContentString } from '@utils';
+import { createFileRoute, useNavigate } from '@tanstack/react-router';
+import { Save } from 'lucide-react';
+import { useState } from 'react';
+import { useForm } from 'react-hook-form';
+import { Slide, toast, ToastContainer } from 'react-toastify';
+
+import EditorField from '@/components/problem/EditorField';
+import { FocusCardActionNodePicker } from '@/components/focusCard';
+
+export const Route = createFileRoute('/_GNBLayout/focus-card/register/')({
+ component: RouteComponent,
+});
+
+interface FormValues {
+ actionNodeId?: number;
+ title: string;
+ description: string;
+ content: string;
+}
+
+function RouteComponent() {
+ const navigate = useNavigate();
+ const { invalidateFocusCardList } = useInvalidate();
+ const { mutate, isPending } = postFocusCard();
+
+ const { control, handleSubmit, watch, setValue } = useForm({
+ defaultValues: {
+ actionNodeId: undefined,
+ title: getEmptyContentString(),
+ description: getEmptyContentString(),
+ content: getEmptyContentString(),
+ },
+ });
+
+ const [actionNodeError, setActionNodeError] = useState(false);
+ const actionNodeId = watch('actionNodeId');
+
+ const onSubmit = handleSubmit((data) => {
+ if (!data.actionNodeId) {
+ setActionNodeError(true);
+ toast.error('Action Node를 선택해 주세요');
+ return;
+ }
+ setActionNodeError(false);
+
+ mutate(
+ {
+ body: {
+ actionNodeId: data.actionNodeId,
+ title: data.title,
+ description: data.description,
+ content: data.content,
+ },
+ },
+ {
+ onSuccess: () => {
+ invalidateFocusCardList();
+ toast.success('카드가 생성되었습니다');
+ navigate({ to: '/focus-card' });
+ },
+ onError: (error: Error) => {
+ toast.error(error.message);
+ },
+ }
+ );
+ });
+
+ return (
+ <>
+
+
+
+
+ {isPending ? '저장 중...' : '저장'}
+
+
+
+
+ >
+ );
+}
diff --git a/apps/admin/src/routes/_GNBLayout/publish/$publishId/index.tsx b/apps/admin/src/routes/_GNBLayout/publish/$publishId/index.tsx
new file mode 100644
index 000000000..3d1fb0760
--- /dev/null
+++ b/apps/admin/src/routes/_GNBLayout/publish/$publishId/index.tsx
@@ -0,0 +1,316 @@
+import {
+ deletePublishFocusCardLink,
+ getFocusCardIssuanceCandidates,
+ getProblemSetById,
+ getPublishById,
+ postPublishFocusCardLink,
+} from '@apis';
+import { Header, Modal } from '@components';
+import { useInvalidate, useModal, useSelectedStudent } from '@hooks';
+import { InlineProblemViewer } from '@repo/pointer-editor-v2';
+import { components } from '@schema';
+import { createFileRoute } from '@tanstack/react-router';
+import dayjs from 'dayjs';
+import { AlertCircle, FileText, Plus, Sparkles, X } from 'lucide-react';
+import { useMemo, useState } from 'react';
+import { Slide, toast, ToastContainer } from 'react-toastify';
+
+import '@repo/pointer-editor-v2/style.css';
+
+export const Route = createFileRoute('/_GNBLayout/publish/$publishId/')({
+ component: RouteComponent,
+});
+
+type FocusCardLink = components['schemas']['PublishFocusCardLinkResp'];
+type FocusCardIssuance = components['schemas']['FocusCardIssuanceResp'];
+
+function RouteComponent() {
+ const { publishId: publishIdStr } = Route.useParams();
+ const publishId = Number(publishIdStr);
+
+ const { selectedStudent } = useSelectedStudent();
+ const { invalidatePublishDetail, invalidatePublish } = useInvalidate();
+
+ const { data: publish, isLoading: isPublishLoading } = getPublishById({ id: publishId });
+ const problemSetId = publish?.problemSet?.id ?? 0;
+ const { data: problemSet } = getProblemSetById({ id: problemSetId });
+
+ const { mutate: mutateAddLink } = postPublishFocusCardLink();
+ const { mutate: mutateDeleteLink } = deletePublishFocusCardLink();
+
+ const { isOpen: isAddOpen, openModal: openAdd, closeModal: closeAdd } = useModal();
+ const [addTarget, setAddTarget] = useState<{
+ problemSetItemId: number;
+ problemId: number;
+ problemTitle: string;
+ } | null>(null);
+
+ // 모달이 열렸을 때만 해당 problem 의 매칭 후보를 조회. studentId 는 사이드바 선택 학생에서 가져옴.
+ const { data: candidatesResp, isLoading: isCandidatesLoading } = getFocusCardIssuanceCandidates(
+ {
+ studentId: selectedStudent?.id ?? 0,
+ problemId: addTarget?.problemId ?? 0,
+ targetDate: publish?.publishAt ?? dayjs().format('YYYY-MM-DD'),
+ },
+ {
+ enabled: isAddOpen && !!selectedStudent && !!addTarget?.problemId && !!publish?.publishAt,
+ }
+ );
+
+ const linksByItem = useMemo(() => {
+ if (!publish) return new Map();
+ const map = new Map();
+ publish.data?.forEach((group) => {
+ group.focusCards?.forEach((link) => {
+ const list = map.get(link.problemSetItemId) ?? [];
+ list.push(link);
+ map.set(link.problemSetItemId, list);
+ });
+ });
+ return map;
+ }, [publish]);
+
+ const handleClickAdd = (problemSetItemId: number, problemId: number, problemTitle: string) => {
+ setAddTarget({ problemSetItemId, problemId, problemTitle });
+ openAdd();
+ };
+
+ // problemSetItemId -> problemId 매핑은 publish.data 의 그룹 순서와 problemSet.problems 의 순서가
+ // no 기준으로 동일하다고 가정. 안전하게 problemSet 의 item.problem.id 를 직접 사용.
+
+ const handleConfirmAdd = (issuance: FocusCardIssuance) => {
+ if (!addTarget) return;
+ mutateAddLink(
+ {
+ params: { path: { publishId } },
+ body: {
+ problemSetItemId: addTarget.problemSetItemId,
+ focusCardIssuanceId: issuance.id,
+ },
+ },
+ {
+ onSuccess: () => {
+ invalidatePublishDetail(publishId);
+ if (publish?.publishAt) {
+ const d = dayjs(publish.publishAt);
+ invalidatePublish(d.year(), d.month() + 1);
+ }
+ toast.success('출제근거 카드가 매핑되었습니다');
+ closeAdd();
+ setAddTarget(null);
+ },
+ onError: (error: Error) => {
+ toast.error(error.message);
+ },
+ }
+ );
+ };
+
+ const handleDeleteLink = (linkId: number) => {
+ mutateDeleteLink(
+ { params: { path: { linkId } } },
+ {
+ onSuccess: () => {
+ invalidatePublishDetail(publishId);
+ if (publish?.publishAt) {
+ const d = dayjs(publish.publishAt);
+ invalidatePublish(d.year(), d.month() + 1);
+ }
+ toast.success('매핑이 해제되었습니다');
+ },
+ onError: (error: Error) => {
+ toast.error(error.message);
+ },
+ }
+ );
+ };
+
+ if (isPublishLoading || !publish) {
+ return (
+
+ );
+ }
+
+ const candidates = candidatesResp?.data ?? [];
+ const problemSetItems = problemSet?.problems ?? [];
+
+ return (
+ <>
+
+
+
+
+
+ {!selectedStudent && (
+
+
+
+ 사이드바에서 학생을 선택하면 출제근거 카드 후보를 확인할 수 있습니다.
+
+
+ )}
+
+
+ 문제 세트
+
+ {publish.problemSet?.title ?? '제목 없음'}
+
+
+ 발행일: {publish.publishAt} · 진행: {publish.progress}
+
+
+
+ {problemSetItems.length === 0 ? (
+
+ ) : (
+
+ {problemSetItems.map((item) => {
+ const links = linksByItem.get(item.id) ?? [];
+ return (
+
+
+
+
+ {item.no}
+
+
+
+ {item.problem.title ?? '제목 없음'}
+
+ {item.problem.memo && (
+
{item.problem.memo}
+ )}
+
+
+
+
+
+ {links.length > 0 ? (
+
+ {links.map((link) => (
+
+
+ {link.focusCardIssuance.card.actionNode.name}
+
+
+ {link.focusCardIssuance.card.title}
+
+
+
+
+ ))}
+
+ ) : (
+
매핑된 카드가 없습니다
+ )}
+
+ );
+ })}
+
+ )}
+
+
+
+ {
+ closeAdd();
+ setAddTarget(null);
+ }}>
+
+
+
출제근거 카드 매핑
+
+ 문제: {addTarget?.problemTitle ?? ''}
+
+
+ 이 문제의 포인팅·버블 액션과 매칭되는 발급 카드만 표시됩니다.
+
+
+
+
+ {!selectedStudent ? (
+
+ 학생이 선택되지 않아 후보 카드를 불러올 수 없습니다.
+
+ ) : isCandidatesLoading ? (
+
후보 카드 조회 중...
+ ) : candidates.length === 0 ? (
+
+
+
매칭되는 발급 카드가 없습니다
+
+ 이 문제의 포인팅·버블 액션과 일치하는 발급 카드가 없어요.
+
+ '집중학습 카드 발급' 화면에서 적절한 카드를 먼저 발급해 주세요.
+
+
+ ) : (
+
+ {candidates.map((iss) => (
+ -
+
+
+ ))}
+
+ )}
+
+
+
+ >
+ );
+}
diff --git a/apps/admin/src/routes/_GNBLayout/publish/index.tsx b/apps/admin/src/routes/_GNBLayout/publish/index.tsx
index 93d6a719e..2383df353 100644
--- a/apps/admin/src/routes/_GNBLayout/publish/index.tsx
+++ b/apps/admin/src/routes/_GNBLayout/publish/index.tsx
@@ -14,6 +14,7 @@ import {
ChevronRight,
AlertCircle,
Package,
+ Eye,
} from 'lucide-react';
import 'dayjs/locale/ko';
@@ -95,6 +96,14 @@ const Day = ({ fullDate, day, dayOfWeek, publishId, title, setId, selectedStuden
{/* Action Buttons */}
{title && selectedStudent && (
+ {publishId && (
+
+
+
+ )}
{expandedSets.has(problemSet.id) && (
-
+
{problemCount === 0 ? (
@@ -289,6 +367,81 @@ function RouteComponent() {
})}
)}
+
+ {isSelected && (
+
+
+
+
+
+
+ 출제근거 카드 매핑
+
+
+ ({publishDate} · 문제별 매칭 카드만 표시)
+
+
+
+ {problemCount === 0 ? (
+
+ 세트에 문제가 없어 매핑할 수 없습니다.
+
+ ) : (
+
+ {problemSet.problems.map((item) => {
+ const linked = linkMap[item.id] ?? new Set();
+ const candidates = candidatesByItem.get(item.id) ?? [];
+ return (
+
+
+
+ {item.no}
+
+
+ {item.problem.title ?? '제목 없음'}
+
+
+ {candidates.length === 0 ? (
+
+ 이 문제와 매칭되는 발급 카드가 없습니다.
+
+ ) : (
+
+ {candidates.map((iss) => {
+ const isOn = linked.has(iss.id);
+ return (
+
+ );
+ })}
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ )}
)}
diff --git a/apps/admin/src/types/api/queryParams.ts b/apps/admin/src/types/api/queryParams.ts
index 6f3570cfe..0db6fca88 100644
--- a/apps/admin/src/types/api/queryParams.ts
+++ b/apps/admin/src/types/api/queryParams.ts
@@ -107,3 +107,20 @@ export interface GetDailyCommentParams {
export interface GetMockExamByStudentParams {
studentId: number;
}
+
+export interface GetFocusCardIssuanceByDateParams {
+ studentId: number;
+ issuedDate?: string;
+}
+
+export interface GetFocusCardIssuanceCandidatesParams {
+ studentId: number;
+ problemId: number;
+ targetDate?: string;
+}
+
+export interface PostPublishFocusCardLinkCandidatesParams {
+ studentId: number;
+ problemSetId: number;
+ targetDate?: string;
+}
diff --git a/apps/admin/src/types/api/schema.d.ts b/apps/admin/src/types/api/schema.d.ts
index a33265cad..2593a9a43 100644
--- a/apps/admin/src/types/api/schema.d.ts
+++ b/apps/admin/src/types/api/schema.d.ts
@@ -386,6 +386,24 @@ export interface paths {
patch?: never;
trace?: never;
};
+ '/api/admin/vulnerability/config': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** 취약도 가중치 조회 */
+ get: operations['getConfig'];
+ /** 취약도 가중치 수정 */
+ put: operations['updateConfig'];
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
'/api/admin/user/{id}': {
parameters: {
query?: never;
@@ -1814,6 +1832,40 @@ export interface paths {
patch?: never;
trace?: never;
};
+ '/api/admin/publish/validate': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** 발행 드라이런 검증 (저장 없이 매핑 규칙 검사) */
+ post: operations['validate'];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ '/api/admin/publish/focus-card-link-candidates': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** ProblemSet 단위 부착 가능한 카드 발급 후보 일괄 조회 */
+ post: operations['candidatesForProblemSet'];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
'/api/admin/problem': {
parameters: {
query?: never;
@@ -1904,6 +1956,23 @@ export interface paths {
patch?: never;
trace?: never;
};
+ '/api/admin/pointing/bubble/{id}/auto-attach-action': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** 버블에 Action 타입 ConceptNode 1개를 LLM 으로 자동 부착 (단건) */
+ post: operations['autoAttachAction'];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
'/api/admin/pointing/bubble/migrate-from-comment': {
parameters: {
query?: never;
@@ -2159,7 +2228,10 @@ export interface paths {
path?: never;
cookie?: never;
};
- /** 노드 목록 조회 */
+ /**
+ * 노드 목록 조회
+ * @description onlyFocusCardCandidates=true 면 활성 집중학습카드가 없는 Action 노드만 반환한다.
+ */
get: operations['getNodes'];
put?: never;
/** 노드 생성 */
@@ -2312,6 +2384,57 @@ export interface paths {
patch?: never;
trace?: never;
};
+ '/api/admin/_debug/tiptap/to-text': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** tiptap doc JSON → plain text 평문화 */
+ post: operations['toText'];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ '/api/admin/_debug/tiptap/to-doc': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** plain text → tiptap doc JSON 변환 */
+ post: operations['toDoc'];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ '/api/admin/_debug/llm/chat': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** OpenRouter 호출 즉시 응답 확인 */
+ post: operations['chat'];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
'/your-redirect-url': {
parameters: {
query?: never;
@@ -3132,6 +3255,40 @@ export interface paths {
patch?: never;
trace?: never;
};
+ '/api/admin/vulnerability/students/{studentId}/weak-actions': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** 학생 약점 액션 전체 조회 (vulnerability 내림차순) */
+ get: operations['getWeakActions'];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ '/api/admin/vulnerability/students/{studentId}/history/{conceptNodeId}': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** 학생-액션 일별 취약도 추이 */
+ get: operations['getHistory'];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
'/api/admin/student': {
parameters: {
query?: never;
@@ -3287,6 +3444,26 @@ export interface paths {
patch?: never;
trace?: never;
};
+ '/api/admin/focus-card/issuance/candidates': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Problem 단위 부착 가능한 카드 발급 후보 조회
+ * @description studentId 학생에게 targetDate(생략 시 오늘) 에 발급된 카드 중, 지정 problem 의 pointing bubble 액션과 매칭되는 발급만 반환한다.
+ */
+ get: operations['candidatesForProblem'];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
'/api/admin/focus-card/issuance/by-date': {
parameters: {
query?: never;
@@ -3488,6 +3665,23 @@ export interface paths {
patch?: never;
trace?: never;
};
+ '/api/admin/_debug/tiptap/format-guide': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** LLM 응답용 plain text 포맷 가이드 반환 */
+ get: operations['formatGuide'];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
'/api/student/scrap/trash/all': {
parameters: {
query?: never;
@@ -4162,6 +4356,13 @@ export interface components {
/** @description 마케팅 알림 허용 여부 (이벤트 및 업데이트 관련 알림) */
isAllowMarketingPush?: boolean;
};
+ VulnerabilityConfigUpdateRequest: {
+ lambda: number;
+ wA: number;
+ wB: number;
+ wC: number;
+ wD: number;
+ };
AdminUpdateRequest: {
/** @description 이름. null/빈문자열이면 기존 값 유지 */
name?: string;
@@ -4317,6 +4518,7 @@ export interface components {
/** Format: int32 */
no: number;
problem: components['schemas']['ProblemMetaResp'];
+ actionNodeIds?: number[];
};
ProblemSetResp: {
/** Format: int64 */
@@ -4945,7 +5147,7 @@ export interface components {
*/
type: string;
/**
- * @description 틀린 문항 번호 목록
+ * @description 틀린 문항 번호 목록. 모두 정답인 경우 빈 배열을 전달
* @example [
* 1,
* 2,
@@ -5438,6 +5640,39 @@ export interface components {
problemSet: components['schemas']['ProblemSetResp'];
data: components['schemas']['PublishProblemGroupResp'][];
};
+ LinkValidationError: {
+ /** Format: int64 */
+ problemSetItemId?: number;
+ /** Format: int64 */
+ focusCardIssuanceId?: number;
+ code?: string;
+ message?: string;
+ };
+ PublishValidateResp: {
+ ok: boolean;
+ errors: components['schemas']['LinkValidationError'][];
+ };
+ FocusCardLinkCandidatesReq: {
+ /** Format: int64 */
+ studentId: number;
+ /** Format: int64 */
+ problemSetId: number;
+ /** Format: date */
+ targetDate?: string;
+ };
+ ListRespProblemFocusCardCandidateResp: {
+ requestId: string;
+ /** Format: int32 */
+ total: number;
+ data: components['schemas']['ProblemFocusCardCandidateResp'][];
+ };
+ ProblemFocusCardCandidateResp: {
+ /** Format: int64 */
+ problemSetItemId: number;
+ /** Format: int64 */
+ problemId: number;
+ candidates: components['schemas']['FocusCardIssuanceResp'][];
+ };
PointingCreateRequest: {
/** Format: int32 */
no?: number;
@@ -5493,6 +5728,56 @@ export interface components {
grade: number;
name: string;
};
+ /** @description 부착된 Action 노드 요약. attached=false 이면 null */
+ ActionNodeRef: {
+ /**
+ * Format: int64
+ * @description ConceptNode id
+ */
+ id: number;
+ /** @description ConceptNode 이름 */
+ name: string;
+ };
+ /** @description 후보별 평가 (score 내림차순) */
+ CandidateDetail: {
+ /**
+ * Format: int64
+ * @description 후보 ConceptNode id
+ */
+ nodeId: number;
+ /**
+ * Format: double
+ * @description 0.0 ~ 1.0 부합도
+ */
+ score?: number;
+ /** @description 이 후보에 대한 평가 사유 */
+ reason?: string;
+ };
+ /** @description LLM 응답 상세 (운영 디버깅용) */
+ LlmDetail: {
+ /**
+ * Format: int64
+ * @description LLM 이 선택한 nodeId. 보류 시 null
+ */
+ selectedNodeId?: number;
+ /** @description 선택 또는 보류 사유 */
+ reason: string;
+ /** @description 후보별 평가 (score 내림차순) */
+ candidates: components['schemas']['CandidateDetail'][];
+ /** @description LLM 원본 응답 JSON 문자열 */
+ raw: string;
+ };
+ PointingBubbleAutoAttachResp: {
+ /**
+ * Format: int64
+ * @description 대상 PointingBubble id
+ */
+ pointingBubbleId: number;
+ /** @description Action 부착 여부. false 이면 skip 되었거나 부착 실패 */
+ attached: boolean;
+ actionNode?: components['schemas']['ActionNodeRef'];
+ llm: components['schemas']['LlmDetail'];
+ };
FailedProblemInfo: {
/** Format: int64 */
problemId?: number;
@@ -5768,6 +6053,40 @@ export interface components {
email: string;
password: string;
};
+ DebugTiptapToTextReq: {
+ /** @description 평문화할 tiptap doc JSON 문자열 */
+ docJson: string;
+ };
+ DebugTiptapToTextResp: {
+ text?: string;
+ };
+ DebugTiptapToDocReq: {
+ /** @description tiptap doc 으로 변환할 plain text (FORMAT_GUIDE 규약) */
+ text: string;
+ };
+ DebugTiptapToDocResp: {
+ docJson?: string;
+ };
+ DebugLlmChatReq: {
+ /** @description 사용자 메시지 */
+ message: string;
+ /** @description 시스템 프롬프트(선택) */
+ system?: string;
+ /**
+ * @description OpenRouter 모델 (기본 GPT_4O_MINI)
+ * @enum {string}
+ */
+ model?: 'GPT_4O_MINI';
+ /**
+ * Format: int32
+ * @description 최대 토큰 (기본 500)
+ */
+ maxTokens?: number;
+ };
+ DebugLlmChatResp: {
+ model?: string;
+ content?: string;
+ };
ListRespPublishResp: {
requestId: string;
/** Format: int32 */
@@ -6193,6 +6512,44 @@ export interface components {
*/
timestamp?: string;
};
+ ListRespStudentVulnerabilityResp: {
+ requestId: string;
+ /** Format: int32 */
+ total: number;
+ data: components['schemas']['StudentVulnerabilityResp'][];
+ };
+ StudentVulnerabilityResp: {
+ /** Format: int64 */
+ conceptNodeId?: number;
+ conceptNodeName?: string;
+ /** @enum {string} */
+ difficultyLevel?: 'LOW' | 'MEDIUM' | 'HIGH';
+ vulnerability?: number;
+ /** Format: int32 */
+ problemCount?: number;
+ /** Format: date-time */
+ lastCalculatedAt?: string;
+ };
+ ListRespVulnerabilityHistoryResp: {
+ requestId: string;
+ /** Format: int32 */
+ total: number;
+ data: components['schemas']['VulnerabilityHistoryResp'][];
+ };
+ VulnerabilityHistoryResp: {
+ vulnerability?: number;
+ /** Format: int32 */
+ problemCount?: number;
+ /** Format: date */
+ snapshotDate?: string;
+ };
+ VulnerabilityConfigResp: {
+ lambda?: number;
+ wA?: number;
+ wB?: number;
+ wC?: number;
+ wD?: number;
+ };
ListRespAdminResp: {
requestId: string;
/** Format: int32 */
@@ -7442,6 +7799,48 @@ export interface operations {
};
};
};
+ getConfig: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ '*/*': components['schemas']['VulnerabilityConfigResp'];
+ };
+ };
+ };
+ };
+ updateConfig: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ 'application/json': components['schemas']['VulnerabilityConfigUpdateRequest'];
+ };
+ };
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
findById: {
parameters: {
query?: never;
@@ -10106,6 +10505,54 @@ export interface operations {
};
};
};
+ validate: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ 'application/json': components['schemas']['PublishCreateRequest'];
+ };
+ };
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ '*/*': components['schemas']['PublishValidateResp'];
+ };
+ };
+ };
+ };
+ candidatesForProblemSet: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ 'application/json': components['schemas']['FocusCardLinkCandidatesReq'];
+ };
+ };
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ '*/*': components['schemas']['ListRespProblemFocusCardCandidateResp'];
+ };
+ };
+ };
+ };
search_2: {
parameters: {
query?: {
@@ -10113,6 +10560,8 @@ export interface operations {
title?: string;
concepts?: number[];
problemType?: 'MAIN_PROBLEM' | 'CHILD_PROBLEM';
+ actionNodeIds?: number[];
+ actionMatchMode?: 'ANY' | 'ALL';
page?: number;
size?: number;
};
@@ -10256,6 +10705,9 @@ export interface operations {
query?: {
setTitle?: string;
problemTitle?: string;
+ status?: 'CONFIRMED' | 'DOING';
+ actionNodeIds?: number[];
+ actionMatchMode?: 'ANY' | 'ALL';
page?: number;
size?: number;
};
@@ -10351,6 +10803,30 @@ export interface operations {
};
};
};
+ autoAttachAction: {
+ parameters: {
+ query?: {
+ overrideExisting?: boolean;
+ };
+ header?: never;
+ path: {
+ id: number;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ '*/*': components['schemas']['PointingBubbleAutoAttachResp'];
+ };
+ };
+ };
+ };
migrateFromComment: {
parameters: {
query?: never;
@@ -10867,7 +11343,10 @@ export interface operations {
};
getNodes: {
parameters: {
- query?: never;
+ query?: {
+ /** @description true 면 활성 집중학습카드가 없는 Action 노드만 반환 (기본 false) */
+ onlyFocusCardCandidates?: boolean;
+ };
header?: never;
path?: never;
cookie?: never;
@@ -11225,6 +11704,78 @@ export interface operations {
};
};
};
+ toText: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ 'application/json': components['schemas']['DebugTiptapToTextReq'];
+ };
+ };
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ '*/*': components['schemas']['DebugTiptapToTextResp'];
+ };
+ };
+ };
+ };
+ toDoc: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ 'application/json': components['schemas']['DebugTiptapToDocReq'];
+ };
+ };
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ '*/*': components['schemas']['DebugTiptapToDocResp'];
+ };
+ };
+ };
+ };
+ chat: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ 'application/json': components['schemas']['DebugLlmChatReq'];
+ };
+ };
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ '*/*': components['schemas']['DebugLlmChatResp'];
+ };
+ };
+ };
+ };
oauthRedirectExample: {
parameters: {
query: {
@@ -12351,6 +12902,54 @@ export interface operations {
};
};
};
+ getWeakActions: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ studentId: number;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ '*/*': components['schemas']['ListRespStudentVulnerabilityResp'];
+ };
+ };
+ };
+ };
+ getHistory: {
+ parameters: {
+ query: {
+ from: string;
+ to: string;
+ };
+ header?: never;
+ path: {
+ studentId: number;
+ conceptNodeId: number;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ '*/*': components['schemas']['ListRespVulnerabilityHistoryResp'];
+ };
+ };
+ };
+ };
search_11: {
parameters: {
query?: {
@@ -12590,6 +13189,30 @@ export interface operations {
};
};
};
+ candidatesForProblem: {
+ parameters: {
+ query: {
+ studentId: number;
+ problemId: number;
+ targetDate?: string;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ '*/*': components['schemas']['ListRespFocusCardIssuanceResp'];
+ };
+ };
+ };
+ };
issuanceByDate: {
parameters: {
query: {
@@ -12906,6 +13529,26 @@ export interface operations {
};
};
};
+ formatGuide: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ '*/*': string;
+ };
+ };
+ };
+ };
emptyTrash: {
parameters: {
query?: never;