Merge pull request '바코드 기능 커밋밋' (#397) from barcode into main

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/397
This commit is contained in:
kjs 2026-03-04 20:54:52 +09:00
commit 4da5d10f09
23 changed files with 3100 additions and 0 deletions

View File

@ -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); // 리스크/알림 관리

View File

@ -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();

View File

@ -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;

View File

@ -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<GetBarcodeLabelsResult> {
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<BarcodeLabelMaster>(listSql, [...values, limit, offset]);
return { items, total, page, limit };
}
async getLabelById(labelId: string): Promise<BarcodeLabelMaster | null> {
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<BarcodeLabelMaster>(sql, [labelId]);
}
async getLayout(labelId: string): Promise<BarcodeLabelLayout | null> {
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<string> {
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<boolean> {
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<boolean> {
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<boolean> {
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<string | null> {
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<BarcodeLabelTemplate[]> {
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<BarcodeLabelTemplate>(sql);
return rows || [];
}
async getTemplateById(templateId: string): Promise<BarcodeLabelTemplate | null> {
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<BarcodeLabelTemplate>(sql, [templateId]);
}
}
export default new BarcodeLabelService();

View File

@ -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;
}

View File

@ -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 (
<DndProvider backend={HTML5Backend}>
<BarcodeDesignerProvider labelId={labelId}>
<div className="flex h-screen flex-col overflow-hidden bg-gray-50">
<BarcodeDesignerToolbar />
<div className="flex flex-1 overflow-hidden">
<BarcodeDesignerLeftPanel />
<BarcodeDesignerCanvas />
<BarcodeDesignerRightPanel />
</div>
</div>
</BarcodeDesignerProvider>
</DndProvider>
);
}

View File

@ -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 (
<div className="min-h-screen bg-gray-50">
<div className="w-full max-w-none space-y-8 px-4 py-8">
<div className="flex items-center justify-between rounded-lg border bg-white p-6 shadow-sm">
<div>
<h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600">ZD421 </p>
</div>
<Button onClick={handleCreateNew} className="gap-2">
<Plus className="h-4 w-4" />
</Button>
</div>
<Card className="shadow-sm">
<CardHeader className="bg-gray-50/50">
<CardTitle className="flex items-center gap-2">
<Search className="h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent className="pt-6">
<div className="flex gap-2">
<Input
placeholder="라벨명으로 검색..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSearchClick();
}
}}
className="flex-1"
/>
<Button onClick={handleSearchClick} className="gap-2">
<Search className="h-4 w-4" />
</Button>
<Button onClick={handleReset} variant="outline" className="gap-2">
<RotateCcw className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
<Card className="shadow-sm">
<CardHeader className="bg-gray-50/50">
<CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
<span className="text-muted-foreground text-sm font-normal">( {total})</span>
</span>
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<BarcodeListTable
labels={labels}
total={total}
page={page}
limit={limit}
isLoading={isLoading}
onPageChange={setPage}
onRefresh={refetch}
/>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,244 @@
"use client";
import { useState } from "react";
import { BarcodeLabelMaster } from "@/types/barcode";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Copy, Trash2, Loader2 } from "lucide-react";
import { barcodeApi } from "@/lib/api/barcodeApi";
import { useToast } from "@/hooks/use-toast";
import { useRouter } from "next/navigation";
import { format } from "date-fns";
interface BarcodeListTableProps {
labels: BarcodeLabelMaster[];
total: number;
page: number;
limit: number;
isLoading: boolean;
onPageChange: (page: number) => void;
onRefresh: () => void;
}
export function BarcodeListTable({
labels,
total,
page,
limit,
isLoading,
onPageChange,
onRefresh,
}: BarcodeListTableProps) {
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [isCopying, setIsCopying] = useState(false);
const { toast } = useToast();
const router = useRouter();
const totalPages = Math.ceil(total / limit);
const handleEdit = (labelId: string) => {
router.push(`/admin/screenMng/barcodeList/designer/${labelId}`);
};
const handleCopy = async (labelId: string) => {
setIsCopying(true);
try {
const response = await barcodeApi.copyLabel(labelId);
if (response.success) {
toast({
title: "성공",
description: "바코드 라벨이 복사되었습니다.",
});
onRefresh();
}
} catch (error: any) {
toast({
title: "오류",
description: error.message || "바코드 라벨 복사에 실패했습니다.",
variant: "destructive",
});
} finally {
setIsCopying(false);
}
};
const handleDeleteClick = (labelId: string) => {
setDeleteTarget(labelId);
};
const handleDeleteConfirm = async () => {
if (!deleteTarget) return;
setIsDeleting(true);
try {
const response = await barcodeApi.deleteLabel(deleteTarget);
if (response.success) {
toast({
title: "성공",
description: "바코드 라벨이 삭제되었습니다.",
});
setDeleteTarget(null);
onRefresh();
}
} catch (error: any) {
toast({
title: "오류",
description: error.message || "바코드 라벨 삭제에 실패했습니다.",
variant: "destructive",
});
} finally {
setIsDeleting(false);
}
};
const formatDate = (dateString: string | null) => {
if (!dateString) return "-";
try {
return format(new Date(dateString), "yyyy-MM-dd");
} catch {
return dateString;
}
};
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
</div>
);
}
if (labels.length === 0) {
return (
<div className="text-muted-foreground flex h-64 flex-col items-center justify-center">
<p> .</p>
</div>
);
}
return (
<>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[80px]">No</TableHead>
<TableHead></TableHead>
<TableHead className="w-[120px]">릿 </TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[200px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{labels.map((label, index) => {
const rowNumber = (page - 1) * limit + index + 1;
return (
<TableRow
key={label.label_id}
onClick={() => handleEdit(label.label_id)}
className="cursor-pointer hover:bg-muted/50"
>
<TableCell className="font-medium">{rowNumber}</TableCell>
<TableCell>
<div>
<div className="font-medium">{label.label_name_kor}</div>
{label.label_name_eng && (
<div className="text-muted-foreground text-sm">{label.label_name_eng}</div>
)}
</div>
</TableCell>
<TableCell>
{label.width_mm != null && label.height_mm != null
? `${label.width_mm}×${label.height_mm}mm`
: label.template_type || "-"}
</TableCell>
<TableCell>{label.created_by || "-"}</TableCell>
<TableCell>{formatDate(label.updated_at || label.created_at)}</TableCell>
<TableCell>
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
<Button
size="icon"
variant="outline"
onClick={() => handleCopy(label.label_id)}
disabled={isCopying}
className="h-8 w-8"
title="복사"
>
<Copy className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="destructive"
onClick={() => handleDeleteClick(label.label_id)}
className="h-8 w-8"
title="삭제"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 p-4">
<Button variant="outline" size="sm" onClick={() => onPageChange(page - 1)} disabled={page === 1}>
</Button>
<span className="text-muted-foreground text-sm">
{page} / {totalPages}
</span>
<Button variant="outline" size="sm" onClick={() => onPageChange(page + 1)} disabled={page === totalPages}>
</Button>
</div>
)}
<AlertDialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}></AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteConfirm}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"삭제"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@ -0,0 +1,87 @@
"use client";
import { useDrag } from "react-dnd";
import { Type, Barcode, Image, Minus, Square } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { BarcodeLabelComponent } from "@/types/barcode";
import { v4 as uuidv4 } from "uuid";
const ITEMS: { type: BarcodeLabelComponent["type"]; label: string; icon: React.ReactNode }[] = [
{ type: "text", label: "텍스트", icon: <Type className="h-4 w-4" /> },
{ type: "barcode", label: "바코드", icon: <Barcode className="h-4 w-4" /> },
{ type: "image", label: "이미지", icon: <Image className="h-4 w-4" /> },
{ type: "line", label: "선", icon: <Minus className="h-4 w-4" /> },
{ type: "rectangle", label: "사각형", icon: <Square className="h-4 w-4" /> },
];
const MM_TO_PX = 4;
function defaultComponent(type: BarcodeLabelComponent["type"]): BarcodeLabelComponent {
const id = `comp_${uuidv4()}`;
const base = { id, type, x: 10 * MM_TO_PX, y: 10 * MM_TO_PX, width: 80, height: 24, zIndex: 0 };
switch (type) {
case "text":
return { ...base, content: "텍스트", fontSize: 10, fontColor: "#000000" };
case "barcode":
return {
...base,
width: 120,
height: 40,
barcodeType: "CODE128",
barcodeValue: "123456789",
showBarcodeText: true,
};
case "image":
return { ...base, width: 60, height: 60, imageUrl: "", objectFit: "contain" };
case "line":
return { ...base, width: 100, height: 2, lineColor: "#000", lineWidth: 1 };
case "rectangle":
return { ...base, width: 80, height: 40, backgroundColor: "transparent", lineColor: "#000", lineWidth: 1 };
default:
return base;
}
}
function DraggableItem({
type,
label,
icon,
}: {
type: BarcodeLabelComponent["type"];
label: string;
icon: React.ReactNode;
}) {
const [{ isDragging }, drag] = useDrag(() => ({
type: "barcode-component",
item: { component: defaultComponent(type) },
collect: (m) => ({ isDragging: m.isDragging() }),
}));
return (
<div
ref={drag}
className={`flex cursor-move items-center gap-2 rounded border p-2 text-sm hover:border-blue-500 hover:bg-blue-50 ${
isDragging ? "opacity-50" : ""
}`}
>
{icon}
<span>{label}</span>
</div>
);
}
export function BarcodeComponentPalette() {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm"> </CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{ITEMS.map((item) => (
<DraggableItem key={item.type} type={item.type} label={item.label} icon={item.icon} />
))}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,84 @@
"use client";
import { useRef } from "react";
import { useDrop } from "react-dnd";
import { useBarcodeDesigner, MM_TO_PX } from "@/contexts/BarcodeDesignerContext";
import { BarcodeLabelCanvasComponent } from "./BarcodeLabelCanvasComponent";
import { BarcodeLabelComponent } from "@/types/barcode";
import { v4 as uuidv4 } from "uuid";
export function BarcodeDesignerCanvas() {
const canvasRef = useRef<HTMLDivElement>(null);
const {
widthMm,
heightMm,
components,
addComponent,
selectComponent,
showGrid,
snapValueToGrid,
} = useBarcodeDesigner();
const widthPx = widthMm * MM_TO_PX;
const heightPx = heightMm * MM_TO_PX;
const [{ isOver }, drop] = useDrop(() => ({
accept: "barcode-component",
drop: (item: { component: BarcodeLabelComponent }, monitor) => {
if (!canvasRef.current) return;
const offset = monitor.getClientOffset();
const rect = canvasRef.current.getBoundingClientRect();
if (!offset) return;
let x = offset.x - rect.left;
let y = offset.y - rect.top;
// 드롭 시 요소 중앙이 커서에 오도록 보정
x -= item.component.width / 2;
y -= item.component.height / 2;
x = Math.max(0, Math.min(x, widthPx - item.component.width));
y = Math.max(0, Math.min(y, heightPx - item.component.height));
const newComp: BarcodeLabelComponent = {
...item.component,
id: `comp_${uuidv4()}`,
x: snapValueToGrid(x),
y: snapValueToGrid(y),
zIndex: components.length,
};
addComponent(newComp);
},
collect: (m) => ({ isOver: m.isOver() }),
}), [widthPx, heightPx, components.length, addComponent, snapValueToGrid]);
return (
<div className="flex flex-1 items-center justify-center overflow-auto bg-gray-100 p-6">
<div
key={`canvas-${widthMm}-${heightMm}`}
ref={(r) => {
(canvasRef as any).current = r;
drop(r);
}}
className="relative bg-white shadow-lg"
style={{
width: widthPx,
height: heightPx,
minWidth: widthPx,
minHeight: heightPx,
backgroundImage: showGrid
? `linear-gradient(to right, #e5e7eb 1px, transparent 1px),
linear-gradient(to bottom, #e5e7eb 1px, transparent 1px)`
: undefined,
backgroundSize: showGrid ? `${MM_TO_PX * 5}px ${MM_TO_PX * 5}px` : undefined,
outline: isOver ? "2px dashed #2563eb" : "1px solid #d1d5db",
}}
onClick={(e) => {
if (e.target === e.currentTarget) selectComponent(null);
}}
>
{components.map((c) => (
<BarcodeLabelCanvasComponent key={c.id} component={c} />
))}
</div>
</div>
);
}

View File

@ -0,0 +1,20 @@
"use client";
import { ScrollArea } from "@/components/ui/scroll-area";
import { BarcodeTemplatePalette } from "./BarcodeTemplatePalette";
import { BarcodeComponentPalette } from "./BarcodeComponentPalette";
export function BarcodeDesignerLeftPanel() {
return (
<div className="flex min-h-0 w-64 shrink-0 flex-col overflow-hidden border-r bg-white">
<div className="min-h-0 flex-1 overflow-hidden">
<ScrollArea className="h-full">
<div className="space-y-4 p-4">
<BarcodeTemplatePalette />
<BarcodeComponentPalette />
</div>
</ScrollArea>
</div>
</div>
);
}

View File

@ -0,0 +1,251 @@
"use client";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Trash2 } from "lucide-react";
import { useBarcodeDesigner } from "@/contexts/BarcodeDesignerContext";
import { BarcodeLabelComponent } from "@/types/barcode";
export function BarcodeDesignerRightPanel() {
const {
components,
selectedComponentId,
updateComponent,
removeComponent,
selectComponent,
widthMm,
heightMm,
setWidthMm,
setHeightMm,
} = useBarcodeDesigner();
const selected = components.find((c) => c.id === selectedComponentId);
if (!selected) {
return (
<div className="w-72 border-l bg-white p-4">
<p className="text-muted-foreground text-sm"> .</p>
<div className="mt-4 space-y-2">
<Label className="text-xs"> (mm)</Label>
<div className="flex gap-2">
<Input
type="number"
min={10}
max={200}
value={widthMm}
onChange={(e) => setWidthMm(Number(e.target.value) || 50)}
/>
<span className="py-2">×</span>
<Input
type="number"
min={10}
max={200}
value={heightMm}
onChange={(e) => setHeightMm(Number(e.target.value) || 30)}
/>
</div>
</div>
</div>
);
}
const update = (updates: Partial<BarcodeLabelComponent>) =>
updateComponent(selected.id, updates);
return (
<div className="w-72 border-l bg-white">
<div className="border-b p-2 flex items-center justify-between">
<span className="text-sm font-medium"></span>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => {
removeComponent(selected.id);
selectComponent(null);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="space-y-4 p-4">
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs">X (px)</Label>
<Input
type="number"
value={Math.round(selected.x)}
onChange={(e) => update({ x: Number(e.target.value) || 0 })}
/>
</div>
<div>
<Label className="text-xs">Y (px)</Label>
<Input
type="number"
value={Math.round(selected.y)}
onChange={(e) => update({ y: Number(e.target.value) || 0 })}
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
type="number"
min={4}
value={Math.round(selected.width)}
onChange={(e) => update({ width: Number(e.target.value) || 10 })}
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
type="number"
min={4}
value={Math.round(selected.height)}
onChange={(e) => update({ height: Number(e.target.value) || 10 })}
/>
</div>
</div>
{selected.type === "text" && (
<>
<div>
<Label className="text-xs"></Label>
<Input
value={selected.content || ""}
onChange={(e) => update({ content: e.target.value })}
placeholder="텍스트"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
type="number"
min={6}
max={72}
value={selected.fontSize || 10}
onChange={(e) => update({ fontSize: Number(e.target.value) || 10 })}
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
type="color"
value={selected.fontColor || "#000000"}
onChange={(e) => update({ fontColor: e.target.value })}
className="h-9 w-20 p-1"
/>
</div>
</>
)}
{selected.type === "barcode" && (
<>
<div>
<Label className="text-xs"> </Label>
<Select
value={selected.barcodeType || "CODE128"}
onValueChange={(v) => update({ barcodeType: v })}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="CODE128">CODE128</SelectItem>
<SelectItem value="CODE39">CODE39</SelectItem>
<SelectItem value="EAN13">EAN13</SelectItem>
<SelectItem value="EAN8">EAN8</SelectItem>
<SelectItem value="QR">QR </SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"></Label>
<Input
value={selected.barcodeValue || ""}
onChange={(e) => update({ barcodeValue: e.target.value })}
placeholder="123456789"
/>
</div>
<div className="flex items-center gap-2">
<Switch
checked={selected.showBarcodeText !== false}
onCheckedChange={(v) => update({ showBarcodeText: v })}
/>
<Label className="text-xs"> (1D)</Label>
</div>
</>
)}
{selected.type === "line" && (
<>
<div>
<Label className="text-xs"> </Label>
<Input
type="number"
min={1}
value={selected.lineWidth || 1}
onChange={(e) => update({ lineWidth: Number(e.target.value) || 1 })}
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
type="color"
value={selected.lineColor || "#000000"}
onChange={(e) => update({ lineColor: e.target.value })}
className="h-9 w-20 p-1"
/>
</div>
</>
)}
{selected.type === "rectangle" && (
<>
<div>
<Label className="text-xs"> </Label>
<Input
type="number"
min={0}
value={selected.lineWidth ?? 1}
onChange={(e) => update({ lineWidth: Number(e.target.value) || 0 })}
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
type="color"
value={selected.lineColor || "#000000"}
onChange={(e) => update({ lineColor: e.target.value })}
className="h-9 w-20 p-1"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
type="color"
value={selected.backgroundColor || "#ffffff"}
onChange={(e) => update({ backgroundColor: e.target.value })}
className="h-9 w-20 p-1"
/>
</div>
</>
)}
{selected.type === "image" && (
<div>
<Label className="text-xs"> URL</Label>
<Input
value={selected.imageUrl || ""}
onChange={(e) => update({ imageUrl: e.target.value })}
placeholder="https://..."
/>
<p className="text-muted-foreground mt-1 text-xs"> </p>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,179 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { ArrowLeft, Save, Loader2, Download, Printer } from "lucide-react";
import { useBarcodeDesigner } from "@/contexts/BarcodeDesignerContext";
import { barcodeApi } from "@/lib/api/barcodeApi";
import { useToast } from "@/hooks/use-toast";
import { generateZPL } from "@/lib/zplGenerator";
import { BarcodePrintPreviewModal } from "./BarcodePrintPreviewModal";
export function BarcodeDesignerToolbar() {
const router = useRouter();
const { toast } = useToast();
const {
labelId,
labelMaster,
widthMm,
heightMm,
components,
saveLayout,
isSaving,
} = useBarcodeDesigner();
const handleDownloadZPL = () => {
const layout = { width_mm: widthMm, height_mm: heightMm, components };
const zpl = generateZPL(layout);
const blob = new Blob([zpl], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = (labelMaster?.label_name_kor || "label") + ".zpl";
a.click();
URL.revokeObjectURL(url);
toast({ title: "다운로드", description: "ZPL 파일이 다운로드되었습니다. Zebra 프린터/유틸에서 사용하세요." });
};
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [printPreviewOpen, setPrintPreviewOpen] = useState(false);
const [newLabelName, setNewLabelName] = useState("");
const [creating, setCreating] = useState(false);
const handleSave = async () => {
if (labelId !== "new") {
await saveLayout();
return;
}
setSaveDialogOpen(true);
};
const handleCreateAndSave = async () => {
const name = newLabelName.trim();
if (!name) {
toast({
title: "입력 필요",
description: "라벨명을 입력하세요.",
variant: "destructive",
});
return;
}
setCreating(true);
try {
const createRes = await barcodeApi.createLabel({
labelNameKor: name,
});
if (!createRes.success || !createRes.data?.labelId) throw new Error(createRes.message || "생성 실패");
const newId = createRes.data.labelId;
await barcodeApi.saveLayout(newId, {
width_mm: widthMm,
height_mm: heightMm,
components: components.map((c, i) => ({ ...c, zIndex: i })),
});
toast({ title: "저장됨", description: "라벨이 생성되었습니다." });
setSaveDialogOpen(false);
setNewLabelName("");
router.push(`/admin/screenMng/barcodeList/designer/${newId}`);
} catch (e: any) {
toast({
title: "저장 실패",
description: e.message || "라벨 생성에 실패했습니다.",
variant: "destructive",
});
} finally {
setCreating(false);
}
};
return (
<>
<div className="flex items-center justify-between border-b bg-white px-4 py-2">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="gap-1"
onClick={() => router.push("/admin/screenMng/barcodeList")}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<span className="text-muted-foreground text-sm">
{labelId === "new" ? "새 라벨" : labelMaster?.label_name_kor || "바코드 라벨 디자이너"}
</span>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
className="gap-1"
onClick={() => setPrintPreviewOpen(true)}
>
<Printer className="h-4 w-4" />
</Button>
<Button size="sm" variant="outline" className="gap-1" onClick={handleDownloadZPL}>
<Download className="h-4 w-4" />
ZPL
</Button>
<Button size="sm" className="gap-1" onClick={handleSave} disabled={isSaving || creating}>
{(isSaving || creating) ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
</Button>
</div>
</div>
<BarcodePrintPreviewModal
open={printPreviewOpen}
onOpenChange={setPrintPreviewOpen}
layout={{
width_mm: widthMm,
height_mm: heightMm,
components: components.map((c, i) => ({ ...c, zIndex: i })),
}}
labelName={labelMaster?.label_name_kor || "라벨"}
/>
<Dialog open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="space-y-2 py-2">
<Label> ()</Label>
<Input
value={newLabelName}
onChange={(e) => setNewLabelName(e.target.value)}
placeholder="예: 품목 바코드 라벨"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSaveDialogOpen(false)}>
</Button>
<Button onClick={handleCreateAndSave} disabled={creating}>
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -0,0 +1,262 @@
"use client";
import { useRef, useState, useEffect } from "react";
import { BarcodeLabelComponent } from "@/types/barcode";
import { useBarcodeDesigner } from "@/contexts/BarcodeDesignerContext";
import JsBarcode from "jsbarcode";
import QRCode from "qrcode";
import { getFullImageUrl } from "@/lib/api/client";
import { MM_TO_PX } from "@/contexts/BarcodeDesignerContext";
interface Props {
component: BarcodeLabelComponent;
}
// 1D 바코드 렌더
function Barcode1DRender({
value,
format,
width,
height,
showText,
}: {
value: string;
format: string;
width: number;
height: number;
showText: boolean;
}) {
const svgRef = useRef<SVGSVGElement>(null);
useEffect(() => {
if (!svgRef.current || !value.trim()) return;
try {
JsBarcode(svgRef.current, value.trim(), {
format: format.toLowerCase(),
width: 2,
height: Math.max(20, height - (showText ? 14 : 4)),
displayValue: showText,
margin: 2,
});
} catch {
// ignore
}
}, [value, format, height, showText]);
return (
<div className="flex h-full w-full items-center justify-center overflow-hidden">
<svg ref={svgRef} className="max-h-full max-w-full" />
</div>
);
}
// QR 렌더
function QRRender({ value, size }: { value: string; size: number }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (!canvasRef.current || !value.trim()) return;
QRCode.toCanvas(canvasRef.current, value.trim(), {
width: Math.max(40, size),
margin: 1,
});
}, [value, size]);
return (
<div className="flex h-full w-full items-center justify-center overflow-hidden">
<canvas ref={canvasRef} />
</div>
);
}
export function BarcodeLabelCanvasComponent({ component }: Props) {
const {
updateComponent,
removeComponent,
selectComponent,
selectedComponentId,
snapValueToGrid,
} = useBarcodeDesigner();
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0, compX: 0, compY: 0 });
const [isResizing, setIsResizing] = useState(false);
const [resizeStart, setResizeStart] = useState({ x: 0, y: 0, w: 0, h: 0 });
const ref = useRef<HTMLDivElement>(null);
const selected = selectedComponentId === component.id;
const handleMouseDown = (e: React.MouseEvent) => {
e.stopPropagation();
selectComponent(component.id);
if ((e.target as HTMLElement).closest("[data-resize-handle]")) {
setIsResizing(true);
setResizeStart({
x: e.clientX,
y: e.clientY,
w: component.width,
h: component.height,
});
} else {
setIsDragging(true);
setDragStart({ x: e.clientX, y: e.clientY, compX: component.x, compY: component.y });
}
};
useEffect(() => {
if (!isDragging && !isResizing) return;
const onMove = (e: MouseEvent) => {
if (isDragging) {
const dx = e.clientX - dragStart.x;
const dy = e.clientY - dragStart.y;
updateComponent(component.id, {
x: Math.max(0, snapValueToGrid(dragStart.compX + dx)),
y: Math.max(0, snapValueToGrid(dragStart.compY + dy)),
});
} else if (isResizing) {
const dx = e.clientX - resizeStart.x;
const dy = e.clientY - resizeStart.y;
updateComponent(component.id, {
width: Math.max(20, resizeStart.w + dx),
height: Math.max(10, resizeStart.h + dy),
});
}
};
const onUp = () => {
setIsDragging(false);
setIsResizing(false);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
return () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
}, [
isDragging,
isResizing,
dragStart,
resizeStart,
component.id,
updateComponent,
snapValueToGrid,
]);
const style: React.CSSProperties = {
position: "absolute",
left: component.x,
top: component.y,
width: component.width,
height: component.height,
zIndex: component.zIndex,
};
const border = selected ? "2px solid #2563eb" : "1px solid transparent";
const isBarcode = component.type === "barcode";
const isQR = component.barcodeType === "QR";
const content = () => {
switch (component.type) {
case "text":
return (
<div
style={{
fontSize: component.fontSize || 10,
color: component.fontColor || "#000",
fontWeight: component.fontWeight || "normal",
overflow: "hidden",
wordBreak: "break-all",
width: "100%",
height: "100%",
}}
>
{component.content || "텍스트"}
</div>
);
case "barcode":
if (isQR) {
return (
<QRRender
value={component.barcodeValue || ""}
size={Math.min(component.width, component.height)}
/>
);
}
return (
<Barcode1DRender
value={component.barcodeValue || "123456789"}
format={component.barcodeType || "CODE128"}
width={component.width}
height={component.height}
showText={component.showBarcodeText !== false}
/>
);
case "image":
return component.imageUrl ? (
<img
src={getFullImageUrl(component.imageUrl)}
alt=""
style={{
width: "100%",
height: "100%",
objectFit: (component.objectFit as "contain") || "contain",
}}
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-gray-100 text-xs text-gray-400">
</div>
);
case "line":
return (
<div
style={{
width: "100%",
height: component.lineWidth || 1,
backgroundColor: component.lineColor || "#000",
marginTop: (component.height - (component.lineWidth || 1)) / 2,
}}
/>
);
case "rectangle":
return (
<div
style={{
width: "100%",
height: "100%",
backgroundColor: component.backgroundColor || "transparent",
border: `${component.lineWidth || 1}px solid ${component.lineColor || "#000"}`,
}}
/>
);
default:
return null;
}
};
return (
<div
ref={ref}
style={{ ...style, border }}
className="cursor-move overflow-hidden bg-white"
onMouseDown={handleMouseDown}
>
{content()}
{selected && component.type !== "line" && (
<div
data-resize-handle
className="absolute bottom-0 right-0 h-2 w-2 cursor-se-resize bg-blue-500"
onMouseDown={(e) => {
e.stopPropagation();
setIsResizing(true);
setResizeStart({
x: e.clientX,
y: e.clientY,
w: component.width,
h: component.height,
});
}}
/>
)}
</div>
);
}

View File

@ -0,0 +1,233 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Download, Printer, Loader2, AlertCircle } from "lucide-react";
import { BarcodeLabelLayout } from "@/types/barcode";
import { generateZPL } from "@/lib/zplGenerator";
import {
printZPLToZebraBLE,
isWebBluetoothSupported,
getUnsupportedMessage,
} from "@/lib/zebraBluetooth";
import {
printZPLToBrowserPrint,
getBrowserPrintHelpMessage,
} from "@/lib/zebraBrowserPrint";
import { useToast } from "@/hooks/use-toast";
import { BarcodeLabelCanvasComponent } from "./BarcodeLabelCanvasComponent";
import { MM_TO_PX } from "@/contexts/BarcodeDesignerContext";
const PREVIEW_MAX_PX = 320;
interface BarcodePrintPreviewModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
layout: BarcodeLabelLayout;
labelName?: string;
}
export function BarcodePrintPreviewModal({
open,
onOpenChange,
layout,
labelName = "라벨",
}: BarcodePrintPreviewModalProps) {
const { toast } = useToast();
const [printing, setPrinting] = useState(false);
const { width_mm, height_mm, components } = layout;
const widthPx = width_mm * MM_TO_PX;
const heightPx = height_mm * MM_TO_PX;
const scale =
widthPx > PREVIEW_MAX_PX || heightPx > PREVIEW_MAX_PX
? Math.min(PREVIEW_MAX_PX / widthPx, PREVIEW_MAX_PX / heightPx)
: 1;
const previewW = Math.round(widthPx * scale);
const previewH = Math.round(heightPx * scale);
const zpl = generateZPL(layout);
const bleSupported = isWebBluetoothSupported();
const unsupportedMsg = getUnsupportedMessage();
const handleDownloadZPL = () => {
const blob = new Blob([zpl], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${labelName}.zpl`;
a.click();
URL.revokeObjectURL(url);
toast({ title: "다운로드", description: "ZPL 파일이 저장되었습니다." });
};
const handlePrintToZebra = async () => {
const canUseBle = bleSupported;
if (!canUseBle) {
// Browser Print만 시도 (스크립트 로드 후 기본 프린터로 전송)
setPrinting(true);
try {
const result = await printZPLToBrowserPrint(zpl);
if (result.success) {
toast({ title: "전송 완료", description: result.message });
onOpenChange(false);
} else {
toast({
title: "출력 실패",
description: result.message,
variant: "destructive",
});
toast({
title: "안내",
description: getBrowserPrintHelpMessage(),
variant: "default",
});
}
} catch (e: unknown) {
toast({
title: "오류",
description: (e as Error).message || "Zebra 출력 중 오류가 발생했습니다.",
variant: "destructive",
});
} finally {
setPrinting(false);
}
return;
}
// Web Bluetooth 지원 시: Browser Print 먼저 시도, 실패하면 BLE로 폴백
setPrinting(true);
try {
const bpResult = await printZPLToBrowserPrint(zpl);
if (bpResult.success) {
toast({ title: "전송 완료", description: bpResult.message });
onOpenChange(false);
return;
}
const bleResult = await printZPLToZebraBLE(zpl);
if (bleResult.success) {
toast({ title: "전송 완료", description: bleResult.message });
onOpenChange(false);
} else {
toast({
title: "출력 실패",
description: bleResult.message,
variant: "destructive",
});
toast({
title: "안내",
description: getBrowserPrintHelpMessage(),
variant: "default",
});
}
} catch (e: unknown) {
toast({
title: "오류",
description: (e as Error).message || "Zebra 출력 중 오류가 발생했습니다.",
variant: "destructive",
});
} finally {
setPrinting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-muted-foreground text-sm">
{width_mm}×{height_mm}mm · {components.length}
</p>
{/* 미리보기 캔버스 (축소) */}
<div className="flex justify-center rounded border bg-gray-100 p-4">
<div
className="relative bg-white shadow"
style={{
width: previewW,
height: previewH,
transformOrigin: "top left",
}}
>
<div
className="pointer-events-none"
style={{
transform: `scale(${scale})`,
transformOrigin: "0 0",
width: widthPx,
height: heightPx,
position: "absolute",
left: 0,
top: 0,
}}
>
{components.map((c) => (
<BarcodeLabelCanvasComponent key={c.id} component={c} />
))}
</div>
</div>
</div>
{!bleSupported && (
<div className="flex gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
<AlertCircle className="h-4 w-4 shrink-0" />
<span>
Web Bluetooth . Zebra Browser Print .
{unsupportedMsg && ` ${unsupportedMsg}`}
</span>
</div>
)}
<p className="text-muted-foreground text-xs">
{bleSupported ? (
<>
Zebra Bluetooth LE로 , .
(Chrome/Edge )
{typeof navigator !== "undefined" && /Android/i.test(navigator.userAgent) && (
<> Android에서는 BLE , &apos;ZD421&apos; .</>
)}
</>
) : null}
{typeof navigator !== "undefined" && /Android/i.test(navigator.userAgent) && (
<>
{" "}
&apos;Zebra Browser Print&apos; , · .
</>
)}
</p>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" size="sm" onClick={handleDownloadZPL} className="gap-1">
<Download className="h-4 w-4" />
ZPL
</Button>
<Button
size="sm"
className="gap-1"
onClick={handlePrintToZebra}
disabled={printing}
>
{printing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Printer className="h-4 w-4" />
)}
Zebra
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,132 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Loader2, Search } from "lucide-react";
import { useBarcodeDesigner } from "@/contexts/BarcodeDesignerContext";
import { barcodeApi, BarcodeLabelTemplate } from "@/lib/api/barcodeApi";
type Category = "all" | "basic" | "zebra";
export function BarcodeTemplatePalette() {
const { applyTemplate } = useBarcodeDesigner();
const [templates, setTemplates] = useState<BarcodeLabelTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [category, setCategory] = useState<Category>("all");
const [searchText, setSearchText] = useState("");
useEffect(() => {
(async () => {
try {
const res = await barcodeApi.getTemplates();
if (res.success && res.data) setTemplates(res.data);
} catch {
setTemplates([]);
} finally {
setLoading(false);
}
})();
}, []);
const filtered = useMemo(() => {
let list = templates;
if (category === "basic") {
list = list.filter((t) => t.template_id.startsWith("TMPL_"));
} else if (category === "zebra") {
list = list.filter((t) => t.template_id.startsWith("ZJ"));
}
const q = searchText.trim().toLowerCase();
if (q) {
list = list.filter(
(t) =>
t.template_id.toLowerCase().includes(q) ||
(t.template_name_kor && t.template_name_kor.toLowerCase().includes(q)) ||
(t.template_name_eng && t.template_name_eng.toLowerCase().includes(q))
);
}
return list;
}, [templates, category, searchText]);
if (loading) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm"> </CardTitle>
</CardHeader>
<CardContent className="flex justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm"> </CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="relative">
<Search className="text-muted-foreground absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2" />
<Input
placeholder="코드·이름으로 찾기"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="h-8 pl-8 text-xs"
/>
</div>
<div className="flex gap-1">
<Button
variant={category === "all" ? "secondary" : "ghost"}
size="sm"
className="flex-1 text-xs"
onClick={() => setCategory("all")}
>
</Button>
<Button
variant={category === "basic" ? "secondary" : "ghost"}
size="sm"
className="flex-1 text-xs"
onClick={() => setCategory("basic")}
>
</Button>
<Button
variant={category === "zebra" ? "secondary" : "ghost"}
size="sm"
className="flex-1 text-xs"
onClick={() => setCategory("zebra")}
>
</Button>
</div>
<ScrollArea className="h-[280px] pr-2">
<div className="space-y-1">
{filtered.length === 0 ? (
<p className="text-muted-foreground py-2 text-center text-xs"> </p>
) : (
filtered.map((t) => (
<Button
key={t.template_id}
variant="outline"
size="sm"
className="h-auto w-full justify-start py-1.5 text-left"
onClick={() => applyTemplate(t.template_id)}
>
<span className="truncate">{t.template_name_kor}</span>
<span className="text-muted-foreground ml-1 shrink-0 text-xs">
{t.width_mm}×{t.height_mm}
</span>
</Button>
))
)}
</div>
</ScrollArea>
</CardContent>
</Card>
);
}

View File

@ -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<BarcodeLabelComponent>) => void;
removeComponent: (id: string) => void;
selectComponent: (id: string | null) => void;
reorderComponent: (id: string, direction: "up" | "down") => void;
loadLabel: () => Promise<void>;
loadLayout: () => Promise<void>;
saveLayout: () => Promise<void>;
applyTemplate: (templateId: string) => Promise<void>;
gridSize: number;
showGrid: boolean;
setShowGrid: (v: boolean) => void;
snapValueToGrid: (v: number) => number;
}
const BarcodeDesignerContext = createContext<BarcodeDesignerContextType | undefined>(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<BarcodeLabelMaster | null>(null);
const [widthMm, setWidthMm] = useState(DEFAULT_WIDTH_MM);
const [heightMm, setHeightMm] = useState(DEFAULT_HEIGHT_MM);
const [components, setComponents] = useState<BarcodeLabelComponent[]>([]);
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(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<string | null>(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<BarcodeLabelComponent>) => {
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 (
<BarcodeDesignerContext.Provider value={value}>
{children}
{isLoading && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/20">
<div className="bg-background flex flex-col items-center gap-2 rounded-lg border p-4 shadow-lg">
<div className="border-primary h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
<span className="text-sm"> ...</span>
</div>
</div>
)}
</BarcodeDesignerContext.Provider>
);
}
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 };

View File

@ -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<BarcodeLabelMaster[]>([]);
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,
};
}

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

86
frontend/types/barcode.ts Normal file
View File

@ -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;
}