바코드 기능 커밋밋
This commit is contained in:
244
frontend/components/barcode/BarcodeListTable.tsx
Normal file
244
frontend/components/barcode/BarcodeListTable.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { BarcodeLabelMaster } from "@/types/barcode";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Copy, Trash2, Loader2 } from "lucide-react";
|
||||
import { barcodeApi } from "@/lib/api/barcodeApi";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { format } from "date-fns";
|
||||
|
||||
interface BarcodeListTableProps {
|
||||
labels: BarcodeLabelMaster[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
isLoading: boolean;
|
||||
onPageChange: (page: number) => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function BarcodeListTable({
|
||||
labels,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
isLoading,
|
||||
onPageChange,
|
||||
onRefresh,
|
||||
}: BarcodeListTableProps) {
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isCopying, setIsCopying] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
const handleEdit = (labelId: string) => {
|
||||
router.push(`/admin/screenMng/barcodeList/designer/${labelId}`);
|
||||
};
|
||||
|
||||
const handleCopy = async (labelId: string) => {
|
||||
setIsCopying(true);
|
||||
try {
|
||||
const response = await barcodeApi.copyLabel(labelId);
|
||||
if (response.success) {
|
||||
toast({
|
||||
title: "성공",
|
||||
description: "바코드 라벨이 복사되었습니다.",
|
||||
});
|
||||
onRefresh();
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: "오류",
|
||||
description: error.message || "바코드 라벨 복사에 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsCopying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteClick = (labelId: string) => {
|
||||
setDeleteTarget(labelId);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteTarget) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const response = await barcodeApi.deleteLabel(deleteTarget);
|
||||
if (response.success) {
|
||||
toast({
|
||||
title: "성공",
|
||||
description: "바코드 라벨이 삭제되었습니다.",
|
||||
});
|
||||
setDeleteTarget(null);
|
||||
onRefresh();
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: "오류",
|
||||
description: error.message || "바코드 라벨 삭제에 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return "-";
|
||||
try {
|
||||
return format(new Date(dateString), "yyyy-MM-dd");
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (labels.length === 0) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex h-64 flex-col items-center justify-center">
|
||||
<p>등록된 바코드 라벨이 없습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[80px]">No</TableHead>
|
||||
<TableHead>라벨명</TableHead>
|
||||
<TableHead className="w-[120px]">템플릿 유형</TableHead>
|
||||
<TableHead className="w-[120px]">작성자</TableHead>
|
||||
<TableHead className="w-[120px]">수정일</TableHead>
|
||||
<TableHead className="w-[200px]">액션</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{labels.map((label, index) => {
|
||||
const rowNumber = (page - 1) * limit + index + 1;
|
||||
return (
|
||||
<TableRow
|
||||
key={label.label_id}
|
||||
onClick={() => handleEdit(label.label_id)}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
>
|
||||
<TableCell className="font-medium">{rowNumber}</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{label.label_name_kor}</div>
|
||||
{label.label_name_eng && (
|
||||
<div className="text-muted-foreground text-sm">{label.label_name_eng}</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{label.width_mm != null && label.height_mm != null
|
||||
? `${label.width_mm}×${label.height_mm}mm`
|
||||
: label.template_type || "-"}
|
||||
</TableCell>
|
||||
<TableCell>{label.created_by || "-"}</TableCell>
|
||||
<TableCell>{formatDate(label.updated_at || label.created_at)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => handleCopy(label.label_id)}
|
||||
disabled={isCopying}
|
||||
className="h-8 w-8"
|
||||
title="복사"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="destructive"
|
||||
onClick={() => handleDeleteClick(label.label_id)}
|
||||
className="h-8 w-8"
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 p-4">
|
||||
<Button variant="outline" size="sm" onClick={() => onPageChange(page - 1)} disabled={page === 1}>
|
||||
이전
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={() => onPageChange(page + 1)} disabled={page === totalPages}>
|
||||
다음
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertDialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>바코드 라벨 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
이 바코드 라벨을 삭제하시겠습니까?
|
||||
<br />
|
||||
삭제된 라벨은 복구할 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={isDeleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
삭제 중...
|
||||
</>
|
||||
) : (
|
||||
"삭제"
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useDrag } from "react-dnd";
|
||||
import { Type, Barcode, Image, Minus, Square } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { BarcodeLabelComponent } from "@/types/barcode";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
const ITEMS: { type: BarcodeLabelComponent["type"]; label: string; icon: React.ReactNode }[] = [
|
||||
{ type: "text", label: "텍스트", icon: <Type className="h-4 w-4" /> },
|
||||
{ type: "barcode", label: "바코드", icon: <Barcode className="h-4 w-4" /> },
|
||||
{ type: "image", label: "이미지", icon: <Image className="h-4 w-4" /> },
|
||||
{ type: "line", label: "선", icon: <Minus className="h-4 w-4" /> },
|
||||
{ type: "rectangle", label: "사각형", icon: <Square className="h-4 w-4" /> },
|
||||
];
|
||||
|
||||
const MM_TO_PX = 4;
|
||||
|
||||
function defaultComponent(type: BarcodeLabelComponent["type"]): BarcodeLabelComponent {
|
||||
const id = `comp_${uuidv4()}`;
|
||||
const base = { id, type, x: 10 * MM_TO_PX, y: 10 * MM_TO_PX, width: 80, height: 24, zIndex: 0 };
|
||||
|
||||
switch (type) {
|
||||
case "text":
|
||||
return { ...base, content: "텍스트", fontSize: 10, fontColor: "#000000" };
|
||||
case "barcode":
|
||||
return {
|
||||
...base,
|
||||
width: 120,
|
||||
height: 40,
|
||||
barcodeType: "CODE128",
|
||||
barcodeValue: "123456789",
|
||||
showBarcodeText: true,
|
||||
};
|
||||
case "image":
|
||||
return { ...base, width: 60, height: 60, imageUrl: "", objectFit: "contain" };
|
||||
case "line":
|
||||
return { ...base, width: 100, height: 2, lineColor: "#000", lineWidth: 1 };
|
||||
case "rectangle":
|
||||
return { ...base, width: 80, height: 40, backgroundColor: "transparent", lineColor: "#000", lineWidth: 1 };
|
||||
default:
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
function DraggableItem({
|
||||
type,
|
||||
label,
|
||||
icon,
|
||||
}: {
|
||||
type: BarcodeLabelComponent["type"];
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}) {
|
||||
const [{ isDragging }, drag] = useDrag(() => ({
|
||||
type: "barcode-component",
|
||||
item: { component: defaultComponent(type) },
|
||||
collect: (m) => ({ isDragging: m.isDragging() }),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={drag}
|
||||
className={`flex cursor-move items-center gap-2 rounded border p-2 text-sm hover:border-blue-500 hover:bg-blue-50 ${
|
||||
isDragging ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BarcodeComponentPalette() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">요소 추가</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{ITEMS.map((item) => (
|
||||
<DraggableItem key={item.type} type={item.type} label={item.label} icon={item.icon} />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useBarcodeDesigner, MM_TO_PX } from "@/contexts/BarcodeDesignerContext";
|
||||
import { BarcodeLabelCanvasComponent } from "./BarcodeLabelCanvasComponent";
|
||||
import { BarcodeLabelComponent } from "@/types/barcode";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
export function BarcodeDesignerCanvas() {
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
widthMm,
|
||||
heightMm,
|
||||
components,
|
||||
addComponent,
|
||||
selectComponent,
|
||||
showGrid,
|
||||
snapValueToGrid,
|
||||
} = useBarcodeDesigner();
|
||||
|
||||
const widthPx = widthMm * MM_TO_PX;
|
||||
const heightPx = heightMm * MM_TO_PX;
|
||||
|
||||
const [{ isOver }, drop] = useDrop(() => ({
|
||||
accept: "barcode-component",
|
||||
drop: (item: { component: BarcodeLabelComponent }, monitor) => {
|
||||
if (!canvasRef.current) return;
|
||||
const offset = monitor.getClientOffset();
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
if (!offset) return;
|
||||
|
||||
let x = offset.x - rect.left;
|
||||
let y = offset.y - rect.top;
|
||||
// 드롭 시 요소 중앙이 커서에 오도록 보정
|
||||
x -= item.component.width / 2;
|
||||
y -= item.component.height / 2;
|
||||
x = Math.max(0, Math.min(x, widthPx - item.component.width));
|
||||
y = Math.max(0, Math.min(y, heightPx - item.component.height));
|
||||
|
||||
const newComp: BarcodeLabelComponent = {
|
||||
...item.component,
|
||||
id: `comp_${uuidv4()}`,
|
||||
x: snapValueToGrid(x),
|
||||
y: snapValueToGrid(y),
|
||||
zIndex: components.length,
|
||||
};
|
||||
addComponent(newComp);
|
||||
},
|
||||
collect: (m) => ({ isOver: m.isOver() }),
|
||||
}), [widthPx, heightPx, components.length, addComponent, snapValueToGrid]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center overflow-auto bg-gray-100 p-6">
|
||||
<div
|
||||
key={`canvas-${widthMm}-${heightMm}`}
|
||||
ref={(r) => {
|
||||
(canvasRef as any).current = r;
|
||||
drop(r);
|
||||
}}
|
||||
className="relative bg-white shadow-lg"
|
||||
style={{
|
||||
width: widthPx,
|
||||
height: heightPx,
|
||||
minWidth: widthPx,
|
||||
minHeight: heightPx,
|
||||
backgroundImage: showGrid
|
||||
? `linear-gradient(to right, #e5e7eb 1px, transparent 1px),
|
||||
linear-gradient(to bottom, #e5e7eb 1px, transparent 1px)`
|
||||
: undefined,
|
||||
backgroundSize: showGrid ? `${MM_TO_PX * 5}px ${MM_TO_PX * 5}px` : undefined,
|
||||
outline: isOver ? "2px dashed #2563eb" : "1px solid #d1d5db",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) selectComponent(null);
|
||||
}}
|
||||
>
|
||||
{components.map((c) => (
|
||||
<BarcodeLabelCanvasComponent key={c.id} component={c} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { BarcodeTemplatePalette } from "./BarcodeTemplatePalette";
|
||||
import { BarcodeComponentPalette } from "./BarcodeComponentPalette";
|
||||
|
||||
export function BarcodeDesignerLeftPanel() {
|
||||
return (
|
||||
<div className="flex min-h-0 w-64 shrink-0 flex-col overflow-hidden border-r bg-white">
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
<BarcodeTemplatePalette />
|
||||
<BarcodeComponentPalette />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { useBarcodeDesigner } from "@/contexts/BarcodeDesignerContext";
|
||||
import { BarcodeLabelComponent } from "@/types/barcode";
|
||||
|
||||
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 (
|
||||
<div className="w-72 border-l bg-white">
|
||||
<div className="border-b p-2 flex items-center justify-between">
|
||||
<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>
|
||||
<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>
|
||||
<div>
|
||||
<Label className="text-xs">값</Label>
|
||||
<Input
|
||||
value={selected.barcodeValue || ""}
|
||||
onChange={(e) => update({ barcodeValue: e.target.value })}
|
||||
placeholder="123456789"
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
179
frontend/components/barcode/designer/BarcodeDesignerToolbar.tsx
Normal file
179
frontend/components/barcode/designer/BarcodeDesignerToolbar.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ArrowLeft, Save, Loader2, Download, Printer } from "lucide-react";
|
||||
import { useBarcodeDesigner } from "@/contexts/BarcodeDesignerContext";
|
||||
import { barcodeApi } from "@/lib/api/barcodeApi";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { generateZPL } from "@/lib/zplGenerator";
|
||||
import { BarcodePrintPreviewModal } from "./BarcodePrintPreviewModal";
|
||||
|
||||
export function BarcodeDesignerToolbar() {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const {
|
||||
labelId,
|
||||
labelMaster,
|
||||
widthMm,
|
||||
heightMm,
|
||||
components,
|
||||
saveLayout,
|
||||
isSaving,
|
||||
} = useBarcodeDesigner();
|
||||
|
||||
const handleDownloadZPL = () => {
|
||||
const layout = { width_mm: widthMm, height_mm: heightMm, components };
|
||||
const zpl = generateZPL(layout);
|
||||
const blob = new Blob([zpl], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = (labelMaster?.label_name_kor || "label") + ".zpl";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast({ title: "다운로드", description: "ZPL 파일이 다운로드되었습니다. Zebra 프린터/유틸에서 사용하세요." });
|
||||
};
|
||||
|
||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||
const [printPreviewOpen, setPrintPreviewOpen] = useState(false);
|
||||
const [newLabelName, setNewLabelName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (labelId !== "new") {
|
||||
await saveLayout();
|
||||
return;
|
||||
}
|
||||
setSaveDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCreateAndSave = async () => {
|
||||
const name = newLabelName.trim();
|
||||
if (!name) {
|
||||
toast({
|
||||
title: "입력 필요",
|
||||
description: "라벨명을 입력하세요.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
const createRes = await barcodeApi.createLabel({
|
||||
labelNameKor: name,
|
||||
});
|
||||
if (!createRes.success || !createRes.data?.labelId) throw new Error(createRes.message || "생성 실패");
|
||||
const newId = createRes.data.labelId;
|
||||
|
||||
await barcodeApi.saveLayout(newId, {
|
||||
width_mm: widthMm,
|
||||
height_mm: heightMm,
|
||||
components: components.map((c, i) => ({ ...c, zIndex: i })),
|
||||
});
|
||||
|
||||
toast({ title: "저장됨", description: "라벨이 생성되었습니다." });
|
||||
setSaveDialogOpen(false);
|
||||
setNewLabelName("");
|
||||
router.push(`/admin/screenMng/barcodeList/designer/${newId}`);
|
||||
} catch (e: any) {
|
||||
toast({
|
||||
title: "저장 실패",
|
||||
description: e.message || "라벨 생성에 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between border-b bg-white px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1"
|
||||
onClick={() => router.push("/admin/screenMng/barcodeList")}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
목록
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{labelId === "new" ? "새 라벨" : labelMaster?.label_name_kor || "바코드 라벨 디자이너"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1"
|
||||
onClick={() => setPrintPreviewOpen(true)}
|
||||
>
|
||||
<Printer className="h-4 w-4" />
|
||||
인쇄 미리보기
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="gap-1" onClick={handleDownloadZPL}>
|
||||
<Download className="h-4 w-4" />
|
||||
ZPL 다운로드
|
||||
</Button>
|
||||
<Button size="sm" className="gap-1" onClick={handleSave} disabled={isSaving || creating}>
|
||||
{(isSaving || creating) ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BarcodePrintPreviewModal
|
||||
open={printPreviewOpen}
|
||||
onOpenChange={setPrintPreviewOpen}
|
||||
layout={{
|
||||
width_mm: widthMm,
|
||||
height_mm: heightMm,
|
||||
components: components.map((c, i) => ({ ...c, zIndex: i })),
|
||||
}}
|
||||
labelName={labelMaster?.label_name_kor || "라벨"}
|
||||
/>
|
||||
|
||||
<Dialog open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>새 라벨 저장</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 py-2">
|
||||
<Label>라벨명 (한글)</Label>
|
||||
<Input
|
||||
value={newLabelName}
|
||||
onChange={(e) => setNewLabelName(e.target.value)}
|
||||
placeholder="예: 품목 바코드 라벨"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSaveDialogOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleCreateAndSave} disabled={creating}>
|
||||
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { BarcodeLabelComponent } from "@/types/barcode";
|
||||
import { useBarcodeDesigner } from "@/contexts/BarcodeDesignerContext";
|
||||
import JsBarcode from "jsbarcode";
|
||||
import QRCode from "qrcode";
|
||||
import { getFullImageUrl } from "@/lib/api/client";
|
||||
import { MM_TO_PX } from "@/contexts/BarcodeDesignerContext";
|
||||
|
||||
interface Props {
|
||||
component: BarcodeLabelComponent;
|
||||
}
|
||||
|
||||
// 1D 바코드 렌더
|
||||
function Barcode1DRender({
|
||||
value,
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
showText,
|
||||
}: {
|
||||
value: string;
|
||||
format: string;
|
||||
width: number;
|
||||
height: number;
|
||||
showText: boolean;
|
||||
}) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !value.trim()) return;
|
||||
try {
|
||||
JsBarcode(svgRef.current, value.trim(), {
|
||||
format: format.toLowerCase(),
|
||||
width: 2,
|
||||
height: Math.max(20, height - (showText ? 14 : 4)),
|
||||
displayValue: showText,
|
||||
margin: 2,
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [value, format, height, showText]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center overflow-hidden">
|
||||
<svg ref={svgRef} className="max-h-full max-w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// QR 렌더
|
||||
function QRRender({ value, size }: { value: string; size: number }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || !value.trim()) return;
|
||||
QRCode.toCanvas(canvasRef.current, value.trim(), {
|
||||
width: Math.max(40, size),
|
||||
margin: 1,
|
||||
});
|
||||
}, [value, size]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center overflow-hidden">
|
||||
<canvas ref={canvasRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BarcodeLabelCanvasComponent({ component }: Props) {
|
||||
const {
|
||||
updateComponent,
|
||||
removeComponent,
|
||||
selectComponent,
|
||||
selectedComponentId,
|
||||
snapValueToGrid,
|
||||
} = useBarcodeDesigner();
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0, compX: 0, compY: 0 });
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [resizeStart, setResizeStart] = useState({ x: 0, y: 0, w: 0, h: 0 });
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const selected = selectedComponentId === component.id;
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
selectComponent(component.id);
|
||||
if ((e.target as HTMLElement).closest("[data-resize-handle]")) {
|
||||
setIsResizing(true);
|
||||
setResizeStart({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
w: component.width,
|
||||
h: component.height,
|
||||
});
|
||||
} else {
|
||||
setIsDragging(true);
|
||||
setDragStart({ x: e.clientX, y: e.clientY, compX: component.x, compY: component.y });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging && !isResizing) return;
|
||||
|
||||
const onMove = (e: MouseEvent) => {
|
||||
if (isDragging) {
|
||||
const dx = e.clientX - dragStart.x;
|
||||
const dy = e.clientY - dragStart.y;
|
||||
updateComponent(component.id, {
|
||||
x: Math.max(0, snapValueToGrid(dragStart.compX + dx)),
|
||||
y: Math.max(0, snapValueToGrid(dragStart.compY + dy)),
|
||||
});
|
||||
} else if (isResizing) {
|
||||
const dx = e.clientX - resizeStart.x;
|
||||
const dy = e.clientY - resizeStart.y;
|
||||
updateComponent(component.id, {
|
||||
width: Math.max(20, resizeStart.w + dx),
|
||||
height: Math.max(10, resizeStart.h + dy),
|
||||
});
|
||||
}
|
||||
};
|
||||
const onUp = () => {
|
||||
setIsDragging(false);
|
||||
setIsResizing(false);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
}, [
|
||||
isDragging,
|
||||
isResizing,
|
||||
dragStart,
|
||||
resizeStart,
|
||||
component.id,
|
||||
updateComponent,
|
||||
snapValueToGrid,
|
||||
]);
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
left: component.x,
|
||||
top: component.y,
|
||||
width: component.width,
|
||||
height: component.height,
|
||||
zIndex: component.zIndex,
|
||||
};
|
||||
|
||||
const border = selected ? "2px solid #2563eb" : "1px solid transparent";
|
||||
const isBarcode = component.type === "barcode";
|
||||
const isQR = component.barcodeType === "QR";
|
||||
|
||||
const content = () => {
|
||||
switch (component.type) {
|
||||
case "text":
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
fontSize: component.fontSize || 10,
|
||||
color: component.fontColor || "#000",
|
||||
fontWeight: component.fontWeight || "normal",
|
||||
overflow: "hidden",
|
||||
wordBreak: "break-all",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
{component.content || "텍스트"}
|
||||
</div>
|
||||
);
|
||||
case "barcode":
|
||||
if (isQR) {
|
||||
return (
|
||||
<QRRender
|
||||
value={component.barcodeValue || ""}
|
||||
size={Math.min(component.width, component.height)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Barcode1DRender
|
||||
value={component.barcodeValue || "123456789"}
|
||||
format={component.barcodeType || "CODE128"}
|
||||
width={component.width}
|
||||
height={component.height}
|
||||
showText={component.showBarcodeText !== false}
|
||||
/>
|
||||
);
|
||||
case "image":
|
||||
return component.imageUrl ? (
|
||||
<img
|
||||
src={getFullImageUrl(component.imageUrl)}
|
||||
alt=""
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: (component.objectFit as "contain") || "contain",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-gray-100 text-xs text-gray-400">
|
||||
이미지
|
||||
</div>
|
||||
);
|
||||
case "line":
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: component.lineWidth || 1,
|
||||
backgroundColor: component.lineColor || "#000",
|
||||
marginTop: (component.height - (component.lineWidth || 1)) / 2,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case "rectangle":
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backgroundColor: component.backgroundColor || "transparent",
|
||||
border: `${component.lineWidth || 1}px solid ${component.lineColor || "#000"}`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{ ...style, border }}
|
||||
className="cursor-move overflow-hidden bg-white"
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
{content()}
|
||||
{selected && component.type !== "line" && (
|
||||
<div
|
||||
data-resize-handle
|
||||
className="absolute bottom-0 right-0 h-2 w-2 cursor-se-resize bg-blue-500"
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsResizing(true);
|
||||
setResizeStart({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
w: component.width,
|
||||
h: component.height,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Download, Printer, Loader2, AlertCircle } from "lucide-react";
|
||||
import { BarcodeLabelLayout } from "@/types/barcode";
|
||||
import { generateZPL } from "@/lib/zplGenerator";
|
||||
import {
|
||||
printZPLToZebraBLE,
|
||||
isWebBluetoothSupported,
|
||||
getUnsupportedMessage,
|
||||
} from "@/lib/zebraBluetooth";
|
||||
import {
|
||||
printZPLToBrowserPrint,
|
||||
getBrowserPrintHelpMessage,
|
||||
} from "@/lib/zebraBrowserPrint";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { BarcodeLabelCanvasComponent } from "./BarcodeLabelCanvasComponent";
|
||||
import { MM_TO_PX } from "@/contexts/BarcodeDesignerContext";
|
||||
|
||||
const PREVIEW_MAX_PX = 320;
|
||||
|
||||
interface BarcodePrintPreviewModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
layout: BarcodeLabelLayout;
|
||||
labelName?: string;
|
||||
}
|
||||
|
||||
export function BarcodePrintPreviewModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
layout,
|
||||
labelName = "라벨",
|
||||
}: BarcodePrintPreviewModalProps) {
|
||||
const { toast } = useToast();
|
||||
const [printing, setPrinting] = useState(false);
|
||||
|
||||
const { width_mm, height_mm, components } = layout;
|
||||
const widthPx = width_mm * MM_TO_PX;
|
||||
const heightPx = height_mm * MM_TO_PX;
|
||||
const scale =
|
||||
widthPx > PREVIEW_MAX_PX || heightPx > PREVIEW_MAX_PX
|
||||
? Math.min(PREVIEW_MAX_PX / widthPx, PREVIEW_MAX_PX / heightPx)
|
||||
: 1;
|
||||
const previewW = Math.round(widthPx * scale);
|
||||
const previewH = Math.round(heightPx * scale);
|
||||
|
||||
const zpl = generateZPL(layout);
|
||||
const bleSupported = isWebBluetoothSupported();
|
||||
const unsupportedMsg = getUnsupportedMessage();
|
||||
|
||||
const handleDownloadZPL = () => {
|
||||
const blob = new Blob([zpl], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${labelName}.zpl`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast({ title: "다운로드", description: "ZPL 파일이 저장되었습니다." });
|
||||
};
|
||||
|
||||
const handlePrintToZebra = async () => {
|
||||
const canUseBle = bleSupported;
|
||||
if (!canUseBle) {
|
||||
// Browser Print만 시도 (스크립트 로드 후 기본 프린터로 전송)
|
||||
setPrinting(true);
|
||||
try {
|
||||
const result = await printZPLToBrowserPrint(zpl);
|
||||
if (result.success) {
|
||||
toast({ title: "전송 완료", description: result.message });
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
toast({
|
||||
title: "출력 실패",
|
||||
description: result.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
toast({
|
||||
title: "안내",
|
||||
description: getBrowserPrintHelpMessage(),
|
||||
variant: "default",
|
||||
});
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
toast({
|
||||
title: "오류",
|
||||
description: (e as Error).message || "Zebra 출력 중 오류가 발생했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setPrinting(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Web Bluetooth 지원 시: Browser Print 먼저 시도, 실패하면 BLE로 폴백
|
||||
setPrinting(true);
|
||||
try {
|
||||
const bpResult = await printZPLToBrowserPrint(zpl);
|
||||
if (bpResult.success) {
|
||||
toast({ title: "전송 완료", description: bpResult.message });
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
const bleResult = await printZPLToZebraBLE(zpl);
|
||||
if (bleResult.success) {
|
||||
toast({ title: "전송 완료", description: bleResult.message });
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
toast({
|
||||
title: "출력 실패",
|
||||
description: bleResult.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
toast({
|
||||
title: "안내",
|
||||
description: getBrowserPrintHelpMessage(),
|
||||
variant: "default",
|
||||
});
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
toast({
|
||||
title: "오류",
|
||||
description: (e as Error).message || "Zebra 출력 중 오류가 발생했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setPrinting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>인쇄 미리보기</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{width_mm}×{height_mm}mm · {components.length}개 요소
|
||||
</p>
|
||||
|
||||
{/* 미리보기 캔버스 (축소) */}
|
||||
<div className="flex justify-center rounded border bg-gray-100 p-4">
|
||||
<div
|
||||
className="relative bg-white shadow"
|
||||
style={{
|
||||
width: previewW,
|
||||
height: previewH,
|
||||
transformOrigin: "top left",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none"
|
||||
style={{
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "0 0",
|
||||
width: widthPx,
|
||||
height: heightPx,
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
}}
|
||||
>
|
||||
{components.map((c) => (
|
||||
<BarcodeLabelCanvasComponent key={c.id} component={c} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!bleSupported && (
|
||||
<div className="flex gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<span>
|
||||
Web Bluetooth 미지원 브라우저입니다. Zebra Browser Print 앱을 설치하면 출력할 수 있습니다.
|
||||
{unsupportedMsg && ` ${unsupportedMsg}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{bleSupported ? (
|
||||
<>
|
||||
Zebra 프린터를 Bluetooth LE로 켜 두고, 출력 시 기기 선택에서 프린터를 선택하세요.
|
||||
(Chrome/Edge 권장)
|
||||
{typeof navigator !== "undefined" && /Android/i.test(navigator.userAgent) && (
|
||||
<> Android에서는 목록에 인근 BLE 기기가 모두 표시되므로, 'ZD421' 등 프린터 이름을 골라 주세요.</>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
{typeof navigator !== "undefined" && /Android/i.test(navigator.userAgent) && (
|
||||
<>
|
||||
{" "}
|
||||
목록에 프린터가 안 나오면 지브라 공식 'Zebra Browser Print' 앱을 설치한 뒤, 앱에서 프린터 검색·기본 설정 후 이 사이트를 허용하면 출력할 수 있습니다.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" size="sm" onClick={handleDownloadZPL} className="gap-1">
|
||||
<Download className="h-4 w-4" />
|
||||
ZPL 다운로드
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-1"
|
||||
onClick={handlePrintToZebra}
|
||||
disabled={printing}
|
||||
>
|
||||
{printing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Printer className="h-4 w-4" />
|
||||
)}
|
||||
Zebra 프린터로 출력
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
132
frontend/components/barcode/designer/BarcodeTemplatePalette.tsx
Normal file
132
frontend/components/barcode/designer/BarcodeTemplatePalette.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Loader2, Search } from "lucide-react";
|
||||
import { useBarcodeDesigner } from "@/contexts/BarcodeDesignerContext";
|
||||
import { barcodeApi, BarcodeLabelTemplate } from "@/lib/api/barcodeApi";
|
||||
|
||||
type Category = "all" | "basic" | "zebra";
|
||||
|
||||
export function BarcodeTemplatePalette() {
|
||||
const { applyTemplate } = useBarcodeDesigner();
|
||||
const [templates, setTemplates] = useState<BarcodeLabelTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [category, setCategory] = useState<Category>("all");
|
||||
const [searchText, setSearchText] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await barcodeApi.getTemplates();
|
||||
if (res.success && res.data) setTemplates(res.data);
|
||||
} catch {
|
||||
setTemplates([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = templates;
|
||||
if (category === "basic") {
|
||||
list = list.filter((t) => t.template_id.startsWith("TMPL_"));
|
||||
} else if (category === "zebra") {
|
||||
list = list.filter((t) => t.template_id.startsWith("ZJ"));
|
||||
}
|
||||
const q = searchText.trim().toLowerCase();
|
||||
if (q) {
|
||||
list = list.filter(
|
||||
(t) =>
|
||||
t.template_id.toLowerCase().includes(q) ||
|
||||
(t.template_name_kor && t.template_name_kor.toLowerCase().includes(q)) ||
|
||||
(t.template_name_eng && t.template_name_eng.toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
return list;
|
||||
}, [templates, category, searchText]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">라벨 규격</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center py-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">라벨 규격</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="코드·이름으로 찾기"
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="h-8 pl-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant={category === "all" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => setCategory("all")}
|
||||
>
|
||||
전체
|
||||
</Button>
|
||||
<Button
|
||||
variant={category === "basic" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => setCategory("basic")}
|
||||
>
|
||||
기본
|
||||
</Button>
|
||||
<Button
|
||||
variant={category === "zebra" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => setCategory("zebra")}
|
||||
>
|
||||
제트라벨
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea className="h-[280px] pr-2">
|
||||
<div className="space-y-1">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-muted-foreground py-2 text-center text-xs">검색 결과 없음</p>
|
||||
) : (
|
||||
filtered.map((t) => (
|
||||
<Button
|
||||
key={t.template_id}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-auto w-full justify-start py-1.5 text-left"
|
||||
onClick={() => applyTemplate(t.template_id)}
|
||||
>
|
||||
<span className="truncate">{t.template_name_kor}</span>
|
||||
<span className="text-muted-foreground ml-1 shrink-0 text-xs">
|
||||
{t.width_mm}×{t.height_mm}
|
||||
</span>
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user