ERP-node/frontend/contexts/BarcodeDesignerContext.tsx

337 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 };