234 lines
7.5 KiB
TypeScript
234 lines
7.5 KiB
TypeScript
|
|
"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>
|
|||
|
|
);
|
|||
|
|
}
|