2026-03-04 20:51:00 +09:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
2026-03-05 21:45:26 +09:00
|
|
|
|
import { useState, useEffect } from "react";
|
2026-03-04 20:51:00 +09:00
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
|
|
|
|
import { Switch } from "@/components/ui/switch";
|
2026-03-05 21:45:26 +09:00
|
|
|
|
import { Trash2, Plus } from "lucide-react";
|
|
|
|
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
2026-03-04 20:51:00 +09:00
|
|
|
|
import { useBarcodeDesigner } from "@/contexts/BarcodeDesignerContext";
|
|
|
|
|
|
import { BarcodeLabelComponent } from "@/types/barcode";
|
|
|
|
|
|
|
2026-03-05 21:45:26 +09:00
|
|
|
|
// QR 기본 키: 품번, 품명, 규격
|
|
|
|
|
|
const DEFAULT_QR_JSON_KEYS = ["part_no", "part_name", "spec"];
|
|
|
|
|
|
|
|
|
|
|
|
function parseQRJsonValue(str: string): Record<string, string> {
|
|
|
|
|
|
const trimmed = (str || "").trim();
|
|
|
|
|
|
if (!trimmed) return {};
|
|
|
|
|
|
try {
|
|
|
|
|
|
const o = JSON.parse(trimmed);
|
|
|
|
|
|
if (o && typeof o === "object" && !Array.isArray(o)) {
|
|
|
|
|
|
return Object.fromEntries(
|
|
|
|
|
|
Object.entries(o).map(([k, v]) => [String(k), v != null ? String(v) : ""])
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
return {};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function QRJsonFields({
|
|
|
|
|
|
selected,
|
|
|
|
|
|
update,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
selected: BarcodeLabelComponent;
|
|
|
|
|
|
update: (u: Partial<BarcodeLabelComponent>) => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const [pairs, setPairs] = useState<{ key: string; value: string }[]>(() => {
|
|
|
|
|
|
const parsed = parseQRJsonValue(selected.barcodeValue || "");
|
|
|
|
|
|
if (Object.keys(parsed).length > 0) {
|
|
|
|
|
|
return Object.entries(parsed).map(([key, value]) => ({ key, value }));
|
|
|
|
|
|
}
|
|
|
|
|
|
return DEFAULT_QR_JSON_KEYS.map((key) => ({ key, value: "" }));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 바코드 값이 바깥에서 바뀌면 파싱해서 동기화
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const parsed = parseQRJsonValue(selected.barcodeValue || "");
|
|
|
|
|
|
if (Object.keys(parsed).length > 0) {
|
|
|
|
|
|
setPairs(Object.entries(parsed).map(([key, value]) => ({ key, value: String(value ?? "") })));
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [selected.barcodeValue]);
|
|
|
|
|
|
|
|
|
|
|
|
const applyJson = () => {
|
|
|
|
|
|
const obj: Record<string, string> = {};
|
|
|
|
|
|
pairs.forEach(({ key, value }) => {
|
|
|
|
|
|
const k = key.trim();
|
|
|
|
|
|
if (k) obj[k] = value.trim();
|
|
|
|
|
|
});
|
|
|
|
|
|
update({ barcodeValue: JSON.stringify(obj) });
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const setPair = (index: number, field: "key" | "value", val: string) => {
|
|
|
|
|
|
setPairs((prev) => {
|
|
|
|
|
|
const next = [...prev];
|
|
|
|
|
|
if (!next[index]) next[index] = { key: "", value: "" };
|
|
|
|
|
|
next[index] = { ...next[index], [field]: val };
|
|
|
|
|
|
return next;
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const addRow = () => setPairs((prev) => [...prev, { key: "", value: "" }]);
|
|
|
|
|
|
const removeRow = (index: number) =>
|
|
|
|
|
|
setPairs((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== index)));
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="rounded-md border border-primary/30 bg-muted/20 p-3">
|
|
|
|
|
|
<Label className="text-xs font-medium">여러 값 입력 → JSON으로 QR 생성</Label>
|
|
|
|
|
|
<p className="text-muted-foreground mt-0.5 text-[10px]">키는 자유 입력, 값 입력 후 적용 버튼을 누르면 QR에 반영됩니다.</p>
|
|
|
|
|
|
<div className="mt-2 space-y-2">
|
|
|
|
|
|
{pairs.map((p, i) => (
|
|
|
|
|
|
<div key={i} className="flex gap-1 items-center">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
className="h-8 flex-1 min-w-0 text-xs"
|
|
|
|
|
|
placeholder="키 (예: part_no)"
|
|
|
|
|
|
value={p.key}
|
|
|
|
|
|
onChange={(e) => setPair(i, "key", e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
className="h-8 flex-1 min-w-0 text-xs"
|
|
|
|
|
|
placeholder="값"
|
|
|
|
|
|
value={p.value}
|
|
|
|
|
|
onChange={(e) => setPair(i, "value", e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
className="h-8 w-8 shrink-0 text-destructive"
|
|
|
|
|
|
onClick={() => removeRow(i)}
|
|
|
|
|
|
disabled={pairs.length <= 1}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="mt-2 flex gap-1">
|
|
|
|
|
|
<Button type="button" size="sm" variant="outline" className="flex-1 gap-1" onClick={addRow}>
|
|
|
|
|
|
<Plus className="h-3.5 w-3.5" />
|
|
|
|
|
|
필드 추가
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button type="button" size="sm" className="flex-1" onClick={applyJson}>
|
|
|
|
|
|
JSON으로 QR 적용
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 20:51:00 +09:00
|
|
|
|
export function BarcodeDesignerRightPanel() {
|
|
|
|
|
|
const {
|
|
|
|
|
|
components,
|
|
|
|
|
|
selectedComponentId,
|
|
|
|
|
|
updateComponent,
|
|
|
|
|
|
removeComponent,
|
|
|
|
|
|
selectComponent,
|
|
|
|
|
|
widthMm,
|
|
|
|
|
|
heightMm,
|
|
|
|
|
|
setWidthMm,
|
|
|
|
|
|
setHeightMm,
|
|
|
|
|
|
} = useBarcodeDesigner();
|
|
|
|
|
|
|
|
|
|
|
|
const selected = components.find((c) => c.id === selectedComponentId);
|
|
|
|
|
|
|
|
|
|
|
|
if (!selected) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="w-72 border-l bg-white p-4">
|
|
|
|
|
|
<p className="text-muted-foreground text-sm">요소를 선택하면 속성을 편집할 수 있습니다.</p>
|
|
|
|
|
|
<div className="mt-4 space-y-2">
|
|
|
|
|
|
<Label className="text-xs">라벨 크기 (mm)</Label>
|
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min={10}
|
|
|
|
|
|
max={200}
|
|
|
|
|
|
value={widthMm}
|
|
|
|
|
|
onChange={(e) => setWidthMm(Number(e.target.value) || 50)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="py-2">×</span>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min={10}
|
|
|
|
|
|
max={200}
|
|
|
|
|
|
value={heightMm}
|
|
|
|
|
|
onChange={(e) => setHeightMm(Number(e.target.value) || 30)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const update = (updates: Partial<BarcodeLabelComponent>) =>
|
|
|
|
|
|
updateComponent(selected.id, updates);
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-03-05 21:45:26 +09:00
|
|
|
|
<div className="flex w-72 flex-col border-l bg-white overflow-hidden">
|
|
|
|
|
|
<div className="shrink-0 border-b p-2 flex items-center justify-between">
|
2026-03-04 20:51:00 +09:00
|
|
|
|
<span className="text-sm font-medium">속성</span>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
className="h-8 w-8 text-destructive"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
removeComponent(selected.id);
|
|
|
|
|
|
selectComponent(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 className="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2026-03-05 21:45:26 +09:00
|
|
|
|
<ScrollArea className="flex-1 min-h-0">
|
2026-03-04 20:51:00 +09:00
|
|
|
|
<div className="space-y-4 p-4">
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Label className="text-xs">X (px)</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
value={Math.round(selected.x)}
|
|
|
|
|
|
onChange={(e) => update({ x: Number(e.target.value) || 0 })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Label className="text-xs">Y (px)</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
value={Math.round(selected.y)}
|
|
|
|
|
|
onChange={(e) => update({ y: Number(e.target.value) || 0 })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Label className="text-xs">너비</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min={4}
|
|
|
|
|
|
value={Math.round(selected.width)}
|
|
|
|
|
|
onChange={(e) => update({ width: Number(e.target.value) || 10 })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Label className="text-xs">높이</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min={4}
|
|
|
|
|
|
value={Math.round(selected.height)}
|
|
|
|
|
|
onChange={(e) => update({ height: Number(e.target.value) || 10 })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{selected.type === "text" && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Label className="text-xs">내용</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
value={selected.content || ""}
|
|
|
|
|
|
onChange={(e) => update({ content: e.target.value })}
|
|
|
|
|
|
placeholder="텍스트"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Label className="text-xs">글자 크기</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min={6}
|
|
|
|
|
|
max={72}
|
|
|
|
|
|
value={selected.fontSize || 10}
|
|
|
|
|
|
onChange={(e) => update({ fontSize: Number(e.target.value) || 10 })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Label className="text-xs">글자 색</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="color"
|
|
|
|
|
|
value={selected.fontColor || "#000000"}
|
|
|
|
|
|
onChange={(e) => update({ fontColor: e.target.value })}
|
|
|
|
|
|
className="h-9 w-20 p-1"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{selected.type === "barcode" && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Label className="text-xs">바코드 유형</Label>
|
|
|
|
|
|
<Select
|
|
|
|
|
|
value={selected.barcodeType || "CODE128"}
|
|
|
|
|
|
onValueChange={(v) => update({ barcodeType: v })}
|
|
|
|
|
|
>
|
|
|
|
|
|
<SelectTrigger className="h-9">
|
|
|
|
|
|
<SelectValue />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem value="CODE128">CODE128</SelectItem>
|
|
|
|
|
|
<SelectItem value="CODE39">CODE39</SelectItem>
|
|
|
|
|
|
<SelectItem value="EAN13">EAN13</SelectItem>
|
|
|
|
|
|
<SelectItem value="EAN8">EAN8</SelectItem>
|
|
|
|
|
|
<SelectItem value="QR">QR 코드</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
2026-03-05 21:45:26 +09:00
|
|
|
|
{selected.barcodeType === "QR" && (
|
|
|
|
|
|
<QRJsonFields selected={selected} update={update} />
|
|
|
|
|
|
)}
|
2026-03-04 20:51:00 +09:00
|
|
|
|
<div>
|
2026-03-05 21:45:26 +09:00
|
|
|
|
<Label className="text-xs">{selected.barcodeType === "QR" ? "값 (직접 JSON 입력)" : "값"}</Label>
|
2026-03-04 20:51:00 +09:00
|
|
|
|
<Input
|
|
|
|
|
|
value={selected.barcodeValue || ""}
|
|
|
|
|
|
onChange={(e) => update({ barcodeValue: e.target.value })}
|
2026-03-05 21:45:26 +09:00
|
|
|
|
placeholder={selected.barcodeType === "QR" ? '{"part_no":"","part_name":"","spec":""}' : "123456789"}
|
2026-03-04 20:51:00 +09:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<Switch
|
|
|
|
|
|
checked={selected.showBarcodeText !== false}
|
|
|
|
|
|
onCheckedChange={(v) => update({ showBarcodeText: v })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Label className="text-xs">숫자 표시 (1D)</Label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{selected.type === "line" && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Label className="text-xs">선 두께</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min={1}
|
|
|
|
|
|
value={selected.lineWidth || 1}
|
|
|
|
|
|
onChange={(e) => update({ lineWidth: Number(e.target.value) || 1 })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Label className="text-xs">색상</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="color"
|
|
|
|
|
|
value={selected.lineColor || "#000000"}
|
|
|
|
|
|
onChange={(e) => update({ lineColor: e.target.value })}
|
|
|
|
|
|
className="h-9 w-20 p-1"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{selected.type === "rectangle" && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Label className="text-xs">테두리 두께</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min={0}
|
|
|
|
|
|
value={selected.lineWidth ?? 1}
|
|
|
|
|
|
onChange={(e) => update({ lineWidth: Number(e.target.value) || 0 })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Label className="text-xs">테두리 색</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="color"
|
|
|
|
|
|
value={selected.lineColor || "#000000"}
|
|
|
|
|
|
onChange={(e) => update({ lineColor: e.target.value })}
|
|
|
|
|
|
className="h-9 w-20 p-1"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Label className="text-xs">배경 색</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="color"
|
|
|
|
|
|
value={selected.backgroundColor || "#ffffff"}
|
|
|
|
|
|
onChange={(e) => update({ backgroundColor: e.target.value })}
|
|
|
|
|
|
className="h-9 w-20 p-1"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{selected.type === "image" && (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Label className="text-xs">이미지 URL</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
value={selected.imageUrl || ""}
|
|
|
|
|
|
onChange={(e) => update({ imageUrl: e.target.value })}
|
|
|
|
|
|
placeholder="https://..."
|
|
|
|
|
|
/>
|
|
|
|
|
|
<p className="text-muted-foreground mt-1 text-xs">또는 나중에 업로드 기능 연동</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-03-05 21:45:26 +09:00
|
|
|
|
</ScrollArea>
|
2026-03-04 20:51:00 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|