바코드 기능 커밋밋

This commit is contained in:
2026-03-04 20:51:00 +09:00
parent 7ad17065f0
commit b9c0a0f243
23 changed files with 3100 additions and 0 deletions

View 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;
},
};

View 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
}
}
}
}

View 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 프린터로 출력'으로 인쇄할 수 있습니다.";
}

View 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, "~~");
}