From f5b419f911fd5f0a6bb9c58c3a6cf0c467fa0edb Mon Sep 17 00:00:00 2001 From: jaemin Date: Tue, 12 May 2026 16:13:57 +0900 Subject: [PATCH 1/2] feat(admin): add focus-card admin API layer (MAT-685) - Regen OpenAPI schema from QA exposing new focus-card and publish-focus-card-link endpoints - focusCard/* controllers for template CRUD, per-student issuance, candidates lookup - publish/* controllers for focus-card-link CRUD and bulk candidates query (POST-as-read via useQuery + body) - analytics/getConceptHistory for student vulnerability lookup - Extend conceptGraph/getNode with optional onlyFocusCardCandidates param (default false, backwards-compatible) - Add 3 param interfaces to types/api/queryParams.ts Co-Authored-By: Claude Opus 4.7 (1M context) --- .../controller/analytics/getConceptHistory.ts | 16 + .../src/apis/controller/analytics/index.ts | 3 + .../apis/controller/conceptGraph/getNode.ts | 12 +- .../controller/focusCard/deleteFocusCard.ts | 7 + .../focusCard/deleteFocusCardIssuance.ts | 7 + .../controller/focusCard/getFocusCardById.ts | 11 + .../focusCard/getFocusCardIssuanceByDate.ts | 23 + .../getFocusCardIssuanceCandidates.ts | 24 + .../controller/focusCard/getFocusCardList.ts | 7 + .../src/apis/controller/focusCard/index.ts | 23 + .../controller/focusCard/postFocusCard.ts | 7 + .../focusCard/postFocusCardAutoIssue.ts | 7 + .../focusCard/postFocusCardContent.ts | 7 + .../focusCard/postFocusCardIssuance.ts | 7 + .../publish/deletePublishFocusCardLink.ts | 7 + .../getPublishFocusCardLinkCandidates.ts | 24 + .../src/apis/controller/publish/index.ts | 13 +- .../publish/postPublishFocusCardLink.ts | 7 + apps/admin/src/apis/index.ts | 2 + apps/admin/src/types/api/queryParams.ts | 17 + apps/admin/src/types/api/schema.d.ts | 649 +++++++++++++++++- 21 files changed, 874 insertions(+), 6 deletions(-) create mode 100644 apps/admin/src/apis/controller/analytics/getConceptHistory.ts create mode 100644 apps/admin/src/apis/controller/analytics/index.ts create mode 100644 apps/admin/src/apis/controller/focusCard/deleteFocusCard.ts create mode 100644 apps/admin/src/apis/controller/focusCard/deleteFocusCardIssuance.ts create mode 100644 apps/admin/src/apis/controller/focusCard/getFocusCardById.ts create mode 100644 apps/admin/src/apis/controller/focusCard/getFocusCardIssuanceByDate.ts create mode 100644 apps/admin/src/apis/controller/focusCard/getFocusCardIssuanceCandidates.ts create mode 100644 apps/admin/src/apis/controller/focusCard/getFocusCardList.ts create mode 100644 apps/admin/src/apis/controller/focusCard/index.ts create mode 100644 apps/admin/src/apis/controller/focusCard/postFocusCard.ts create mode 100644 apps/admin/src/apis/controller/focusCard/postFocusCardAutoIssue.ts create mode 100644 apps/admin/src/apis/controller/focusCard/postFocusCardContent.ts create mode 100644 apps/admin/src/apis/controller/focusCard/postFocusCardIssuance.ts create mode 100644 apps/admin/src/apis/controller/publish/deletePublishFocusCardLink.ts create mode 100644 apps/admin/src/apis/controller/publish/getPublishFocusCardLinkCandidates.ts create mode 100644 apps/admin/src/apis/controller/publish/postPublishFocusCardLink.ts 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/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; From 62ca4f6ee1598245d35083da8b4714495bb0a22c Mon Sep 17 00:00:00 2001 From: jaemin Date: Tue, 12 May 2026 16:14:22 +0900 Subject: [PATCH 2/2] feat(admin): add focus-card admin pages and integrations (MAT-685) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /focus-card: template list + delete - /focus-card/register: new template with FocusCardActionNodePicker (uses onlyFocusCardCandidates to hide nodes that already have a card) - /focus-card/$focusCardId: template detail/content edit with ActionNode description / payload (example, pointingExample) panel - /focus-card/issuance: per-student issuance management — by-date list, manual issue modal, revoke, auto-issue trigger, student vulnerability panel (top-5 weak concepts via analytics) - /publish/$publishId: publish detail with focus card link chips per problem + add-modal that loads only problem-matching candidates - /publish (modify): Day cell hover Eye button → publish detail - /publish/register/$publishDate/$studentId (modify): inline focus card mapping section using bulk candidates query (POST /publish/focus-card-link-candidates); only matching cards selectable; submit body includes focusCardLinks - Add useActionNodeDetail / useActionNodeTypeId hooks - Add 4 invalidators: focus card list/item, issuance by-date, publish detail - Add FOCUS_CARD / FOCUS_CARD_ISSUANCE menus under new 집중학습 nav section (between 문제 관리 and 개념 그래프). SUPER admin only until BE menu seed lands - Refactor canAccessPath to longest-prefix-wins (getMostSpecificNavItem) so /focus-card permission does not leak into /focus-card/issuance; GNB active state follows the same rule Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/admin/src/components/common/GNB.tsx | 35 +- .../focusCard/FocusCardActionNodePicker.tsx | 143 +++++++ apps/admin/src/components/focusCard/index.ts | 3 + apps/admin/src/constants/adminPermissions.ts | 56 ++- apps/admin/src/hooks/index.ts | 4 + apps/admin/src/hooks/useActionNodeDetail.ts | 18 + apps/admin/src/hooks/useActionNodeTypeId.ts | 11 + apps/admin/src/hooks/useInvalidate.ts | 53 +++ .../focus-card/$focusCardId/index.tsx | 241 +++++++++++ .../routes/_GNBLayout/focus-card/index.tsx | 142 +++++++ .../_GNBLayout/focus-card/issuance/index.tsx | 392 ++++++++++++++++++ .../_GNBLayout/focus-card/register/index.tsx | 137 ++++++ .../_GNBLayout/publish/$publishId/index.tsx | 316 ++++++++++++++ .../src/routes/_GNBLayout/publish/index.tsx | 9 + .../$publishDate/$studentId/index.tsx | 167 +++++++- 15 files changed, 1692 insertions(+), 35 deletions(-) create mode 100644 apps/admin/src/components/focusCard/FocusCardActionNodePicker.tsx create mode 100644 apps/admin/src/components/focusCard/index.ts create mode 100644 apps/admin/src/hooks/useActionNodeDetail.ts create mode 100644 apps/admin/src/hooks/useActionNodeTypeId.ts create mode 100644 apps/admin/src/routes/_GNBLayout/focus-card/$focusCardId/index.tsx create mode 100644 apps/admin/src/routes/_GNBLayout/focus-card/index.tsx create mode 100644 apps/admin/src/routes/_GNBLayout/focus-card/issuance/index.tsx create mode 100644 apps/admin/src/routes/_GNBLayout/focus-card/register/index.tsx create mode 100644 apps/admin/src/routes/_GNBLayout/publish/$publishId/index.tsx 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 ( + <> + +
+
+
+ + 삭제 + + + {isUpdating ? '저장 중...' : '저장'} + +
+
+ +
{ + e.preventDefault(); + onSubmit(); + }}> +
+

Action Node

+
+ + {card.actionNode.name} + + {actionNodeDetail?.nodeType?.label && ( + + {actionNodeDetail.nodeType.label} + + )} +
+ + {(() => { + const payload = actionNodeDetail?.payload as + | { example?: unknown; pointingExample?: unknown } + | undefined; + const example = typeof payload?.example === 'string' ? payload.example : ''; + const pointingExample = + typeof payload?.pointingExample === 'string' ? payload.pointingExample : ''; + const description = + typeof actionNodeDetail?.description === 'string' + ? actionNodeDetail.description + : ''; + + const hasAny = description || example || pointingExample; + if (!hasAny) return null; + + return ( +
+ {description && ( +
+
+ 설명 +
+
+ {description} +
+
+ )} + {example && ( +
+
+ 예시 +
+
+ {example} +
+
+ )} + {pointingExample && ( +
+
+ 포인팅 +
+
+ {pointingExample} +
+
+ )} +
+ ); + })()} + +

Action Node는 생성 후 변경할 수 없습니다.

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+ + + + + + ); +} 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.title} +
+
+

설명

+ {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 ( + <> + +
+
+ {isStudentSelected && ( +
+ + {isAutoIssuing ? '실행 중...' : '자동 발급 실행'} + + + 카드 발급 + +
+ )} +
+ +
+
+
+

학생

+

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

+
+
+

발급 일자

+

{issuedDate}

+
+
+ + {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 ? '저장 중...' : '저장'} + +
+
{ + e.preventDefault(); + onSubmit(); + }}> +
+ + { + setValue('actionNodeId', id); + if (id !== undefined) setActionNodeError(false); + }} + hasError={actionNodeError} + /> +

+ 아직 집중학습 카드가 등록되지 않은 Action 노드만 표시됩니다. +

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+ + ); +} 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 ( + + ); + })} +
+ )} +
+ ); + })} +
+ )} +
+ )}
)}