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:
commit
4da5d10f09
|
|
@ -90,6 +90,7 @@ import externalCallConfigRoutes from "./routes/externalCallConfigRoutes";
|
||||||
import dataflowExecutionRoutes from "./routes/dataflowExecutionRoutes";
|
import dataflowExecutionRoutes from "./routes/dataflowExecutionRoutes";
|
||||||
import dashboardRoutes from "./routes/dashboardRoutes";
|
import dashboardRoutes from "./routes/dashboardRoutes";
|
||||||
import reportRoutes from "./routes/reportRoutes";
|
import reportRoutes from "./routes/reportRoutes";
|
||||||
|
import barcodeLabelRoutes from "./routes/barcodeLabelRoutes";
|
||||||
import openApiProxyRoutes from "./routes/openApiProxyRoutes"; // 날씨/환율 API
|
import openApiProxyRoutes from "./routes/openApiProxyRoutes"; // 날씨/환율 API
|
||||||
import deliveryRoutes from "./routes/deliveryRoutes"; // 배송/화물 관리
|
import deliveryRoutes from "./routes/deliveryRoutes"; // 배송/화물 관리
|
||||||
import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관리
|
import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관리
|
||||||
|
|
@ -278,6 +279,7 @@ app.use("/api/external-call-configs", externalCallConfigRoutes);
|
||||||
app.use("/api/dataflow", dataflowExecutionRoutes);
|
app.use("/api/dataflow", dataflowExecutionRoutes);
|
||||||
app.use("/api/dashboards", dashboardRoutes);
|
app.use("/api/dashboards", dashboardRoutes);
|
||||||
app.use("/api/admin/reports", reportRoutes);
|
app.use("/api/admin/reports", reportRoutes);
|
||||||
|
app.use("/api/admin/barcode-labels", barcodeLabelRoutes);
|
||||||
app.use("/api/open-api", openApiProxyRoutes); // 날씨/환율 외부 API
|
app.use("/api/open-api", openApiProxyRoutes); // 날씨/환율 외부 API
|
||||||
app.use("/api/delivery", deliveryRoutes); // 배송/화물 관리
|
app.use("/api/delivery", deliveryRoutes); // 배송/화물 관리
|
||||||
app.use("/api/risk-alerts", riskAlertRoutes); // 리스크/알림 관리
|
app.use("/api/risk-alerts", riskAlertRoutes); // 리스크/알림 관리
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 기기가 모두 표시되므로, 'ZD421' 등 프린터 이름을 골라 주세요.</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{typeof navigator !== "undefined" && /Android/i.test(navigator.userAgent) && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
목록에 프린터가 안 나오면 지브라 공식 'Zebra Browser Print' 앱을 설치한 뒤, 앱에서 프린터 검색·기본 설정 후 이 사이트를 허용하면 출력할 수 있습니다.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleDownloadZPL} className="gap-1">
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
ZPL 다운로드
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="gap-1"
|
||||||
|
onClick={handlePrintToZebra}
|
||||||
|
disabled={printing}
|
||||||
|
>
|
||||||
|
{printing ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Printer className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Zebra 프린터로 출력
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 프린터로 출력'으로 인쇄할 수 있습니다.";
|
||||||
|
}
|
||||||
|
|
@ -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, "~~");
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue