"use client"; import { createContext, useContext, useState, useCallback, ReactNode, useEffect, useRef, } from "react"; import { BarcodeLabelComponent, BarcodeLabelLayout, BarcodeLabelMaster, } from "@/types/barcode"; import { barcodeApi } from "@/lib/api/barcodeApi"; import { useToast } from "@/hooks/use-toast"; import { v4 as uuidv4 } from "uuid"; interface BarcodeDesignerContextType { labelId: string; labelMaster: BarcodeLabelMaster | null; widthMm: number; heightMm: number; components: BarcodeLabelComponent[]; selectedComponentId: string | null; isLoading: boolean; isSaving: boolean; setWidthMm: (v: number) => void; setHeightMm: (v: number) => void; addComponent: (component: BarcodeLabelComponent) => void; updateComponent: (id: string, updates: Partial) => void; removeComponent: (id: string) => void; selectComponent: (id: string | null) => void; reorderComponent: (id: string, direction: "up" | "down") => void; loadLabel: () => Promise; loadLayout: () => Promise; saveLayout: () => Promise; applyTemplate: (templateId: string) => Promise; gridSize: number; showGrid: boolean; setShowGrid: (v: boolean) => void; snapValueToGrid: (v: number) => number; } const BarcodeDesignerContext = createContext(undefined); const MM_TO_PX = 4; const DEFAULT_WIDTH_MM = 50; const DEFAULT_HEIGHT_MM = 30; export function BarcodeDesignerProvider({ labelId, children, }: { labelId: string; children: ReactNode; }) { const [labelMaster, setLabelMaster] = useState(null); const [widthMm, setWidthMm] = useState(DEFAULT_WIDTH_MM); const [heightMm, setHeightMm] = useState(DEFAULT_HEIGHT_MM); const [components, setComponents] = useState([]); const [selectedComponentId, setSelectedComponentId] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [showGrid, setShowGrid] = useState(true); const [gridSize] = useState(2); // mm const { toast } = useToast(); const selectedComponentIdRef = useRef(null); selectedComponentIdRef.current = selectedComponentId; const snapValueToGrid = useCallback( (v: number) => Math.round(v / (gridSize * MM_TO_PX)) * (gridSize * MM_TO_PX), [gridSize] ); const loadLabel = useCallback(async () => { if (labelId === "new") { setLabelMaster(null); setWidthMm(DEFAULT_WIDTH_MM); setHeightMm(DEFAULT_HEIGHT_MM); setComponents([]); setIsLoading(false); return; } try { const res = await barcodeApi.getLabelById(labelId); if (res.success && res.data) { setLabelMaster(res.data); if (res.data.width_mm != null) setWidthMm(res.data.width_mm); if (res.data.height_mm != null) setHeightMm(res.data.height_mm); } } catch (e: any) { toast({ title: "오류", description: e.message || "라벨 정보를 불러오지 못했습니다.", variant: "destructive", }); } finally { setIsLoading(false); } }, [labelId, toast]); const loadLayout = useCallback(async () => { if (labelId === "new") return; try { const res = await barcodeApi.getLayout(labelId); if (res.success && res.data) { setWidthMm(res.data.width_mm); setHeightMm(res.data.height_mm); setComponents(res.data.components || []); } } catch { // 레이아웃 없으면 빈 상태 유지 } }, [labelId]); // labelId 변경 시에만 초기 로드 (loadLabel/loadLayout을 deps에 넣지 않아 무한 루프 방지) useEffect(() => { let cancelled = false; setIsLoading(true); const run = async () => { if (labelId === "new") { setLabelMaster(null); setWidthMm(DEFAULT_WIDTH_MM); setHeightMm(DEFAULT_HEIGHT_MM); setComponents([]); if (!cancelled) setIsLoading(false); return; } try { const res = await barcodeApi.getLabelById(labelId); if (cancelled) return; if (res.success && res.data) { setLabelMaster(res.data); if (res.data.width_mm != null) setWidthMm(res.data.width_mm); if (res.data.height_mm != null) setHeightMm(res.data.height_mm); } const layoutRes = await barcodeApi.getLayout(labelId); if (cancelled) return; if (layoutRes.success && layoutRes.data) { setWidthMm(layoutRes.data.width_mm); setHeightMm(layoutRes.data.height_mm); setComponents(layoutRes.data.components || []); } } catch (e: any) { if (!cancelled) { toast({ title: "오류", description: e.message || "라벨을 불러오지 못했습니다.", variant: "destructive", }); } } finally { if (!cancelled) setIsLoading(false); } }; run(); return () => { cancelled = true; }; }, [labelId]); // eslint-disable-line react-hooks/exhaustive-deps const addComponent = useCallback((component: BarcodeLabelComponent) => { setComponents((prev) => [...prev, { ...component, id: component.id || `comp_${uuidv4()}` }]); setSelectedComponentId(component.id || null); }, []); const updateComponent = useCallback((id: string, updates: Partial) => { setComponents((prev) => prev.map((c) => (c.id === id ? { ...c, ...updates } : c)) ); }, []); const removeComponent = useCallback((id: string) => { setComponents((prev) => prev.filter((c) => c.id !== id)); setSelectedComponentId((sid) => (sid === id ? null : sid)); }, []); // Delete / Backspace 키로 선택된 요소 삭제 (입력 필드에서는 무시) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key !== "Delete" && e.key !== "Backspace") return; const target = e.target as HTMLElement; if ( target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable ) { return; } const sid = selectedComponentIdRef.current; if (sid) { e.preventDefault(); removeComponent(sid); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [removeComponent]); const reorderComponent = useCallback((id: string, direction: "up" | "down") => { setComponents((prev) => { const idx = prev.findIndex((c) => c.id === id); if (idx < 0) return prev; const next = [...prev]; const swap = direction === "up" ? idx - 1 : idx + 1; if (swap < 0 || swap >= next.length) return prev; [next[idx], next[swap]] = [next[swap], next[idx]]; return next.map((c, i) => ({ ...c, zIndex: i })); }); }, []); const saveLayout = useCallback(async () => { if (labelId === "new") { toast({ title: "저장 불가", description: "먼저 라벨을 저장한 뒤 레이아웃을 저장할 수 있습니다.", variant: "destructive", }); return; } setIsSaving(true); try { const layout: BarcodeLabelLayout = { width_mm: widthMm, height_mm: heightMm, components: components.map((c, i) => ({ ...c, zIndex: i })), }; await barcodeApi.saveLayout(labelId, layout); toast({ title: "저장됨", description: "레이아웃이 저장되었습니다." }); } catch (e: any) { toast({ title: "저장 실패", description: e.message || "레이아웃 저장에 실패했습니다.", variant: "destructive", }); } finally { setIsSaving(false); } }, [labelId, widthMm, heightMm, components, toast]); const applyTemplate = useCallback( async (templateId: string) => { try { const res = await barcodeApi.getTemplateById(templateId); const layout = res.success && res.data ? (res.data as { layout?: BarcodeLabelLayout }).layout : null; if (layout && typeof layout.width_mm === "number" && typeof layout.height_mm === "number") { setWidthMm(layout.width_mm); setHeightMm(layout.height_mm); setComponents( (layout.components || []).map((c) => ({ ...c, id: c.id || `comp_${uuidv4()}`, })) ); setSelectedComponentId(null); const name = (res.data as { template_name_kor?: string }).template_name_kor || "템플릿"; toast({ title: "템플릿 적용", description: `${name} 적용됨 (${layout.width_mm}×${layout.height_mm}mm)`, }); } else { toast({ title: "템플릿 적용 실패", description: "레이아웃 데이터가 없습니다.", variant: "destructive", }); } } catch (e: any) { toast({ title: "템플릿 적용 실패", description: e.message || "템플릿을 불러오지 못했습니다.", variant: "destructive", }); } }, [toast] ); const value: BarcodeDesignerContextType = { labelId, labelMaster, widthMm, heightMm, components, selectedComponentId, isLoading, isSaving, setWidthMm, setHeightMm, addComponent, updateComponent, removeComponent, selectComponent: setSelectedComponentId, reorderComponent, loadLabel, loadLayout, saveLayout, applyTemplate, gridSize, showGrid, setShowGrid, snapValueToGrid, }; return ( {children} {isLoading && (
라벨 불러오는 중...
)} ); } export function useBarcodeDesigner() { const ctx = useContext(BarcodeDesignerContext); if (ctx === undefined) { throw new Error("useBarcodeDesigner must be used within BarcodeDesignerProvider"); } return ctx; } export { MM_TO_PX };