33import { useState , useCallback , useMemo } from "react" ;
44import { useRouter } from "next/navigation" ;
55import { toast } from "sonner" ;
6+ import { PanelLeftClose , PanelLeftOpen } from "lucide-react" ;
67
78import { trpc } from "@/lib/trpc" ;
89import { KatexRenderer } from "@/components/math/katex-renderer" ;
910import { Button } from "@/components/ui/button" ;
1011import { cn } from "@/lib/utils" ;
11- import {
12- AssignmentBuilder ,
13- type AssignmentBuilderItem ,
14- } from "@/components/admin/assignment-builder" ;
12+ import { CanvasBuilder } from "@/components/admin/canvas/canvas-builder" ;
13+ import { useCanvasState } from "@/components/admin/canvas/use-canvas-state" ;
1514
1615// --- 상수 ---
1716
@@ -41,18 +40,6 @@ const PURPOSE_DIFFICULTY_GUIDE: Record<AssignmentPurpose, string> = {
4140 advanced : "고난이도 (4~5)" ,
4241} ;
4342
44- // --- 목적별 기본 난이도 범위 ---
45-
46- const PURPOSE_DIFFICULTY_RANGE : Record <
47- AssignmentPurpose ,
48- { min : number ; max : number }
49- > = {
50- diagnosis : { min : 1 , max : 5 } ,
51- remediation : { min : 1 , max : 2 } ,
52- pre_exam : { min : 3 , max : 4 } ,
53- advanced : { min : 4 , max : 5 } ,
54- } ;
55-
5643// --- 메인 페이지 ---
5744
5845export default function NewAssignmentPage ( ) {
@@ -65,22 +52,19 @@ export default function NewAssignmentPage() {
6552 const [ itemCount , setItemCount ] = useState ( DEFAULT_ITEM_COUNT ) ;
6653 const [ searchQuery , setSearchQuery ] = useState ( "" ) ;
6754
68- // 문항 상태
69- const [ selectedItems , setSelectedItems ] = useState < AssignmentBuilderItem [ ] > (
70- [ ] ,
71- ) ;
72-
7355 // 검색/추천 상태
7456 const [ showSearch , setShowSearch ] = useState ( false ) ;
7557 const [ searchPage , setSearchPage ] = useState ( 1 ) ;
7658 const [ isRecommending , setIsRecommending ] = useState ( false ) ;
59+ const [ showConfig , setShowConfig ] = useState ( true ) ;
60+
61+ // 캔버스 상태
62+ const canvas = useCanvasState ( ) ;
7763
7864 // --- tRPC 쿼리 ---
7965
80- // 추천 문항 (목적/난이도/스킬 기반 4-factor 점수 엔진)
8166 const recommendMutation = trpc . admin . recommendItems . useMutation ( ) ;
8267
83- // 수동 검색
8468 const searchInput = useMemo (
8569 ( ) => ( {
8670 query : searchQuery . trim ( ) || undefined ,
@@ -143,15 +127,24 @@ export default function NewAssignmentPage() {
143127 [ ] ,
144128 ) ;
145129
130+ // 기존 아이템 ID 수집 (추천 시 제외용)
131+ const existingItemIds = useMemo (
132+ ( ) =>
133+ canvas . blocks
134+ . filter ( ( b ) => b . type === "math_item" && b . itemId )
135+ . map ( ( b ) => b . itemId ! ) ,
136+ [ canvas . blocks ] ,
137+ ) ;
138+
146139 const handleRecommend = useCallback ( ( ) => {
147140 setIsRecommending ( true ) ;
148141 recommendMutation . mutate ( {
149142 purpose,
150143 difficulty : targetDifficulty ,
151144 count : itemCount ,
152- excludeItemIds : selectedItems . map ( ( si ) => si . item . id ) ,
145+ excludeItemIds : existingItemIds ,
153146 } ) ;
154- } , [ purpose , targetDifficulty , itemCount , selectedItems , recommendMutation ] ) ;
147+ } , [ purpose , targetDifficulty , itemCount , existingItemIds , recommendMutation ] ) ;
155148
156149 const handleToggleSearch = useCallback ( ( ) => {
157150 setShowSearch ( ( prev ) => ! prev ) ;
@@ -168,60 +161,42 @@ export default function NewAssignmentPage() {
168161
169162 const handleAddItem = useCallback (
170163 ( rawItem : SearchResultItemData ) => {
171- // 중복 방지
172- const alreadyExists = selectedItems . some ( ( si ) => si . item . id === rawItem . id ) ;
173- if ( alreadyExists ) return ;
174-
175- const entry : AssignmentBuilderItem = {
176- item : {
164+ canvas . addMathItemBlock (
165+ {
177166 id : rawItem . id ,
178167 bodyLatex : rawItem . bodyLatex ,
179168 itemType : rawItem . itemType ,
180169 difficultyAuthor : rawItem . difficultyAuthor ?? null ,
181170 } ,
182- position : selectedItems . length + 1 ,
183- points : 10 ,
184- } ;
185-
186- setSelectedItems ( ( prev ) => [ ...prev , entry ] ) ;
187- } ,
188- [ selectedItems ] ,
189- ) ;
190-
191- const handleItemsChange = useCallback (
192- ( items : readonly AssignmentBuilderItem [ ] ) => {
193- setSelectedItems ( [ ...items ] ) ;
194- } ,
195- [ ] ,
196- ) ;
197-
198- const handleRemoveItem = useCallback (
199- ( itemId : string ) => {
200- setSelectedItems ( ( prev ) => prev . filter ( ( si ) => si . item . id !== itemId ) ) ;
171+ 10 ,
172+ ) ;
201173 } ,
202- [ ] ,
174+ [ canvas ] ,
203175 ) ;
204176
205177 const handleSave = useCallback ( ( ) => {
178+ const payload = canvas . toSavePayload ( ) ;
206179 if ( title . trim ( ) . length === 0 ) return ;
207- if ( selectedItems . length === 0 ) return ;
180+ if ( payload . itemIds . length === 0 ) return ;
208181
209182 createAssignmentMutation . mutate ( {
210183 title : title . trim ( ) ,
211184 purpose,
212- itemIds : selectedItems . map ( ( si ) => si . item . id ) ,
213- points : selectedItems . map ( ( si ) => si . points ) ,
185+ itemIds : payload . itemIds ,
186+ points : payload . points ,
214187 } ) ;
215- } , [ title , purpose , selectedItems , createAssignmentMutation ] ) ;
188+ } , [ title , purpose , canvas , createAssignmentMutation ] ) ;
216189
217190 // --- 파생 상태 ---
218191
219192 const selectedItemIds = useMemo (
220- ( ) => new Set ( selectedItems . map ( ( si ) => si . item . id ) ) ,
221- [ selectedItems ] ,
193+ ( ) => new Set ( existingItemIds ) ,
194+ [ existingItemIds ] ,
222195 ) ;
223196
224- const canSave = title . trim ( ) . length > 0 && selectedItems . length >= 1 ;
197+ const canSave =
198+ title . trim ( ) . length > 0 &&
199+ canvas . blocks . some ( ( b ) => b . type === "math_item" ) ;
225200
226201 const recommendResult = recommendMutation . data ;
227202 const recommendedItems : SearchResultItemData [ ] = useMemo (
@@ -243,58 +218,73 @@ export default function NewAssignmentPage() {
243218 const searchTotal = searchQueryResult . data ?. total ?? 0 ;
244219
245220 return (
246- < div className = "flex h-[calc(100vh-4rem)] flex-col gap-4 p-4 " >
221+ < div className = "flex h-[calc(100vh-4rem)] flex-col gap-3 p-3 " >
247222 { /* 페이지 헤더 */ }
248- < div >
249- < h1 className = "text-lg font-semibold text-slate-900" > 학습지 제작</ h1 >
250- < p className = "text-sm text-slate-500" >
251- 목적에 맞는 문항을 추천받거나 직접 검색하여 학습지를 구성합니다
252- </ p >
223+ < div className = "flex items-center justify-between" >
224+ < div >
225+ < h1 className = "text-lg font-semibold text-slate-900" > 학습지 제작</ h1 >
226+ < p className = "text-sm text-slate-500" >
227+ 문항을 추천/검색하여 학습지를 구성합니다
228+ </ p >
229+ </ div >
230+ < Button
231+ variant = "ghost"
232+ size = "sm"
233+ onClick = { ( ) => setShowConfig ( ( v ) => ! v ) }
234+ className = "gap-1.5 text-xs text-slate-500"
235+ >
236+ { showConfig ? (
237+ < > < PanelLeftClose className = "h-4 w-4" /> 설정 접기</ >
238+ ) : (
239+ < > < PanelLeftOpen className = "h-4 w-4" /> 설정 열기</ >
240+ ) }
241+ </ Button >
253242 </ div >
254243
255244 { /* 2-column 레이아웃 */ }
256- < div className = "flex flex-1 gap-4 overflow-hidden" >
257- { /* 왼쪽 패널: 학습지 설정 */ }
258- < div className = "w-[380px] shrink-0 overflow-y-auto rounded-lg border border-slate-200 bg-white p-4" >
259- < ConfigurationPanel
260- title = { title }
261- purpose = { purpose }
262- targetDifficulty = { targetDifficulty }
263- itemCount = { itemCount }
264- isRecommending = { recommendMutation . isPending }
265- onTitleChange = { handleTitleChange }
266- onPurposeChange = { handlePurposeChange }
267- onDifficultyChange = { handleDifficultyChange }
268- onItemCountChange = { handleItemCountChange }
269- onRecommend = { handleRecommend }
270- onToggleSearch = { handleToggleSearch }
271- showSearch = { showSearch }
272- />
273- </ div >
245+ < div className = "flex flex-1 gap-3 overflow-hidden" >
246+ { /* 왼쪽 패널: 학습지 설정 (접기 가능) */ }
247+ { showConfig && (
248+ < div className = "w-[300px] shrink-0 overflow-y-auto rounded-lg border border-slate-200 bg-white p-3" >
249+ < ConfigurationPanel
250+ title = { title }
251+ purpose = { purpose }
252+ targetDifficulty = { targetDifficulty }
253+ itemCount = { itemCount }
254+ isRecommending = { recommendMutation . isPending }
255+ onTitleChange = { handleTitleChange }
256+ onPurposeChange = { handlePurposeChange }
257+ onDifficultyChange = { handleDifficultyChange }
258+ onItemCountChange = { handleItemCountChange }
259+ onRecommend = { handleRecommend }
260+ onToggleSearch = { handleToggleSearch }
261+ showSearch = { showSearch }
262+ />
263+ </ div >
264+ ) }
274265
275- { /* 오른쪽 패널: 문항 구성 */ }
276- < div className = "flex flex-1 flex-col gap-4 overflow-hidden" >
277- { /* 문항 빌더 */ }
266+ { /* 캔버스 빌더 (최대 공간) */ }
267+ < div className = "flex flex-1 flex-col gap-3 overflow-hidden" >
278268 < div className = "flex-1 overflow-y-auto rounded-lg border border-slate-200 bg-white p-4" >
279- { selectedItems . length === 0 ? (
280- < div className = "flex h-full items-center justify-center" >
281- < p className = "text-sm text-slate-400" >
282- 아래에서 추천 또는 검색된 문항을 추가하세요
283- </ p >
284- </ div >
285- ) : (
286- < AssignmentBuilder
287- items = { selectedItems }
288- onItemsChange = { handleItemsChange }
289- onRemoveItem = { handleRemoveItem }
290- />
291- ) }
269+ < CanvasBuilder
270+ blocks = { canvas . blocks }
271+ layout = { canvas . layout }
272+ selectedBlockId = { canvas . selectedBlockId }
273+ onAddBlock = { canvas . addBlock }
274+ onRemoveBlock = { canvas . removeBlock }
275+ onMoveBlock = { canvas . moveBlock }
276+ onUpdateBlock = { canvas . updateBlock }
277+ onSelectBlock = { canvas . selectBlock }
278+ onLayoutChange = { canvas . setLayout }
279+ />
292280 </ div >
293281
294282 { /* 저장 버튼 */ }
295- < div className = "flex items-center justify-between rounded-lg border border-slate-200 bg-white px-4 py-3 " >
283+ < div className = "flex items-center justify-between rounded-lg border border-slate-200 bg-white px-3 py-2 " >
296284 < p className = "text-sm text-slate-500" >
297- 선택된 문항: { selectedItems . length } 개
285+ 문항:{ " " }
286+ { canvas . blocks . filter ( ( b ) => b . type === "math_item" ) . length }
287+ 개 / 전체 블록: { canvas . blocks . length } 개
298288 </ p >
299289 < Button
300290 disabled = { ! canSave || createAssignmentMutation . isPending }
@@ -315,7 +305,7 @@ export default function NewAssignmentPage() {
315305 </ div >
316306
317307 { /* 하단 패널: 추천/검색 결과 */ }
318- < div className = "shrink-0 overflow-hidden rounded-lg border border-slate-200 bg-white" >
308+ < div className = "max-h-[200px] shrink-0 overflow-y-auto rounded-lg border border-slate-200 bg-white" >
319309 { /* 검색 입력 (토글) */ }
320310 { showSearch && (
321311 < div className = "border-b border-slate-200 p-3" >
0 commit comments