바코드 기능 커밋밋
This commit is contained in:
115
frontend/lib/api/barcodeApi.ts
Normal file
115
frontend/lib/api/barcodeApi.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { apiClient } from "./client";
|
||||
import {
|
||||
BarcodeLabelMaster,
|
||||
BarcodeLabelLayout,
|
||||
GetBarcodeLabelsParams,
|
||||
GetBarcodeLabelsResponse,
|
||||
CreateBarcodeLabelRequest,
|
||||
UpdateBarcodeLabelRequest,
|
||||
} from "@/types/barcode";
|
||||
|
||||
const BASE_URL = "/admin/barcode-labels";
|
||||
|
||||
export interface BarcodeLabelTemplate {
|
||||
template_id: string;
|
||||
template_name_kor: string;
|
||||
template_name_eng: string | null;
|
||||
width_mm: number;
|
||||
height_mm: number;
|
||||
layout_json: string;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export const barcodeApi = {
|
||||
/** 바코드 라벨 목록 조회 */
|
||||
getLabels: async (params: GetBarcodeLabelsParams) => {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: GetBarcodeLabelsResponse;
|
||||
}>(BASE_URL, { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** 바코드 라벨 상세 조회 */
|
||||
getLabelById: async (labelId: string) => {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: BarcodeLabelMaster;
|
||||
}>(`${BASE_URL}/${labelId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** 라벨 레이아웃 조회 */
|
||||
getLayout: async (labelId: string) => {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: BarcodeLabelLayout;
|
||||
}>(`${BASE_URL}/${labelId}/layout`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** 라벨 레이아웃 저장 */
|
||||
saveLayout: async (labelId: string, layout: BarcodeLabelLayout) => {
|
||||
const response = await apiClient.put<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
}>(`${BASE_URL}/${labelId}/layout`, layout);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** 기본 템플릿 목록 */
|
||||
getTemplates: async () => {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: BarcodeLabelTemplate[];
|
||||
}>(`${BASE_URL}/templates`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** 템플릿 상세 (레이아웃 적용용) */
|
||||
getTemplateById: async (templateId: string) => {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: BarcodeLabelTemplate & { layout: BarcodeLabelLayout };
|
||||
}>(`${BASE_URL}/templates/${templateId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** 바코드 라벨 생성 (templateId 선택 시 해당 레이아웃 적용) */
|
||||
createLabel: async (data: CreateBarcodeLabelRequest & { templateId?: string }) => {
|
||||
const response = await apiClient.post<{
|
||||
success: boolean;
|
||||
data: { labelId: string };
|
||||
message: string;
|
||||
}>(BASE_URL, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** 바코드 라벨 수정 */
|
||||
updateLabel: async (labelId: string, data: UpdateBarcodeLabelRequest) => {
|
||||
const response = await apiClient.put<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
}>(`${BASE_URL}/${labelId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** 바코드 라벨 삭제 */
|
||||
deleteLabel: async (labelId: string) => {
|
||||
const response = await apiClient.delete<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
}>(`${BASE_URL}/${labelId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** 바코드 라벨 복사 */
|
||||
copyLabel: async (labelId: string) => {
|
||||
const response = await apiClient.post<{
|
||||
success: boolean;
|
||||
data: { labelId: string };
|
||||
message: string;
|
||||
}>(`${BASE_URL}/${labelId}/copy`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
118
frontend/lib/zebraBluetooth.ts
Normal file
118
frontend/lib/zebraBluetooth.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Zebra 프린터 Web Bluetooth LE 연동
|
||||
* Chrome/Edge (Chromium) 에서만 지원. BLE로 ZPL 전송 (512바이트 청크)
|
||||
* 참고: https://developer.zebra.com/content/printing-webapp-using-webbluetooth
|
||||
*/
|
||||
|
||||
const ZEBRA_BLE_SERVICE_UUID = "38eb4a80-c570-11e3-9507-0002a5d5c51b";
|
||||
const ZEBRA_BLE_CHAR_UUID = "38eb4a82-c570-11e3-9507-0002a5d5c51b";
|
||||
const CHUNK_SIZE = 512;
|
||||
const CHUNK_DELAY_MS = 20;
|
||||
|
||||
export function isWebBluetoothSupported(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return !!(navigator.bluetooth && navigator.bluetooth.requestDevice);
|
||||
}
|
||||
|
||||
/** 지원 브라우저 안내 문구 */
|
||||
export function getUnsupportedMessage(): string {
|
||||
if (!isWebBluetoothSupported()) {
|
||||
return "이 브라우저는 Web Bluetooth를 지원하지 않습니다. Chrome 또는 Edge(Chromium)에서 열어주세요. HTTPS 또는 localhost 필요.";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export interface ZebraPrintResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zebra 프린터를 BLE로 선택·연결 후 ZPL 데이터 전송
|
||||
* - 사용자에게 블루투스 기기 선택 창이 뜸 (Zebra 프린터 BLE 선택)
|
||||
* - ZPL을 512바이트 단위로 나누어 순차 전송
|
||||
*/
|
||||
export async function printZPLToZebraBLE(zpl: string): Promise<ZebraPrintResult> {
|
||||
if (!isWebBluetoothSupported()) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Web Bluetooth를 지원하지 않는 브라우저입니다. Chrome 또는 Edge에서 시도해주세요.",
|
||||
};
|
||||
}
|
||||
|
||||
let device: BluetoothDevice | null = null;
|
||||
let server: BluetoothRemoteGATTServer | null = null;
|
||||
|
||||
try {
|
||||
// 1) 서비스 UUID로만 필터 시 Android에서 Zebra가 광고하지 않으면 목록에 안 나옴.
|
||||
// 2) acceptAllDevices + optionalServices 로 모든 BLE 기기 표시 후, 연결해 Zebra 서비스 사용.
|
||||
const useAcceptAll =
|
||||
typeof navigator !== "undefined" &&
|
||||
/Android/i.test(navigator.userAgent);
|
||||
|
||||
if (useAcceptAll) {
|
||||
device = await navigator.bluetooth.requestDevice({
|
||||
acceptAllDevices: true,
|
||||
optionalServices: [ZEBRA_BLE_SERVICE_UUID],
|
||||
});
|
||||
} else {
|
||||
device = await navigator.bluetooth.requestDevice({
|
||||
filters: [{ services: [ZEBRA_BLE_SERVICE_UUID] }],
|
||||
optionalServices: [ZEBRA_BLE_SERVICE_UUID],
|
||||
});
|
||||
}
|
||||
|
||||
if (!device) {
|
||||
return { success: false, message: "프린터를 선택하지 않았습니다." };
|
||||
}
|
||||
|
||||
server = await device.gatt!.connect();
|
||||
let service: BluetoothRemoteGATTService;
|
||||
try {
|
||||
service = await server.getPrimaryService(ZEBRA_BLE_SERVICE_UUID);
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
"선택한 기기는 Zebra 프린터가 아니거나 BLE 인쇄를 지원하지 않습니다. 'ZD421' 등 Zebra 프린터를 선택해 주세요.",
|
||||
};
|
||||
}
|
||||
const characteristic = await service.getCharacteristic(ZEBRA_BLE_CHAR_UUID);
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(zpl);
|
||||
const totalChunks = Math.ceil(data.length / CHUNK_SIZE);
|
||||
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const start = i * CHUNK_SIZE;
|
||||
const end = Math.min(start + CHUNK_SIZE, data.length);
|
||||
const chunk = data.slice(start, end);
|
||||
await characteristic.writeValue(chunk);
|
||||
if (i < totalChunks - 1 && CHUNK_DELAY_MS > 0) {
|
||||
await new Promise((r) => setTimeout(r, CHUNK_DELAY_MS));
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, message: "Zebra 프린터로 전송했습니다." };
|
||||
} catch (err: unknown) {
|
||||
const e = err as Error & { name?: string };
|
||||
if (e.name === "NotFoundError") {
|
||||
return { success: false, message: "Zebra 프린터(BLE)를 찾을 수 없습니다. 프린터 전원과 블루투스 설정을 확인하세요." };
|
||||
}
|
||||
if (e.name === "NotAllowedError") {
|
||||
return { success: false, message: "블루투스 연결이 거부되었습니다." };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: e.message || "Zebra BLE 출력 중 오류가 발생했습니다.",
|
||||
};
|
||||
} finally {
|
||||
if (server && device?.gatt?.connected) {
|
||||
try {
|
||||
device.gatt.disconnect();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
121
frontend/lib/zebraBrowserPrint.ts
Normal file
121
frontend/lib/zebraBrowserPrint.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Zebra Browser Print 연동
|
||||
* - 지브라 공식 "Zebra Browser Print" 앱(Windows/macOS/Android)과 웹 페이지 통신
|
||||
* - 앱 설치 시 네트워크·Bluetooth 프린터 발견 후 ZPL 전송 가능 (Chrome 권장)
|
||||
* - Android: Browser Print APK 설치 시 Chrome에서 목록에 안 나오는 문제 우회 가능
|
||||
* 참고: https://developer.zebra.com/products/printers/browser-print
|
||||
*/
|
||||
|
||||
const BROWSER_PRINT_SCRIPT_URL =
|
||||
"https://cdn.jsdelivr.net/npm/zebra-browser-print-min@3.0.216/BrowserPrint-3.0.216.min.js";
|
||||
|
||||
/** ZebraPrintResult와 동일한 형태 (zebraBluetooth와 공유) */
|
||||
export interface ZebraPrintResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
BrowserPrint?: {
|
||||
getDefaultDevice: (
|
||||
type: string,
|
||||
onSuccess: (device: BrowserPrintDevice) => void,
|
||||
onError: (err: string) => void
|
||||
) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface BrowserPrintDevice {
|
||||
send: (
|
||||
data: string,
|
||||
onSuccess: () => void,
|
||||
onError: (err: string) => void
|
||||
) => void;
|
||||
}
|
||||
|
||||
let scriptLoadPromise: Promise<boolean> | null = null;
|
||||
|
||||
/** Browser Print 스크립트를 한 번만 동적 로드 */
|
||||
function loadBrowserPrintScript(): Promise<boolean> {
|
||||
if (typeof window === "undefined") return Promise.resolve(false);
|
||||
if (window.BrowserPrint) return Promise.resolve(true);
|
||||
if (scriptLoadPromise) return scriptLoadPromise;
|
||||
|
||||
scriptLoadPromise = new Promise((resolve) => {
|
||||
const existing = document.querySelector(
|
||||
`script[src="${BROWSER_PRINT_SCRIPT_URL}"]`
|
||||
);
|
||||
if (existing) {
|
||||
resolve(!!window.BrowserPrint);
|
||||
return;
|
||||
}
|
||||
const script = document.createElement("script");
|
||||
script.src = BROWSER_PRINT_SCRIPT_URL;
|
||||
script.async = true;
|
||||
script.onload = () => resolve(!!window.BrowserPrint);
|
||||
script.onerror = () => resolve(false);
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
return scriptLoadPromise;
|
||||
}
|
||||
|
||||
/** Browser Print 앱이 설치되어 있고 기본 프린터를 사용할 수 있는지 확인 */
|
||||
export function isBrowserPrintAvailable(): boolean {
|
||||
return typeof window !== "undefined" && !!window.BrowserPrint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zebra Browser Print 앱으로 ZPL 전송 (기본 프린터 사용)
|
||||
* - 앱 미설치 또는 기본 프린터 없으면 실패
|
||||
*/
|
||||
export function printZPLToBrowserPrint(zpl: string): Promise<ZebraPrintResult> {
|
||||
return loadBrowserPrintScript().then((loaded) => {
|
||||
if (!loaded || !window.BrowserPrint) {
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
"Zebra Browser Print 스크립트를 불러올 수 없습니다. CDN 연결을 확인하세요.",
|
||||
};
|
||||
}
|
||||
|
||||
return new Promise<ZebraPrintResult>((resolve) => {
|
||||
window.BrowserPrint!.getDefaultDevice(
|
||||
"printer",
|
||||
(device) => {
|
||||
if (!device) {
|
||||
resolve({
|
||||
success: false,
|
||||
message:
|
||||
"기본 Zebra 프린터가 설정되지 않았습니다. Browser Print 앱에서 프린터를 검색해 기본으로 지정해 주세요.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
device.send(
|
||||
zpl,
|
||||
() => resolve({ success: true, message: "Zebra Browser Print로 전송했습니다." }),
|
||||
(err) =>
|
||||
resolve({
|
||||
success: false,
|
||||
message: err || "Browser Print 전송 중 오류가 발생했습니다.",
|
||||
})
|
||||
);
|
||||
},
|
||||
(err) =>
|
||||
resolve({
|
||||
success: false,
|
||||
message:
|
||||
err ||
|
||||
"Zebra Browser Print 앱이 설치되어 있지 않거나 연결할 수 없습니다. Android에서는 'Zebra Browser Print' 앱을 설치한 뒤 Chrome에서 이 페이지를 허용해 주세요.",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Browser Print 앱 설치/다운로드 안내 문구 */
|
||||
export function getBrowserPrintHelpMessage(): string {
|
||||
return "Android에서 Bluetooth 목록에 프린터가 안 나오면, Zebra 공식 'Zebra Browser Print' 앱을 설치한 뒤 앱에서 프린터를 검색·기본 설정하고, 이 사이트를 허용하면 'Zebra 프린터로 출력'으로 인쇄할 수 있습니다.";
|
||||
}
|
||||
67
frontend/lib/zplGenerator.ts
Normal file
67
frontend/lib/zplGenerator.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* ZPL(Zebra Programming Language) 생성
|
||||
* ZD421 등 Zebra 프린터용 라벨 데이터 생성 (200 DPI = 8 dots/mm 기준)
|
||||
*/
|
||||
|
||||
import { BarcodeLabelLayout } from "@/types/barcode";
|
||||
|
||||
const MM_TO_PX = 4;
|
||||
const DOTS_PER_MM = 8; // 200 DPI
|
||||
|
||||
function pxToDots(px: number): number {
|
||||
const mm = px / MM_TO_PX;
|
||||
return Math.round(mm * DOTS_PER_MM);
|
||||
}
|
||||
|
||||
export function generateZPL(layout: BarcodeLabelLayout): string {
|
||||
const { width_mm, height_mm, components } = layout;
|
||||
const widthDots = Math.round(width_mm * DOTS_PER_MM);
|
||||
const heightDots = Math.round(height_mm * DOTS_PER_MM);
|
||||
|
||||
const lines: string[] = [
|
||||
"^XA",
|
||||
"^PW" + widthDots,
|
||||
"^LL" + heightDots,
|
||||
"^LH0,0",
|
||||
];
|
||||
|
||||
const sorted = [...components].sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0));
|
||||
|
||||
for (const c of sorted) {
|
||||
const x = pxToDots(c.x);
|
||||
const y = pxToDots(c.y);
|
||||
const w = pxToDots(c.width);
|
||||
const h = pxToDots(c.height);
|
||||
|
||||
if (c.type === "text") {
|
||||
const fontH = Math.max(10, Math.min(120, (c.fontSize || 10) * 4)); // 대략적 변환
|
||||
const fontW = Math.round(fontH * 0.6);
|
||||
lines.push(`^FO${x},${y}`);
|
||||
lines.push(`^A0N,${fontH},${fontW}`);
|
||||
lines.push(`^FD${escapeZPL(c.content || "")}^FS`);
|
||||
} else if (c.type === "barcode") {
|
||||
if (c.barcodeType === "QR") {
|
||||
const size = Math.min(w, h);
|
||||
const qrSize = Math.max(1, Math.min(10, Math.round(size / 20)));
|
||||
lines.push(`^FO${x},${y}`);
|
||||
lines.push(`^BQN,2,${qrSize}`);
|
||||
lines.push(`^FDQA,${escapeZPL(c.barcodeValue || "")}^FS`);
|
||||
} else {
|
||||
// CODE128: ^BC, CODE39: ^B3
|
||||
const mod = c.barcodeType === "CODE39" ? "^B3N" : "^BCN";
|
||||
const showText = c.showBarcodeText !== false ? "Y" : "N";
|
||||
lines.push(`^FO${x},${y}`);
|
||||
lines.push(`${mod},${Math.max(20, h - 10)},${showText},N,N`);
|
||||
lines.push(`^FD${escapeZPL(c.barcodeValue || "")}^FS`);
|
||||
}
|
||||
}
|
||||
// 이미지/선/사각형은 ZPL에서 비트맵 또는 ^GB 등으로 확장 가능 (생략)
|
||||
}
|
||||
|
||||
lines.push("^XZ");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function escapeZPL(s: string): string {
|
||||
return s.replace(/\^/g, "^^").replace(/~/g, "~~");
|
||||
}
|
||||
Reference in New Issue
Block a user