ERP-node/frontend/contexts/BarcodeDesignerContext.tsx

337 lines
10 KiB
TypeScript
Raw Permalink Normal View History

2026-03-04 20:51:00 +09:00
"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 };