337 lines
10 KiB
TypeScript
337 lines
10 KiB
TypeScript
"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<BarcodeLabelComponent>) => void;
|
||
removeComponent: (id: string) => void;
|
||
selectComponent: (id: string | null) => void;
|
||
reorderComponent: (id: string, direction: "up" | "down") => void;
|
||
|
||
loadLabel: () => Promise<void>;
|
||
loadLayout: () => Promise<void>;
|
||
saveLayout: () => Promise<void>;
|
||
applyTemplate: (templateId: string) => Promise<void>;
|
||
|
||
gridSize: number;
|
||
showGrid: boolean;
|
||
setShowGrid: (v: boolean) => void;
|
||
snapValueToGrid: (v: number) => number;
|
||
}
|
||
|
||
const BarcodeDesignerContext = createContext<BarcodeDesignerContextType | undefined>(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<BarcodeLabelMaster | null>(null);
|
||
const [widthMm, setWidthMm] = useState(DEFAULT_WIDTH_MM);
|
||
const [heightMm, setHeightMm] = useState(DEFAULT_HEIGHT_MM);
|
||
const [components, setComponents] = useState<BarcodeLabelComponent[]>([]);
|
||
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(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<string | null>(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<BarcodeLabelComponent>) => {
|
||
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 (
|
||
<BarcodeDesignerContext.Provider value={value}>
|
||
{children}
|
||
{isLoading && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/20">
|
||
<div className="bg-background flex flex-col items-center gap-2 rounded-lg border p-4 shadow-lg">
|
||
<div className="border-primary h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
|
||
<span className="text-sm">라벨 불러오는 중...</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</BarcodeDesignerContext.Provider>
|
||
);
|
||
}
|
||
|
||
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 };
|