diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 25df9f8d..0bf35e75 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -90,6 +90,7 @@ import externalCallConfigRoutes from "./routes/externalCallConfigRoutes"; import dataflowExecutionRoutes from "./routes/dataflowExecutionRoutes"; import dashboardRoutes from "./routes/dashboardRoutes"; import reportRoutes from "./routes/reportRoutes"; +import barcodeLabelRoutes from "./routes/barcodeLabelRoutes"; import openApiProxyRoutes from "./routes/openApiProxyRoutes"; // 날씨/환율 API import deliveryRoutes from "./routes/deliveryRoutes"; // 배송/화물 관리 import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관리 @@ -278,6 +279,7 @@ app.use("/api/external-call-configs", externalCallConfigRoutes); app.use("/api/dataflow", dataflowExecutionRoutes); app.use("/api/dashboards", dashboardRoutes); app.use("/api/admin/reports", reportRoutes); +app.use("/api/admin/barcode-labels", barcodeLabelRoutes); app.use("/api/open-api", openApiProxyRoutes); // 날씨/환율 외부 API app.use("/api/delivery", deliveryRoutes); // 배송/화물 관리 app.use("/api/risk-alerts", riskAlertRoutes); // 리스크/알림 관리 diff --git a/backend-node/src/controllers/barcodeLabelController.ts b/backend-node/src/controllers/barcodeLabelController.ts new file mode 100644 index 00000000..3159c4e0 --- /dev/null +++ b/backend-node/src/controllers/barcodeLabelController.ts @@ -0,0 +1,218 @@ +/** + * 바코드 라벨 관리 컨트롤러 + * ZD421 등 바코드 프린터용 라벨 CRUD 및 레이아웃/템플릿 + */ + +import { Request, Response, NextFunction } from "express"; +import barcodeLabelService from "../services/barcodeLabelService"; + +function getUserId(req: Request): string { + return (req as any).user?.userId || "SYSTEM"; +} + +export class BarcodeLabelController { + async getLabels(req: Request, res: Response, next: NextFunction) { + try { + const page = Math.max(1, parseInt((req.query.page as string) || "1", 10)); + const limit = Math.min(100, Math.max(1, parseInt((req.query.limit as string) || "20", 10))); + const searchText = (req.query.searchText as string) || ""; + const useYn = (req.query.useYn as string) || "Y"; + const sortBy = (req.query.sortBy as string) || "created_at"; + const sortOrder = (req.query.sortOrder as "ASC" | "DESC") || "DESC"; + + const data = await barcodeLabelService.getLabels({ + page, + limit, + searchText, + useYn, + sortBy, + sortOrder, + }); + + return res.json({ success: true, data }); + } catch (error) { + return next(error); + } + } + + async getLabelById(req: Request, res: Response, next: NextFunction) { + try { + const { labelId } = req.params; + const label = await barcodeLabelService.getLabelById(labelId); + if (!label) { + return res.status(404).json({ + success: false, + message: "바코드 라벨을 찾을 수 없습니다.", + }); + } + return res.json({ success: true, data: label }); + } catch (error) { + return next(error); + } + } + + async getLayout(req: Request, res: Response, next: NextFunction) { + try { + const { labelId } = req.params; + const layout = await barcodeLabelService.getLayout(labelId); + if (!layout) { + return res.status(404).json({ + success: false, + message: "레이아웃을 찾을 수 없습니다.", + }); + } + return res.json({ success: true, data: layout }); + } catch (error) { + return next(error); + } + } + + async createLabel(req: Request, res: Response, next: NextFunction) { + try { + const body = req.body as { + labelNameKor?: string; + labelNameEng?: string; + description?: string; + templateId?: string; + }; + if (!body?.labelNameKor?.trim()) { + return res.status(400).json({ + success: false, + message: "라벨명(한글)은 필수입니다.", + }); + } + const labelId = await barcodeLabelService.createLabel( + { + labelNameKor: body.labelNameKor.trim(), + labelNameEng: body.labelNameEng?.trim(), + description: body.description?.trim(), + templateId: body.templateId?.trim(), + }, + getUserId(req) + ); + return res.status(201).json({ + success: true, + data: { labelId }, + message: "바코드 라벨이 생성되었습니다.", + }); + } catch (error) { + return next(error); + } + } + + async updateLabel(req: Request, res: Response, next: NextFunction) { + try { + const { labelId } = req.params; + const body = req.body as { + labelNameKor?: string; + labelNameEng?: string; + description?: string; + useYn?: string; + }; + const success = await barcodeLabelService.updateLabel( + labelId, + { + labelNameKor: body.labelNameKor?.trim(), + labelNameEng: body.labelNameEng?.trim(), + description: body.description !== undefined ? body.description : undefined, + useYn: body.useYn, + }, + getUserId(req) + ); + if (!success) { + return res.status(404).json({ + success: false, + message: "바코드 라벨을 찾을 수 없습니다.", + }); + } + return res.json({ success: true, message: "수정되었습니다." }); + } catch (error) { + return next(error); + } + } + + async saveLayout(req: Request, res: Response, next: NextFunction) { + try { + const { labelId } = req.params; + const layout = req.body as { width_mm: number; height_mm: number; components: any[] }; + if (!layout || typeof layout.width_mm !== "number" || typeof layout.height_mm !== "number" || !Array.isArray(layout.components)) { + return res.status(400).json({ + success: false, + message: "width_mm, height_mm, components 배열이 필요합니다.", + }); + } + await barcodeLabelService.saveLayout( + labelId, + { width_mm: layout.width_mm, height_mm: layout.height_mm, components: layout.components }, + getUserId(req) + ); + return res.json({ success: true, message: "레이아웃이 저장되었습니다." }); + } catch (error) { + return next(error); + } + } + + async deleteLabel(req: Request, res: Response, next: NextFunction) { + try { + const { labelId } = req.params; + const success = await barcodeLabelService.deleteLabel(labelId); + if (!success) { + return res.status(404).json({ + success: false, + message: "바코드 라벨을 찾을 수 없습니다.", + }); + } + return res.json({ success: true, message: "삭제되었습니다." }); + } catch (error) { + return next(error); + } + } + + async copyLabel(req: Request, res: Response, next: NextFunction) { + try { + const { labelId } = req.params; + const newId = await barcodeLabelService.copyLabel(labelId, getUserId(req)); + if (!newId) { + return res.status(404).json({ + success: false, + message: "바코드 라벨을 찾을 수 없습니다.", + }); + } + return res.json({ + success: true, + data: { labelId: newId }, + message: "복사되었습니다.", + }); + } catch (error) { + return next(error); + } + } + + async getTemplates(req: Request, res: Response, next: NextFunction) { + try { + const templates = await barcodeLabelService.getTemplates(); + return res.json({ success: true, data: templates }); + } catch (error) { + return next(error); + } + } + + async getTemplateById(req: Request, res: Response, next: NextFunction) { + try { + const { templateId } = req.params; + const template = await barcodeLabelService.getTemplateById(templateId); + if (!template) { + return res.status(404).json({ + success: false, + message: "템플릿을 찾을 수 없습니다.", + }); + } + const layout = JSON.parse(template.layout_json); + return res.json({ success: true, data: { ...template, layout } }); + } catch (error) { + return next(error); + } + } +} + +export default new BarcodeLabelController(); diff --git a/backend-node/src/routes/barcodeLabelRoutes.ts b/backend-node/src/routes/barcodeLabelRoutes.ts new file mode 100644 index 00000000..ba580e7e --- /dev/null +++ b/backend-node/src/routes/barcodeLabelRoutes.ts @@ -0,0 +1,41 @@ +import { Router } from "express"; +import barcodeLabelController from "../controllers/barcodeLabelController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +router.use(authenticateToken); + +router.get("/", (req, res, next) => + barcodeLabelController.getLabels(req, res, next) +); +router.get("/templates", (req, res, next) => + barcodeLabelController.getTemplates(req, res, next) +); +router.get("/templates/:templateId", (req, res, next) => + barcodeLabelController.getTemplateById(req, res, next) +); +router.post("/", (req, res, next) => + barcodeLabelController.createLabel(req, res, next) +); + +router.get("/:labelId", (req, res, next) => + barcodeLabelController.getLabelById(req, res, next) +); +router.get("/:labelId/layout", (req, res, next) => + barcodeLabelController.getLayout(req, res, next) +); +router.put("/:labelId", (req, res, next) => + barcodeLabelController.updateLabel(req, res, next) +); +router.put("/:labelId/layout", (req, res, next) => + barcodeLabelController.saveLayout(req, res, next) +); +router.delete("/:labelId", (req, res, next) => + barcodeLabelController.deleteLabel(req, res, next) +); +router.post("/:labelId/copy", (req, res, next) => + barcodeLabelController.copyLabel(req, res, next) +); + +export default router; diff --git a/backend-node/src/services/barcodeLabelService.ts b/backend-node/src/services/barcodeLabelService.ts new file mode 100644 index 00000000..c5399cf9 --- /dev/null +++ b/backend-node/src/services/barcodeLabelService.ts @@ -0,0 +1,247 @@ +/** + * 바코드 라벨 관리 서비스 + * ZD421 등 라벨 디자인 CRUD 및 기본 템플릿 제공 + */ + +import { v4 as uuidv4 } from "uuid"; +import { query, queryOne, transaction } from "../database/db"; +import { BarcodeLabelLayout } from "../types/barcode"; + +export interface BarcodeLabelMaster { + label_id: string; + label_name_kor: string; + label_name_eng: string | null; + description: string | null; + width_mm: number; + height_mm: number; + layout_json: string | null; + use_yn: string; + created_at: string; + created_by: string | null; + updated_at: string | null; + updated_by: string | null; +} + +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 interface GetBarcodeLabelsParams { + page?: number; + limit?: number; + searchText?: string; + useYn?: string; + sortBy?: string; + sortOrder?: "ASC" | "DESC"; +} + +export interface GetBarcodeLabelsResult { + items: BarcodeLabelMaster[]; + total: number; + page: number; + limit: number; +} + +export class BarcodeLabelService { + async getLabels(params: GetBarcodeLabelsParams): Promise { + const { + page = 1, + limit = 20, + searchText = "", + useYn = "Y", + sortBy = "created_at", + sortOrder = "DESC", + } = params; + + const offset = (page - 1) * limit; + const conditions: string[] = []; + const values: any[] = []; + let idx = 1; + + if (useYn) { + conditions.push(`use_yn = $${idx++}`); + values.push(useYn); + } + if (searchText) { + conditions.push(`(label_name_kor LIKE $${idx} OR label_name_eng LIKE $${idx})`); + values.push(`%${searchText}%`); + idx++; + } + const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""; + + const countSql = `SELECT COUNT(*) as total FROM barcode_labels ${where}`; + const countRow = await queryOne<{ total: string }>(countSql, values); + const total = parseInt(countRow?.total || "0", 10); + + const listSql = ` + SELECT label_id, label_name_kor, label_name_eng, description, width_mm, height_mm, + layout_json, use_yn, created_at, created_by, updated_at, updated_by + FROM barcode_labels ${where} + ORDER BY ${sortBy} ${sortOrder} + LIMIT $${idx++} OFFSET $${idx} + `; + const items = await query(listSql, [...values, limit, offset]); + + return { items, total, page, limit }; + } + + async getLabelById(labelId: string): Promise { + const sql = ` + SELECT label_id, label_name_kor, label_name_eng, description, width_mm, height_mm, + layout_json, use_yn, created_at, created_by, updated_at, updated_by + FROM barcode_labels WHERE label_id = $1 + `; + return queryOne(sql, [labelId]); + } + + async getLayout(labelId: string): Promise { + const row = await this.getLabelById(labelId); + if (!row?.layout_json) return null; + try { + return JSON.parse(row.layout_json) as BarcodeLabelLayout; + } catch { + return null; + } + } + + async createLabel( + data: { labelNameKor: string; labelNameEng?: string; description?: string; templateId?: string }, + userId: string + ): Promise { + const labelId = `LBL_${uuidv4().replace(/-/g, "").substring(0, 20)}`; + let widthMm = 50; + let heightMm = 30; + let layoutJson: string | null = null; + + if (data.templateId) { + const t = await this.getTemplateById(data.templateId); + if (t) { + widthMm = t.width_mm; + heightMm = t.height_mm; + layoutJson = t.layout_json; + } + } + if (!layoutJson) { + const defaultLayout: BarcodeLabelLayout = { + width_mm: widthMm, + height_mm: heightMm, + components: [], + }; + layoutJson = JSON.stringify(defaultLayout); + } + + await query( + `INSERT INTO barcode_labels (label_id, label_name_kor, label_name_eng, description, width_mm, height_mm, layout_json, use_yn, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'Y', $8)`, + [ + labelId, + data.labelNameKor, + data.labelNameEng || null, + data.description || null, + widthMm, + heightMm, + layoutJson, + userId, + ] + ); + return labelId; + } + + async updateLabel( + labelId: string, + data: { labelNameKor?: string; labelNameEng?: string; description?: string; useYn?: string }, + userId: string + ): Promise { + const setClauses: string[] = []; + const values: any[] = []; + let idx = 1; + if (data.labelNameKor !== undefined) { + setClauses.push(`label_name_kor = $${idx++}`); + values.push(data.labelNameKor); + } + if (data.labelNameEng !== undefined) { + setClauses.push(`label_name_eng = $${idx++}`); + values.push(data.labelNameEng); + } + if (data.description !== undefined) { + setClauses.push(`description = $${idx++}`); + values.push(data.description); + } + if (data.useYn !== undefined) { + setClauses.push(`use_yn = $${idx++}`); + values.push(data.useYn); + } + if (setClauses.length === 0) return false; + setClauses.push(`updated_at = CURRENT_TIMESTAMP`); + setClauses.push(`updated_by = $${idx++}`); + values.push(userId); + values.push(labelId); + + const updated = await query<{ label_id: string }>( + `UPDATE barcode_labels SET ${setClauses.join(", ")} WHERE label_id = $${idx} RETURNING label_id`, + values + ); + return updated.length > 0; + } + + async saveLayout(labelId: string, layout: BarcodeLabelLayout, userId: string): Promise { + const layoutJson = JSON.stringify(layout); + await query( + `UPDATE barcode_labels SET width_mm = $1, height_mm = $2, layout_json = $3, updated_at = CURRENT_TIMESTAMP, updated_by = $4 WHERE label_id = $5`, + [layout.width_mm, layout.height_mm, layoutJson, userId, labelId] + ); + return true; + } + + async deleteLabel(labelId: string): Promise { + const deleted = await query<{ label_id: string }>( + `DELETE FROM barcode_labels WHERE label_id = $1 RETURNING label_id`, + [labelId] + ); + return deleted.length > 0; + } + + async copyLabel(labelId: string, userId: string): Promise { + const row = await this.getLabelById(labelId); + if (!row) return null; + const newId = `LBL_${uuidv4().replace(/-/g, "").substring(0, 20)}`; + await query( + `INSERT INTO barcode_labels (label_id, label_name_kor, label_name_eng, description, width_mm, height_mm, layout_json, use_yn, created_by) + VALUES ($1, $2 || ' (복사)', $3, $4, $5, $6, $7, 'Y', $8)`, + [ + newId, + row.label_name_kor, + row.label_name_eng, + row.description, + row.width_mm, + row.height_mm, + row.layout_json, + userId, + ] + ); + return newId; + } + + async getTemplates(): Promise { + const sql = ` + SELECT template_id, template_name_kor, template_name_eng, width_mm, height_mm, layout_json, sort_order + FROM barcode_label_templates ORDER BY sort_order, template_id + `; + const rows = await query(sql); + return rows || []; + } + + async getTemplateById(templateId: string): Promise { + const sql = `SELECT template_id, template_name_kor, template_name_eng, width_mm, height_mm, layout_json, sort_order + FROM barcode_label_templates WHERE template_id = $1`; + return queryOne(sql, [templateId]); + } +} + +export default new BarcodeLabelService(); diff --git a/backend-node/src/types/barcode.ts b/backend-node/src/types/barcode.ts new file mode 100644 index 00000000..19f3b31d --- /dev/null +++ b/backend-node/src/types/barcode.ts @@ -0,0 +1,61 @@ +/** + * 바코드 라벨 백엔드 타입 + */ + +export interface BarcodeLabelComponent { + id: string; + type: "text" | "barcode" | "image" | "line" | "rectangle"; + x: number; + y: number; + width: number; + height: number; + zIndex: number; + // text + content?: string; + fontSize?: number; + fontColor?: string; + fontWeight?: string; + // barcode + barcodeType?: string; + barcodeValue?: string; + showBarcodeText?: boolean; + // image + imageUrl?: string; + objectFit?: string; + // line/rectangle + lineColor?: string; + lineWidth?: number; + backgroundColor?: string; +} + +export interface BarcodeLabelLayout { + width_mm: number; + height_mm: number; + components: BarcodeLabelComponent[]; +} + +export interface BarcodeLabelRow { + label_id: string; + label_name_kor: string; + label_name_eng: string | null; + description: string | null; + width_mm: number; + height_mm: number; + layout_json: string | null; + use_yn: string; + created_at: string; + created_by: string | null; + updated_at: string | null; + updated_by: string | null; +} + +export interface BarcodeLabelTemplateRow { + template_id: string; + template_name_kor: string; + template_name_eng: string | null; + width_mm: number; + height_mm: number; + layout_json: string; + sort_order: number; + created_at: string; +} diff --git a/frontend/app/(main)/admin/screenMng/barcodeList/designer/[labelId]/page.tsx b/frontend/app/(main)/admin/screenMng/barcodeList/designer/[labelId]/page.tsx new file mode 100644 index 00000000..8d55ca5c --- /dev/null +++ b/frontend/app/(main)/admin/screenMng/barcodeList/designer/[labelId]/page.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { DndProvider } from "react-dnd"; +import { HTML5Backend } from "react-dnd-html5-backend"; +import { BarcodeDesignerToolbar } from "@/components/barcode/designer/BarcodeDesignerToolbar"; +import { BarcodeDesignerLeftPanel } from "@/components/barcode/designer/BarcodeDesignerLeftPanel"; +import { BarcodeDesignerCanvas } from "@/components/barcode/designer/BarcodeDesignerCanvas"; +import { BarcodeDesignerRightPanel } from "@/components/barcode/designer/BarcodeDesignerRightPanel"; +import { BarcodeDesignerProvider } from "@/contexts/BarcodeDesignerContext"; + +export default function BarcodeLabelDesignerPage() { + const params = useParams(); + const labelId = (params.labelId as string) || "new"; + + return ( + + +
+ +
+ + + +
+
+
+
+ ); +} diff --git a/frontend/app/(main)/admin/screenMng/barcodeList/page.tsx b/frontend/app/(main)/admin/screenMng/barcodeList/page.tsx new file mode 100644 index 00000000..914e111a --- /dev/null +++ b/frontend/app/(main)/admin/screenMng/barcodeList/page.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { BarcodeListTable } from "@/components/barcode/BarcodeListTable"; +import { Plus, Search, RotateCcw } from "lucide-react"; +import { useBarcodeList } from "@/hooks/useBarcodeList"; + +export default function BarcodeLabelManagementPage() { + const router = useRouter(); + const [searchText, setSearchText] = useState(""); + + const { labels, total, page, limit, isLoading, refetch, setPage, handleSearch } = useBarcodeList(); + + const handleSearchClick = () => { + handleSearch(searchText); + }; + + const handleReset = () => { + setSearchText(""); + handleSearch(""); + }; + + const handleCreateNew = () => { + router.push("/admin/screenMng/barcodeList/designer/new"); + }; + + return ( +
+
+
+
+

바코드 라벨 관리

+

ZD421 등 바코드 프린터용 라벨을 작성하고 출력합니다

+
+ +
+ + + + + + 검색 + + + +
+ setSearchText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleSearchClick(); + } + }} + className="flex-1" + /> + + +
+
+
+ + + + + + 바코드 라벨 목록 + (총 {total}건) + + + + + + + +
+
+ ); +} diff --git a/frontend/components/barcode/BarcodeListTable.tsx b/frontend/components/barcode/BarcodeListTable.tsx new file mode 100644 index 00000000..9cb2bca5 --- /dev/null +++ b/frontend/components/barcode/BarcodeListTable.tsx @@ -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(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 ( +
+ +
+ ); + } + + if (labels.length === 0) { + return ( +
+

등록된 바코드 라벨이 없습니다.

+
+ ); + } + + return ( + <> +
+ + + + No + 라벨명 + 템플릿 유형 + 작성자 + 수정일 + 액션 + + + + {labels.map((label, index) => { + const rowNumber = (page - 1) * limit + index + 1; + return ( + handleEdit(label.label_id)} + className="cursor-pointer hover:bg-muted/50" + > + {rowNumber} + +
+
{label.label_name_kor}
+ {label.label_name_eng && ( +
{label.label_name_eng}
+ )} +
+
+ + {label.width_mm != null && label.height_mm != null + ? `${label.width_mm}×${label.height_mm}mm` + : label.template_type || "-"} + + {label.created_by || "-"} + {formatDate(label.updated_at || label.created_at)} + +
e.stopPropagation()}> + + +
+
+
+ ); + })} +
+
+
+ + {totalPages > 1 && ( +
+ + + {page} / {totalPages} + + +
+ )} + + !open && setDeleteTarget(null)}> + + + 바코드 라벨 삭제 + + 이 바코드 라벨을 삭제하시겠습니까? +
+ 삭제된 라벨은 복구할 수 없습니다. +
+
+ + 취소 + + {isDeleting ? ( + <> + + 삭제 중... + + ) : ( + "삭제" + )} + + +
+
+ + ); +} diff --git a/frontend/components/barcode/designer/BarcodeComponentPalette.tsx b/frontend/components/barcode/designer/BarcodeComponentPalette.tsx new file mode 100644 index 00000000..e28ff12d --- /dev/null +++ b/frontend/components/barcode/designer/BarcodeComponentPalette.tsx @@ -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: "barcode", label: "바코드", icon: }, + { type: "image", label: "이미지", icon: }, + { type: "line", label: "선", icon: }, + { type: "rectangle", label: "사각형", icon: }, +]; + +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 ( +
+ {icon} + {label} +
+ ); +} + +export function BarcodeComponentPalette() { + return ( + + + 요소 추가 + + + {ITEMS.map((item) => ( + + ))} + + + ); +} diff --git a/frontend/components/barcode/designer/BarcodeDesignerCanvas.tsx b/frontend/components/barcode/designer/BarcodeDesignerCanvas.tsx new file mode 100644 index 00000000..15136668 --- /dev/null +++ b/frontend/components/barcode/designer/BarcodeDesignerCanvas.tsx @@ -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(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 ( +
+
{ + (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) => ( + + ))} +
+
+ ); +} diff --git a/frontend/components/barcode/designer/BarcodeDesignerLeftPanel.tsx b/frontend/components/barcode/designer/BarcodeDesignerLeftPanel.tsx new file mode 100644 index 00000000..9062041e --- /dev/null +++ b/frontend/components/barcode/designer/BarcodeDesignerLeftPanel.tsx @@ -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 ( +
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/frontend/components/barcode/designer/BarcodeDesignerRightPanel.tsx b/frontend/components/barcode/designer/BarcodeDesignerRightPanel.tsx new file mode 100644 index 00000000..aaa92d2c --- /dev/null +++ b/frontend/components/barcode/designer/BarcodeDesignerRightPanel.tsx @@ -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 ( +
+

요소를 선택하면 속성을 편집할 수 있습니다.

+
+ +
+ setWidthMm(Number(e.target.value) || 50)} + /> + × + setHeightMm(Number(e.target.value) || 30)} + /> +
+
+
+ ); + } + + const update = (updates: Partial) => + updateComponent(selected.id, updates); + + return ( +
+
+ 속성 + +
+
+
+
+ + update({ x: Number(e.target.value) || 0 })} + /> +
+
+ + update({ y: Number(e.target.value) || 0 })} + /> +
+
+ + update({ width: Number(e.target.value) || 10 })} + /> +
+
+ + update({ height: Number(e.target.value) || 10 })} + /> +
+
+ + {selected.type === "text" && ( + <> +
+ + update({ content: e.target.value })} + placeholder="텍스트" + /> +
+
+ + update({ fontSize: Number(e.target.value) || 10 })} + /> +
+
+ + update({ fontColor: e.target.value })} + className="h-9 w-20 p-1" + /> +
+ + )} + + {selected.type === "barcode" && ( + <> +
+ + +
+
+ + update({ barcodeValue: e.target.value })} + placeholder="123456789" + /> +
+
+ update({ showBarcodeText: v })} + /> + +
+ + )} + + {selected.type === "line" && ( + <> +
+ + update({ lineWidth: Number(e.target.value) || 1 })} + /> +
+
+ + update({ lineColor: e.target.value })} + className="h-9 w-20 p-1" + /> +
+ + )} + + {selected.type === "rectangle" && ( + <> +
+ + update({ lineWidth: Number(e.target.value) || 0 })} + /> +
+
+ + update({ lineColor: e.target.value })} + className="h-9 w-20 p-1" + /> +
+
+ + update({ backgroundColor: e.target.value })} + className="h-9 w-20 p-1" + /> +
+ + )} + + {selected.type === "image" && ( +
+ + update({ imageUrl: e.target.value })} + placeholder="https://..." + /> +

또는 나중에 업로드 기능 연동

+
+ )} +
+
+ ); +} diff --git a/frontend/components/barcode/designer/BarcodeDesignerToolbar.tsx b/frontend/components/barcode/designer/BarcodeDesignerToolbar.tsx new file mode 100644 index 00000000..a7b0f598 --- /dev/null +++ b/frontend/components/barcode/designer/BarcodeDesignerToolbar.tsx @@ -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 ( + <> +
+
+ + + {labelId === "new" ? "새 라벨" : labelMaster?.label_name_kor || "바코드 라벨 디자이너"} + +
+
+ + + +
+
+ + ({ ...c, zIndex: i })), + }} + labelName={labelMaster?.label_name_kor || "라벨"} + /> + + + + + 새 라벨 저장 + +
+ + setNewLabelName(e.target.value)} + placeholder="예: 품목 바코드 라벨" + /> +
+ + + + +
+
+ + ); +} diff --git a/frontend/components/barcode/designer/BarcodeLabelCanvasComponent.tsx b/frontend/components/barcode/designer/BarcodeLabelCanvasComponent.tsx new file mode 100644 index 00000000..3552f5fe --- /dev/null +++ b/frontend/components/barcode/designer/BarcodeLabelCanvasComponent.tsx @@ -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(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 ( +
+ +
+ ); +} + +// QR 렌더 +function QRRender({ value, size }: { value: string; size: number }) { + const canvasRef = useRef(null); + useEffect(() => { + if (!canvasRef.current || !value.trim()) return; + QRCode.toCanvas(canvasRef.current, value.trim(), { + width: Math.max(40, size), + margin: 1, + }); + }, [value, size]); + + return ( +
+ +
+ ); +} + +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(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 ( +
+ {component.content || "텍스트"} +
+ ); + case "barcode": + if (isQR) { + return ( + + ); + } + return ( + + ); + case "image": + return component.imageUrl ? ( + + ) : ( +
+ 이미지 +
+ ); + case "line": + return ( +
+ ); + case "rectangle": + return ( +
+ ); + default: + return null; + } + }; + + return ( +
+ {content()} + {selected && component.type !== "line" && ( +
{ + e.stopPropagation(); + setIsResizing(true); + setResizeStart({ + x: e.clientX, + y: e.clientY, + w: component.width, + h: component.height, + }); + }} + /> + )} +
+ ); +} diff --git a/frontend/components/barcode/designer/BarcodePrintPreviewModal.tsx b/frontend/components/barcode/designer/BarcodePrintPreviewModal.tsx new file mode 100644 index 00000000..0db9b65e --- /dev/null +++ b/frontend/components/barcode/designer/BarcodePrintPreviewModal.tsx @@ -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 ( + + + + 인쇄 미리보기 + + +
+

+ {width_mm}×{height_mm}mm · {components.length}개 요소 +

+ + {/* 미리보기 캔버스 (축소) */} +
+
+
+ {components.map((c) => ( + + ))} +
+
+
+ + {!bleSupported && ( +
+ + + Web Bluetooth 미지원 브라우저입니다. Zebra Browser Print 앱을 설치하면 출력할 수 있습니다. + {unsupportedMsg && ` ${unsupportedMsg}`} + +
+ )} + +

+ {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' 앱을 설치한 뒤, 앱에서 프린터 검색·기본 설정 후 이 사이트를 허용하면 출력할 수 있습니다. + + )} +

+
+ + + + + +
+
+ ); +} diff --git a/frontend/components/barcode/designer/BarcodeTemplatePalette.tsx b/frontend/components/barcode/designer/BarcodeTemplatePalette.tsx new file mode 100644 index 00000000..4eb026ba --- /dev/null +++ b/frontend/components/barcode/designer/BarcodeTemplatePalette.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [category, setCategory] = useState("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 ( + + + 라벨 규격 + + + + + + ); + } + + return ( + + + 라벨 규격 + + +
+ + setSearchText(e.target.value)} + className="h-8 pl-8 text-xs" + /> +
+
+ + + +
+ +
+ {filtered.length === 0 ? ( +

검색 결과 없음

+ ) : ( + filtered.map((t) => ( + + )) + )} +
+
+
+
+ ); +} diff --git a/frontend/contexts/BarcodeDesignerContext.tsx b/frontend/contexts/BarcodeDesignerContext.tsx new file mode 100644 index 00000000..cd5b668a --- /dev/null +++ b/frontend/contexts/BarcodeDesignerContext.tsx @@ -0,0 +1,336 @@ +"use client"; + +import { + createContext, + useContext, + useState, + useCallback, + ReactNode, + useEffect, + useRef, +} from "react"; +import { + BarcodeLabelComponent, + BarcodeLabelLayout, + BarcodeLabelMaster, +} from "@/types/barcode"; +import { barcodeApi } from "@/lib/api/barcodeApi"; +import { useToast } from "@/hooks/use-toast"; +import { v4 as uuidv4 } from "uuid"; + +interface BarcodeDesignerContextType { + labelId: string; + labelMaster: BarcodeLabelMaster | null; + widthMm: number; + heightMm: number; + components: BarcodeLabelComponent[]; + selectedComponentId: string | null; + isLoading: boolean; + isSaving: boolean; + + setWidthMm: (v: number) => void; + setHeightMm: (v: number) => void; + addComponent: (component: BarcodeLabelComponent) => void; + updateComponent: (id: string, updates: Partial) => void; + removeComponent: (id: string) => void; + selectComponent: (id: string | null) => void; + reorderComponent: (id: string, direction: "up" | "down") => void; + + loadLabel: () => Promise; + loadLayout: () => Promise; + saveLayout: () => Promise; + applyTemplate: (templateId: string) => Promise; + + gridSize: number; + showGrid: boolean; + setShowGrid: (v: boolean) => void; + snapValueToGrid: (v: number) => number; +} + +const BarcodeDesignerContext = createContext(undefined); + +const MM_TO_PX = 4; +const DEFAULT_WIDTH_MM = 50; +const DEFAULT_HEIGHT_MM = 30; + +export function BarcodeDesignerProvider({ + labelId, + children, +}: { + labelId: string; + children: ReactNode; +}) { + const [labelMaster, setLabelMaster] = useState(null); + const [widthMm, setWidthMm] = useState(DEFAULT_WIDTH_MM); + const [heightMm, setHeightMm] = useState(DEFAULT_HEIGHT_MM); + const [components, setComponents] = useState([]); + const [selectedComponentId, setSelectedComponentId] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [showGrid, setShowGrid] = useState(true); + const [gridSize] = useState(2); // mm + const { toast } = useToast(); + const selectedComponentIdRef = useRef(null); + selectedComponentIdRef.current = selectedComponentId; + + const snapValueToGrid = useCallback( + (v: number) => Math.round(v / (gridSize * MM_TO_PX)) * (gridSize * MM_TO_PX), + [gridSize] + ); + + const loadLabel = useCallback(async () => { + if (labelId === "new") { + setLabelMaster(null); + setWidthMm(DEFAULT_WIDTH_MM); + setHeightMm(DEFAULT_HEIGHT_MM); + setComponents([]); + setIsLoading(false); + return; + } + try { + const res = await barcodeApi.getLabelById(labelId); + if (res.success && res.data) { + setLabelMaster(res.data); + if (res.data.width_mm != null) setWidthMm(res.data.width_mm); + if (res.data.height_mm != null) setHeightMm(res.data.height_mm); + } + } catch (e: any) { + toast({ + title: "오류", + description: e.message || "라벨 정보를 불러오지 못했습니다.", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }, [labelId, toast]); + + const loadLayout = useCallback(async () => { + if (labelId === "new") return; + try { + const res = await barcodeApi.getLayout(labelId); + if (res.success && res.data) { + setWidthMm(res.data.width_mm); + setHeightMm(res.data.height_mm); + setComponents(res.data.components || []); + } + } catch { + // 레이아웃 없으면 빈 상태 유지 + } + }, [labelId]); + + // labelId 변경 시에만 초기 로드 (loadLabel/loadLayout을 deps에 넣지 않아 무한 루프 방지) + useEffect(() => { + let cancelled = false; + setIsLoading(true); + + const run = async () => { + if (labelId === "new") { + setLabelMaster(null); + setWidthMm(DEFAULT_WIDTH_MM); + setHeightMm(DEFAULT_HEIGHT_MM); + setComponents([]); + if (!cancelled) setIsLoading(false); + return; + } + try { + const res = await barcodeApi.getLabelById(labelId); + if (cancelled) return; + if (res.success && res.data) { + setLabelMaster(res.data); + if (res.data.width_mm != null) setWidthMm(res.data.width_mm); + if (res.data.height_mm != null) setHeightMm(res.data.height_mm); + } + const layoutRes = await barcodeApi.getLayout(labelId); + if (cancelled) return; + if (layoutRes.success && layoutRes.data) { + setWidthMm(layoutRes.data.width_mm); + setHeightMm(layoutRes.data.height_mm); + setComponents(layoutRes.data.components || []); + } + } catch (e: any) { + if (!cancelled) { + toast({ + title: "오류", + description: e.message || "라벨을 불러오지 못했습니다.", + variant: "destructive", + }); + } + } finally { + if (!cancelled) setIsLoading(false); + } + }; + + run(); + return () => { + cancelled = true; + }; + }, [labelId]); // eslint-disable-line react-hooks/exhaustive-deps + + const addComponent = useCallback((component: BarcodeLabelComponent) => { + setComponents((prev) => [...prev, { ...component, id: component.id || `comp_${uuidv4()}` }]); + setSelectedComponentId(component.id || null); + }, []); + + const updateComponent = useCallback((id: string, updates: Partial) => { + setComponents((prev) => + prev.map((c) => (c.id === id ? { ...c, ...updates } : c)) + ); + }, []); + + const removeComponent = useCallback((id: string) => { + setComponents((prev) => prev.filter((c) => c.id !== id)); + setSelectedComponentId((sid) => (sid === id ? null : sid)); + }, []); + + // Delete / Backspace 키로 선택된 요소 삭제 (입력 필드에서는 무시) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key !== "Delete" && e.key !== "Backspace") return; + const target = e.target as HTMLElement; + if ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable + ) { + return; + } + const sid = selectedComponentIdRef.current; + if (sid) { + e.preventDefault(); + removeComponent(sid); + } + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [removeComponent]); + + const reorderComponent = useCallback((id: string, direction: "up" | "down") => { + setComponents((prev) => { + const idx = prev.findIndex((c) => c.id === id); + if (idx < 0) return prev; + const next = [...prev]; + const swap = direction === "up" ? idx - 1 : idx + 1; + if (swap < 0 || swap >= next.length) return prev; + [next[idx], next[swap]] = [next[swap], next[idx]]; + return next.map((c, i) => ({ ...c, zIndex: i })); + }); + }, []); + + const saveLayout = useCallback(async () => { + if (labelId === "new") { + toast({ + title: "저장 불가", + description: "먼저 라벨을 저장한 뒤 레이아웃을 저장할 수 있습니다.", + variant: "destructive", + }); + return; + } + setIsSaving(true); + try { + const layout: BarcodeLabelLayout = { + width_mm: widthMm, + height_mm: heightMm, + components: components.map((c, i) => ({ ...c, zIndex: i })), + }; + await barcodeApi.saveLayout(labelId, layout); + toast({ title: "저장됨", description: "레이아웃이 저장되었습니다." }); + } catch (e: any) { + toast({ + title: "저장 실패", + description: e.message || "레이아웃 저장에 실패했습니다.", + variant: "destructive", + }); + } finally { + setIsSaving(false); + } + }, [labelId, widthMm, heightMm, components, toast]); + + const applyTemplate = useCallback( + async (templateId: string) => { + try { + const res = await barcodeApi.getTemplateById(templateId); + const layout = res.success && res.data ? (res.data as { layout?: BarcodeLabelLayout }).layout : null; + if (layout && typeof layout.width_mm === "number" && typeof layout.height_mm === "number") { + setWidthMm(layout.width_mm); + setHeightMm(layout.height_mm); + setComponents( + (layout.components || []).map((c) => ({ + ...c, + id: c.id || `comp_${uuidv4()}`, + })) + ); + setSelectedComponentId(null); + const name = (res.data as { template_name_kor?: string }).template_name_kor || "템플릿"; + toast({ + title: "템플릿 적용", + description: `${name} 적용됨 (${layout.width_mm}×${layout.height_mm}mm)`, + }); + } else { + toast({ + title: "템플릿 적용 실패", + description: "레이아웃 데이터가 없습니다.", + variant: "destructive", + }); + } + } catch (e: any) { + toast({ + title: "템플릿 적용 실패", + description: e.message || "템플릿을 불러오지 못했습니다.", + variant: "destructive", + }); + } + }, + [toast] + ); + + const value: BarcodeDesignerContextType = { + labelId, + labelMaster, + widthMm, + heightMm, + components, + selectedComponentId, + isLoading, + isSaving, + setWidthMm, + setHeightMm, + addComponent, + updateComponent, + removeComponent, + selectComponent: setSelectedComponentId, + reorderComponent, + loadLabel, + loadLayout, + saveLayout, + applyTemplate, + gridSize, + showGrid, + setShowGrid, + snapValueToGrid, + }; + + return ( + + {children} + {isLoading && ( +
+
+
+ 라벨 불러오는 중... +
+
+ )} + + ); +} + +export function useBarcodeDesigner() { + const ctx = useContext(BarcodeDesignerContext); + if (ctx === undefined) { + throw new Error("useBarcodeDesigner must be used within BarcodeDesignerProvider"); + } + return ctx; +} + +export { MM_TO_PX }; diff --git a/frontend/hooks/useBarcodeList.ts b/frontend/hooks/useBarcodeList.ts new file mode 100644 index 00000000..e20fe811 --- /dev/null +++ b/frontend/hooks/useBarcodeList.ts @@ -0,0 +1,65 @@ +import { useState, useEffect } from "react"; +import { BarcodeLabelMaster, GetBarcodeLabelsParams } from "@/types/barcode"; +import { barcodeApi } from "@/lib/api/barcodeApi"; +import { useToast } from "@/hooks/use-toast"; + +export function useBarcodeList() { + const [labels, setLabels] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [limit] = useState(20); + const [isLoading, setIsLoading] = useState(false); + const [searchText, setSearchText] = useState(""); + const { toast } = useToast(); + + const fetchLabels = async () => { + setIsLoading(true); + try { + const params: GetBarcodeLabelsParams = { + page, + limit, + searchText, + useYn: "Y", + sortBy: "created_at", + sortOrder: "DESC", + }; + + const response = await barcodeApi.getLabels(params); + + if (response.success && response.data) { + setLabels(response.data.items); + setTotal(response.data.total); + } + } catch (error: any) { + console.error("바코드 라벨 목록 조회 에러:", error); + toast({ + title: "오류", + description: error.message || "바코드 라벨 목록을 불러오는데 실패했습니다.", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchLabels(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [page, searchText]); + + const handleSearch = (text: string) => { + setSearchText(text); + setPage(1); + }; + + return { + labels, + total, + page, + limit, + isLoading, + refetch: fetchLabels, + setPage, + handleSearch, + }; +} diff --git a/frontend/lib/api/barcodeApi.ts b/frontend/lib/api/barcodeApi.ts new file mode 100644 index 00000000..d056331e --- /dev/null +++ b/frontend/lib/api/barcodeApi.ts @@ -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; + }, +}; diff --git a/frontend/lib/zebraBluetooth.ts b/frontend/lib/zebraBluetooth.ts new file mode 100644 index 00000000..14dcd9ff --- /dev/null +++ b/frontend/lib/zebraBluetooth.ts @@ -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 { + 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 + } + } + } +} diff --git a/frontend/lib/zebraBrowserPrint.ts b/frontend/lib/zebraBrowserPrint.ts new file mode 100644 index 00000000..b1ca8ad8 --- /dev/null +++ b/frontend/lib/zebraBrowserPrint.ts @@ -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 | null = null; + +/** Browser Print 스크립트를 한 번만 동적 로드 */ +function loadBrowserPrintScript(): Promise { + 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 { + return loadBrowserPrintScript().then((loaded) => { + if (!loaded || !window.BrowserPrint) { + return { + success: false, + message: + "Zebra Browser Print 스크립트를 불러올 수 없습니다. CDN 연결을 확인하세요.", + }; + } + + return new Promise((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 프린터로 출력'으로 인쇄할 수 있습니다."; +} diff --git a/frontend/lib/zplGenerator.ts b/frontend/lib/zplGenerator.ts new file mode 100644 index 00000000..c0b4dd17 --- /dev/null +++ b/frontend/lib/zplGenerator.ts @@ -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, "~~"); +} diff --git a/frontend/types/barcode.ts b/frontend/types/barcode.ts new file mode 100644 index 00000000..4e6e5e41 --- /dev/null +++ b/frontend/types/barcode.ts @@ -0,0 +1,86 @@ +/** + * 바코드 라벨 관리 시스템 타입 정의 + * ZD421 등 바코드 프린터 연동용 라벨 템플릿/출력 관리 + */ + +// 캔버스 요소 (디자이너용) +export interface BarcodeLabelComponent { + id: string; + type: "text" | "barcode" | "image" | "line" | "rectangle"; + x: number; + y: number; + width: number; + height: number; + zIndex: number; + content?: string; + fontSize?: number; + fontColor?: string; + fontWeight?: string; + barcodeType?: string; + barcodeValue?: string; + showBarcodeText?: boolean; + imageUrl?: string; + objectFit?: string; + lineColor?: string; + lineWidth?: number; + backgroundColor?: string; +} + +export interface BarcodeLabelLayout { + width_mm: number; + height_mm: number; + components: BarcodeLabelComponent[]; +} + +// 바코드 라벨 마스터 (목록/카드용) +export interface BarcodeLabelMaster { + label_id: string; + label_name_kor: string; + label_name_eng: string | null; + description: string | null; + width_mm?: number; + height_mm?: number; + layout_json?: string | null; + template_type?: string; + use_yn: string; + created_at: string; + created_by: string | null; + updated_at: string | null; + updated_by: string | null; +} + +// 목록 조회 응답 +export interface GetBarcodeLabelsResponse { + items: BarcodeLabelMaster[]; + total: number; + page: number; + limit: number; +} + +// 목록 조회 파라미터 +export interface GetBarcodeLabelsParams { + page?: number; + limit?: number; + searchText?: string; + useYn?: string; + sortBy?: string; + sortOrder?: "ASC" | "DESC"; +} + +// 생성 요청 +export interface CreateBarcodeLabelRequest { + labelNameKor: string; + labelNameEng?: string; + description?: string; + templateType?: string; + templateId?: string; // 선택 시 해당 템플릿 레이아웃 적용 +} + +// 수정 요청 +export interface UpdateBarcodeLabelRequest { + labelNameKor?: string; + labelNameEng?: string; + description?: string; + templateType?: string; + useYn?: string; +}