Merge branch 'main' into jskim-node
This commit is contained in:
commit
95c42a971c
|
|
@ -1,9 +1,5 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"agent-orchestrator": {
|
||||
"command": "node",
|
||||
"args": ["/Users/gbpark/ERP-node/mcp-agent-orchestrator/build/index.js"]
|
||||
},
|
||||
"Framelink Figma MCP": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "figma-developer-mcp", "--figma-api-key=figd_NrYdIWf-CnC23NyH6eMym7sBdfbZTuXyS91tI3VS", "--stdio"]
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ import externalCallConfigRoutes from "./routes/externalCallConfigRoutes";
|
|||
import dataflowExecutionRoutes from "./routes/dataflowExecutionRoutes";
|
||||
import dashboardRoutes from "./routes/dashboardRoutes";
|
||||
import reportRoutes from "./routes/reportRoutes";
|
||||
import barcodeLabelRoutes from "./routes/barcodeLabelRoutes";
|
||||
import openApiProxyRoutes from "./routes/openApiProxyRoutes"; // 날씨/환율 API
|
||||
import deliveryRoutes from "./routes/deliveryRoutes"; // 배송/화물 관리
|
||||
import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관리
|
||||
|
|
@ -114,6 +115,7 @@ import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙
|
|||
import entitySearchRoutes, { entityOptionsRouter } from "./routes/entitySearchRoutes"; // 엔티티 검색 및 옵션
|
||||
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
||||
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
|
||||
import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행
|
||||
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
||||
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
||||
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
|
||||
|
|
@ -244,6 +246,7 @@ app.use("/api/table-management", tableManagementRoutes);
|
|||
app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능
|
||||
app.use("/api/screen-management", screenManagementRoutes);
|
||||
app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리
|
||||
app.use("/api/pop", popActionRoutes); // POP 액션 실행
|
||||
app.use("/api/common-codes", commonCodeRoutes);
|
||||
app.use("/api/dynamic-form", dynamicFormRoutes);
|
||||
app.use("/api/files", fileRoutes);
|
||||
|
|
@ -279,6 +282,7 @@ app.use("/api/external-call-configs", externalCallConfigRoutes);
|
|||
app.use("/api/dataflow", dataflowExecutionRoutes);
|
||||
app.use("/api/dashboards", dashboardRoutes);
|
||||
app.use("/api/admin/reports", reportRoutes);
|
||||
app.use("/api/admin/barcode-labels", barcodeLabelRoutes);
|
||||
app.use("/api/open-api", openApiProxyRoutes); // 날씨/환율 외부 API
|
||||
app.use("/api/delivery", deliveryRoutes); // 배송/화물 관리
|
||||
app.use("/api/risk-alerts", riskAlertRoutes); // 리스크/알림 관리
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -20,7 +20,7 @@ const pool = getPool();
|
|||
export const getScreenGroups = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const { page = 1, size = 20, searchTerm } = req.query;
|
||||
const { page = 1, size = 20, searchTerm, excludePop } = req.query;
|
||||
const offset = (parseInt(page as string) - 1) * parseInt(size as string);
|
||||
|
||||
let whereClause = "WHERE 1=1";
|
||||
|
|
@ -34,6 +34,11 @@ export const getScreenGroups = async (req: AuthenticatedRequest, res: Response)
|
|||
paramIndex++;
|
||||
}
|
||||
|
||||
// POP 그룹 제외 (PC 화면관리용)
|
||||
if (excludePop === "true") {
|
||||
whereClause += ` AND (hierarchy_path IS NULL OR (hierarchy_path NOT LIKE 'POP/%' AND hierarchy_path != 'POP'))`;
|
||||
}
|
||||
|
||||
// 검색어 필터링
|
||||
if (searchTerm) {
|
||||
whereClause += ` AND (group_name ILIKE $${paramIndex} OR group_code ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`;
|
||||
|
|
@ -2573,11 +2578,11 @@ export const getPopScreenGroups = async (req: AuthenticatedRequest, res: Respons
|
|||
const companyCode = req.user?.companyCode || "*";
|
||||
const { searchTerm } = req.query;
|
||||
|
||||
let whereClause = "WHERE hierarchy_path LIKE 'POP/%' OR hierarchy_path = 'POP'";
|
||||
let whereClause = "WHERE (hierarchy_path LIKE 'POP/%' OR hierarchy_path = 'POP')";
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 회사 코드 필터링 (멀티테넌시)
|
||||
// 회사 코드 필터링 (멀티테넌시) - 일반 회사는 자기 회사 데이터만
|
||||
if (companyCode !== "*") {
|
||||
whereClause += ` AND company_code = $${paramIndex}`;
|
||||
params.push(companyCode);
|
||||
|
|
@ -2591,11 +2596,13 @@ export const getPopScreenGroups = async (req: AuthenticatedRequest, res: Respons
|
|||
paramIndex++;
|
||||
}
|
||||
|
||||
// POP 그룹 조회 (계층 구조를 위해 전체 조회)
|
||||
// POP 그룹 조회 (POP 레이아웃이 있는 화면만 카운트/포함)
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
sg.*,
|
||||
(SELECT COUNT(*) FROM screen_group_screens sgs WHERE sgs.group_id = sg.id) as screen_count,
|
||||
(SELECT COUNT(*) FROM screen_group_screens sgs
|
||||
INNER JOIN screen_layouts_pop slp ON sgs.screen_id = slp.screen_id AND sgs.company_code = slp.company_code
|
||||
WHERE sgs.group_id = sg.id AND sgs.company_code = sg.company_code) as screen_count,
|
||||
(SELECT json_agg(
|
||||
json_build_object(
|
||||
'id', sgs.id,
|
||||
|
|
@ -2608,7 +2615,8 @@ export const getPopScreenGroups = async (req: AuthenticatedRequest, res: Respons
|
|||
) ORDER BY sgs.display_order
|
||||
) FROM screen_group_screens sgs
|
||||
LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id
|
||||
WHERE sgs.group_id = sg.id
|
||||
INNER JOIN screen_layouts_pop slp ON sgs.screen_id = slp.screen_id AND sgs.company_code = slp.company_code
|
||||
WHERE sgs.group_id = sg.id AND sgs.company_code = sg.company_code
|
||||
) as screens
|
||||
FROM screen_groups sg
|
||||
${whereClause}
|
||||
|
|
@ -2767,6 +2775,14 @@ export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Respo
|
|||
|
||||
const existing = await pool.query(checkQuery, checkParams);
|
||||
if (existing.rows.length === 0) {
|
||||
// 그룹이 다른 회사 소속인지 확인하여 구체적 메시지 제공
|
||||
const anyGroup = await pool.query(`SELECT company_code FROM screen_groups WHERE id = $1`, [id]);
|
||||
if (anyGroup.rows.length > 0) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: `이 그룹은 ${anyGroup.rows[0].company_code === "*" ? "최고관리자" : anyGroup.rows[0].company_code} 소속이라 삭제할 수 없습니다.`
|
||||
});
|
||||
}
|
||||
return res.status(404).json({ success: false, message: "그룹을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
|
|
@ -2781,7 +2797,10 @@ export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Respo
|
|||
[id]
|
||||
);
|
||||
if (parseInt(childCheck.rows[0].count) > 0) {
|
||||
return res.status(400).json({ success: false, message: "하위 그룹이 있어 삭제할 수 없습니다." });
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `하위 그룹이 ${childCheck.rows[0].count}개 있어 삭제할 수 없습니다. 하위 그룹을 먼저 삭제해주세요.`
|
||||
});
|
||||
}
|
||||
|
||||
// 연결된 화면 확인
|
||||
|
|
@ -2790,7 +2809,10 @@ export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Respo
|
|||
[id]
|
||||
);
|
||||
if (parseInt(screenCheck.rows[0].count) > 0) {
|
||||
return res.status(400).json({ success: false, message: "그룹에 연결된 화면이 있어 삭제할 수 없습니다." });
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `그룹에 연결된 화면이 ${screenCheck.rows[0].count}개 있어 삭제할 수 없습니다. 화면을 먼저 제거해주세요.`
|
||||
});
|
||||
}
|
||||
|
||||
// 삭제
|
||||
|
|
@ -2805,33 +2827,44 @@ export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Respo
|
|||
}
|
||||
};
|
||||
|
||||
// POP 루트 그룹 확보 (없으면 자동 생성)
|
||||
// POP 루트 그룹 확보 (최고관리자 전용 - 일반 회사는 복사 기능으로 배포)
|
||||
export const ensurePopRootGroup = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
// POP 루트 그룹 확인
|
||||
const checkQuery = `
|
||||
SELECT * FROM screen_groups
|
||||
WHERE hierarchy_path = 'POP' AND company_code = $1
|
||||
`;
|
||||
const existing = await pool.query(checkQuery, [companyCode]);
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
return res.json({ success: true, data: existing.rows[0], message: "POP 루트 그룹이 이미 존재합니다." });
|
||||
// 최고관리자만 자동 생성
|
||||
if (companyCode !== "*") {
|
||||
const existing = await pool.query(
|
||||
`SELECT * FROM screen_groups WHERE hierarchy_path = 'POP' AND company_code = $1`,
|
||||
[companyCode]
|
||||
);
|
||||
if (existing.rows.length > 0) {
|
||||
return res.json({ success: true, data: existing.rows[0] });
|
||||
}
|
||||
return res.json({ success: true, data: null, message: "POP 루트 그룹이 없습니다." });
|
||||
}
|
||||
|
||||
// 최고관리자(*): 루트 그룹 확인 후 없으면 생성
|
||||
const checkQuery = `
|
||||
SELECT * FROM screen_groups
|
||||
WHERE hierarchy_path = 'POP' AND company_code = '*'
|
||||
`;
|
||||
const existing = await pool.query(checkQuery, []);
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
return res.json({ success: true, data: existing.rows[0] });
|
||||
}
|
||||
|
||||
// 없으면 생성 (writer 컬럼 사용, is_active는 'Y' - 기존 스키마에 맞춤)
|
||||
const insertQuery = `
|
||||
INSERT INTO screen_groups (
|
||||
group_name, group_code, hierarchy_path, company_code,
|
||||
description, display_order, is_active, writer
|
||||
) VALUES ('POP 화면', 'POP', 'POP', $1, 'POP 화면 관리 루트', 0, 'Y', $2)
|
||||
) VALUES ('POP 화면', 'POP', 'POP', '*', 'POP 화면 관리 루트', 0, 'Y', $1)
|
||||
RETURNING *
|
||||
`;
|
||||
const result = await pool.query(insertQuery, [companyCode, req.user?.userId || ""]);
|
||||
const result = await pool.query(insertQuery, [req.user?.userId || ""]);
|
||||
|
||||
logger.info("POP 루트 그룹 생성", { groupId: result.rows[0].id, companyCode });
|
||||
logger.info("POP 루트 그룹 생성", { groupId: result.rows[0].id });
|
||||
|
||||
res.json({ success: true, data: result.rows[0], message: "POP 루트 그룹이 생성되었습니다." });
|
||||
} catch (error: any) {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { auditLogService, getClientIp } from "../services/auditLogService";
|
|||
export const getScreens = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const userCompanyCode = (req.user as any).companyCode;
|
||||
const { page = 1, size = 20, searchTerm, companyCode } = req.query;
|
||||
const { page = 1, size = 20, searchTerm, companyCode, excludePop } = req.query;
|
||||
|
||||
// 쿼리 파라미터로 companyCode가 전달되면 해당 회사의 화면 조회 (최고 관리자 전용)
|
||||
// 아니면 현재 사용자의 companyCode 사용
|
||||
|
|
@ -25,7 +25,8 @@ export const getScreens = async (req: AuthenticatedRequest, res: Response) => {
|
|||
targetCompanyCode,
|
||||
parseInt(page as string),
|
||||
parseInt(size as string),
|
||||
searchTerm as string // 검색어 전달
|
||||
searchTerm as string,
|
||||
{ excludePop: excludePop === "true" },
|
||||
);
|
||||
|
||||
res.json({
|
||||
|
|
@ -1537,3 +1538,82 @@ export const copyCascadingRelation = async (
|
|||
});
|
||||
}
|
||||
};
|
||||
|
||||
// POP 화면 연결 분석
|
||||
export const analyzePopScreenLinks = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { screenId } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
|
||||
const result = await screenManagementService.analyzePopScreenLinks(
|
||||
parseInt(screenId),
|
||||
companyCode,
|
||||
);
|
||||
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
console.error("POP 화면 연결 분석 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "POP 화면 연결 분석에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// POP 화면 배포 (다른 회사로 복사)
|
||||
export const deployPopScreens = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { screens, targetCompanyCode, groupStructure } = req.body;
|
||||
const { companyCode, userId } = req.user as any;
|
||||
|
||||
if (!screens || !Array.isArray(screens) || screens.length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "배포할 화면 목록이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!targetCompanyCode) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "대상 회사 코드가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (companyCode !== "*") {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "최고 관리자만 POP 화면을 배포할 수 있습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await screenManagementService.deployPopScreens({
|
||||
screens,
|
||||
groupStructure: groupStructure || undefined,
|
||||
targetCompanyCode,
|
||||
companyCode,
|
||||
userId,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: `POP 화면 ${result.deployedScreens.length}개가 ${targetCompanyCode}에 배포되었습니다.`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("POP 화면 배포 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "POP 화면 배포에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,280 @@
|
|||
import { Router, Request, Response } from "express";
|
||||
import { getPool } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// SQL 인젝션 방지: 테이블명/컬럼명 패턴 검증
|
||||
const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
|
||||
function isSafeIdentifier(name: string): boolean {
|
||||
return SAFE_IDENTIFIER.test(name);
|
||||
}
|
||||
|
||||
interface MappingInfo {
|
||||
targetTable: string;
|
||||
columnMapping: Record<string, string>;
|
||||
}
|
||||
|
||||
interface StatusConditionRule {
|
||||
whenColumn: string;
|
||||
operator: string;
|
||||
whenValue: string;
|
||||
thenValue: string;
|
||||
}
|
||||
|
||||
interface ConditionalValueRule {
|
||||
conditions: StatusConditionRule[];
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
interface StatusChangeRuleBody {
|
||||
targetTable: string;
|
||||
targetColumn: string;
|
||||
lookupMode?: "auto" | "manual";
|
||||
manualItemField?: string;
|
||||
manualPkColumn?: string;
|
||||
valueType: "fixed" | "conditional";
|
||||
fixedValue?: string;
|
||||
conditionalValue?: ConditionalValueRule;
|
||||
// 하위호환: 기존 형식
|
||||
value?: string;
|
||||
condition?: string;
|
||||
}
|
||||
|
||||
interface ExecuteActionBody {
|
||||
action: string;
|
||||
data: {
|
||||
items?: Record<string, unknown>[];
|
||||
fieldValues?: Record<string, unknown>;
|
||||
};
|
||||
mappings?: {
|
||||
cardList?: MappingInfo | null;
|
||||
field?: MappingInfo | null;
|
||||
};
|
||||
statusChanges?: StatusChangeRuleBody[];
|
||||
}
|
||||
|
||||
function resolveStatusValue(
|
||||
valueType: string,
|
||||
fixedValue: string,
|
||||
conditionalValue: ConditionalValueRule | undefined,
|
||||
item: Record<string, unknown>
|
||||
): string {
|
||||
if (valueType !== "conditional" || !conditionalValue) return fixedValue;
|
||||
|
||||
for (const cond of conditionalValue.conditions) {
|
||||
const actual = String(item[cond.whenColumn] ?? "");
|
||||
const expected = cond.whenValue;
|
||||
let match = false;
|
||||
|
||||
switch (cond.operator) {
|
||||
case "=": match = actual === expected; break;
|
||||
case "!=": match = actual !== expected; break;
|
||||
case ">": match = parseFloat(actual) > parseFloat(expected); break;
|
||||
case "<": match = parseFloat(actual) < parseFloat(expected); break;
|
||||
case ">=": match = parseFloat(actual) >= parseFloat(expected); break;
|
||||
case "<=": match = parseFloat(actual) <= parseFloat(expected); break;
|
||||
default: match = actual === expected;
|
||||
}
|
||||
|
||||
if (match) return cond.thenValue;
|
||||
}
|
||||
|
||||
return conditionalValue.defaultValue ?? fixedValue;
|
||||
}
|
||||
|
||||
router.post("/execute-action", authenticateToken, async (req: Request, res: Response) => {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const companyCode = (req as any).user?.companyCode;
|
||||
const userId = (req as any).user?.userId;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const { action, data, mappings, statusChanges } = req.body as ExecuteActionBody;
|
||||
const items = data?.items ?? [];
|
||||
const fieldValues = data?.fieldValues ?? {};
|
||||
|
||||
logger.info("[pop/execute-action] 요청", {
|
||||
action,
|
||||
companyCode,
|
||||
userId,
|
||||
itemCount: items.length,
|
||||
hasFieldValues: Object.keys(fieldValues).length > 0,
|
||||
hasMappings: !!mappings,
|
||||
statusChangeCount: statusChanges?.length ?? 0,
|
||||
});
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
let processedCount = 0;
|
||||
let insertedCount = 0;
|
||||
|
||||
if (action === "inbound-confirm") {
|
||||
// 1. 매핑 기반 INSERT (장바구니 데이터 -> 대상 테이블)
|
||||
const cardMapping = mappings?.cardList;
|
||||
const fieldMapping = mappings?.field;
|
||||
|
||||
if (cardMapping?.targetTable && Object.keys(cardMapping.columnMapping).length > 0) {
|
||||
if (!isSafeIdentifier(cardMapping.targetTable)) {
|
||||
throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`);
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const columns: string[] = ["company_code"];
|
||||
const values: unknown[] = [companyCode];
|
||||
|
||||
for (const [sourceField, targetColumn] of Object.entries(cardMapping.columnMapping)) {
|
||||
if (!isSafeIdentifier(targetColumn)) continue;
|
||||
columns.push(`"${targetColumn}"`);
|
||||
values.push(item[sourceField] ?? null);
|
||||
}
|
||||
|
||||
if (fieldMapping?.targetTable === cardMapping.targetTable) {
|
||||
for (const [sourceField, targetColumn] of Object.entries(fieldMapping.columnMapping)) {
|
||||
if (!isSafeIdentifier(targetColumn)) continue;
|
||||
if (columns.includes(`"${targetColumn}"`)) continue;
|
||||
columns.push(`"${targetColumn}"`);
|
||||
values.push(fieldValues[sourceField] ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
if (columns.length > 1) {
|
||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||
const sql = `INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`;
|
||||
|
||||
logger.info("[pop/execute-action] INSERT 실행", {
|
||||
table: cardMapping.targetTable,
|
||||
columnCount: columns.length,
|
||||
});
|
||||
|
||||
await client.query(sql, values);
|
||||
insertedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
fieldMapping?.targetTable &&
|
||||
Object.keys(fieldMapping.columnMapping).length > 0 &&
|
||||
fieldMapping.targetTable !== cardMapping?.targetTable
|
||||
) {
|
||||
if (!isSafeIdentifier(fieldMapping.targetTable)) {
|
||||
throw new Error(`유효하지 않은 테이블명: ${fieldMapping.targetTable}`);
|
||||
}
|
||||
|
||||
const columns: string[] = ["company_code"];
|
||||
const values: unknown[] = [companyCode];
|
||||
|
||||
for (const [sourceField, targetColumn] of Object.entries(fieldMapping.columnMapping)) {
|
||||
if (!isSafeIdentifier(targetColumn)) continue;
|
||||
columns.push(`"${targetColumn}"`);
|
||||
values.push(fieldValues[sourceField] ?? null);
|
||||
}
|
||||
|
||||
if (columns.length > 1) {
|
||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||
const sql = `INSERT INTO "${fieldMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`;
|
||||
await client.query(sql, values);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 상태 변경 규칙 실행 (설정 기반)
|
||||
if (statusChanges && statusChanges.length > 0) {
|
||||
for (const rule of statusChanges) {
|
||||
if (!rule.targetTable || !rule.targetColumn) continue;
|
||||
if (!isSafeIdentifier(rule.targetTable) || !isSafeIdentifier(rule.targetColumn)) {
|
||||
logger.warn("[pop/execute-action] 유효하지 않은 식별자, 건너뜀", { table: rule.targetTable, column: rule.targetColumn });
|
||||
continue;
|
||||
}
|
||||
|
||||
const valueType = rule.valueType ?? "fixed";
|
||||
const fixedValue = rule.fixedValue ?? rule.value ?? "";
|
||||
const lookupMode = rule.lookupMode ?? "auto";
|
||||
|
||||
// 조회 키 결정: 아이템 필드(itemField) -> 대상 테이블 PK 컬럼(pkColumn)
|
||||
let itemField: string;
|
||||
let pkColumn: string;
|
||||
|
||||
if (lookupMode === "manual" && rule.manualItemField && rule.manualPkColumn) {
|
||||
if (!isSafeIdentifier(rule.manualPkColumn)) {
|
||||
logger.warn("[pop/execute-action] 수동 PK 컬럼 유효하지 않음", { manualPkColumn: rule.manualPkColumn });
|
||||
continue;
|
||||
}
|
||||
itemField = rule.manualItemField;
|
||||
pkColumn = rule.manualPkColumn;
|
||||
logger.info("[pop/execute-action] 수동 조회 키", { itemField, pkColumn, table: rule.targetTable });
|
||||
} else if (rule.targetTable === "cart_items") {
|
||||
itemField = "__cart_id";
|
||||
pkColumn = "id";
|
||||
} else {
|
||||
itemField = "__cart_row_key";
|
||||
const pkResult = await client.query(
|
||||
`SELECT a.attname FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) WHERE i.indrelid = $1::regclass AND i.indisprimary`,
|
||||
[rule.targetTable]
|
||||
);
|
||||
pkColumn = pkResult.rows[0]?.attname || "id";
|
||||
}
|
||||
|
||||
const lookupValues = items.map((item) => item[itemField] ?? item[itemField.replace(/^__cart_/, "")]).filter(Boolean);
|
||||
if (lookupValues.length === 0) {
|
||||
logger.warn("[pop/execute-action] 조회 키 값 없음, 건너뜀", { table: rule.targetTable, itemField });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (valueType === "fixed") {
|
||||
const placeholders = lookupValues.map((_, i) => `$${i + 3}`).join(", ");
|
||||
const sql = `UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" IN (${placeholders})`;
|
||||
await client.query(sql, [fixedValue, companyCode, ...lookupValues]);
|
||||
processedCount += lookupValues.length;
|
||||
} else {
|
||||
for (let i = 0; i < lookupValues.length; i++) {
|
||||
const item = items[i] ?? {};
|
||||
const resolvedValue = resolveStatusValue(valueType, fixedValue, rule.conditionalValue, item);
|
||||
await client.query(
|
||||
`UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" = $3`,
|
||||
[resolvedValue, companyCode, lookupValues[i]]
|
||||
);
|
||||
processedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("[pop/execute-action] 상태 변경 실행", {
|
||||
table: rule.targetTable, column: rule.targetColumn, lookupMode, itemField, pkColumn, count: lookupValues.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("[pop/execute-action] 완료", {
|
||||
action,
|
||||
companyCode,
|
||||
processedCount,
|
||||
insertedCount,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `${processedCount}건 처리 완료${insertedCount > 0 ? `, ${insertedCount}건 생성` : ""}`,
|
||||
data: { processedCount, insertedCount },
|
||||
});
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("[pop/execute-action] 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "처리 중 오류가 발생했습니다.",
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -52,6 +52,8 @@ import {
|
|||
updateZone,
|
||||
deleteZone,
|
||||
addLayerToZone,
|
||||
analyzePopScreenLinks,
|
||||
deployPopScreens,
|
||||
} from "../controllers/screenManagementController";
|
||||
|
||||
const router = express.Router();
|
||||
|
|
@ -147,4 +149,8 @@ router.post("/copy-table-type-columns", copyTableTypeColumns);
|
|||
// 연쇄관계 설정 복제
|
||||
router.post("/copy-cascading-relation", copyCascadingRelation);
|
||||
|
||||
// POP 화면 배포 (다른 회사로 복사)
|
||||
router.get("/screens/:screenId/pop-links", analyzePopScreenLinks);
|
||||
router.post("/deploy-pop-screens", deployPopScreens);
|
||||
|
||||
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();
|
||||
|
|
@ -108,42 +108,49 @@ export class ScreenManagementService {
|
|||
companyCode: string,
|
||||
page: number = 1,
|
||||
size: number = 20,
|
||||
searchTerm?: string, // 검색어 추가
|
||||
searchTerm?: string,
|
||||
options?: { excludePop?: boolean },
|
||||
): Promise<PaginatedResponse<ScreenDefinition>> {
|
||||
const offset = (page - 1) * size;
|
||||
|
||||
// WHERE 절 동적 생성
|
||||
const whereConditions: string[] = ["is_active != 'D'"];
|
||||
const whereConditions: string[] = ["sd.is_active != 'D'"];
|
||||
const params: any[] = [];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
whereConditions.push(`company_code = $${params.length + 1}`);
|
||||
whereConditions.push(`sd.company_code = $${params.length + 1}`);
|
||||
params.push(companyCode);
|
||||
}
|
||||
|
||||
// 검색어 필터링 추가 (화면명, 화면 코드, 테이블명 검색)
|
||||
if (searchTerm && searchTerm.trim() !== "") {
|
||||
whereConditions.push(`(
|
||||
screen_name ILIKE $${params.length + 1} OR
|
||||
screen_code ILIKE $${params.length + 1} OR
|
||||
table_name ILIKE $${params.length + 1}
|
||||
sd.screen_name ILIKE $${params.length + 1} OR
|
||||
sd.screen_code ILIKE $${params.length + 1} OR
|
||||
sd.table_name ILIKE $${params.length + 1}
|
||||
)`);
|
||||
params.push(`%${searchTerm.trim()}%`);
|
||||
}
|
||||
|
||||
// POP 화면 제외 필터: screen_layouts_pop에 레이아웃이 있는 화면 제외
|
||||
if (options?.excludePop) {
|
||||
whereConditions.push(
|
||||
`NOT EXISTS (SELECT 1 FROM screen_layouts_pop slp WHERE slp.screen_id = sd.screen_id)`
|
||||
);
|
||||
}
|
||||
|
||||
const whereSQL = whereConditions.join(" AND ");
|
||||
|
||||
// 페이징 쿼리 (Raw Query)
|
||||
const [screens, totalResult] = await Promise.all([
|
||||
query<any>(
|
||||
`SELECT * FROM screen_definitions
|
||||
`SELECT sd.* FROM screen_definitions sd
|
||||
WHERE ${whereSQL}
|
||||
ORDER BY created_date DESC
|
||||
ORDER BY sd.created_date DESC
|
||||
LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
|
||||
[...params, size, offset],
|
||||
),
|
||||
query<{ count: string }>(
|
||||
`SELECT COUNT(*)::text as count FROM screen_definitions
|
||||
`SELECT COUNT(*)::text as count FROM screen_definitions sd
|
||||
WHERE ${whereSQL}`,
|
||||
params,
|
||||
),
|
||||
|
|
@ -5846,28 +5853,24 @@ export class ScreenManagementService {
|
|||
async getScreenIdsWithPopLayout(
|
||||
companyCode: string,
|
||||
): Promise<number[]> {
|
||||
console.log(`=== POP 레이아웃 존재 화면 ID 조회 ===`);
|
||||
console.log(`회사 코드: ${companyCode}`);
|
||||
|
||||
let result: { screen_id: number }[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 POP 레이아웃 조회
|
||||
result = await query<{ screen_id: number }>(
|
||||
`SELECT DISTINCT screen_id FROM screen_layouts_pop`,
|
||||
[],
|
||||
);
|
||||
} else {
|
||||
// 일반 회사: 해당 회사 또는 공통(*) 레이아웃 조회
|
||||
// 일반 회사: 해당 회사 레이아웃만 조회 (company_code='*'는 최고관리자 전용)
|
||||
result = await query<{ screen_id: number }>(
|
||||
`SELECT DISTINCT screen_id FROM screen_layouts_pop
|
||||
WHERE company_code = $1 OR company_code = '*'`,
|
||||
WHERE company_code = $1`,
|
||||
[companyCode],
|
||||
);
|
||||
}
|
||||
|
||||
const screenIds = result.map((r) => r.screen_id);
|
||||
console.log(`POP 레이아웃 존재 화면 수: ${screenIds.length}개`);
|
||||
logger.info("POP 레이아웃 존재 화면 ID 조회", { companyCode, count: screenIds.length });
|
||||
return screenIds;
|
||||
}
|
||||
|
||||
|
|
@ -5905,6 +5908,512 @@ export class ScreenManagementService {
|
|||
console.log(`POP 레이아웃 삭제 완료`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// POP 화면 배포 (다른 회사로 복사)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* POP layout_data 내 다른 화면 참조를 스캔하여 연결 관계 분석
|
||||
*/
|
||||
async analyzePopScreenLinks(
|
||||
screenId: number,
|
||||
companyCode: string,
|
||||
): Promise<{
|
||||
linkedScreenIds: number[];
|
||||
references: Array<{
|
||||
componentId: string;
|
||||
referenceType: string;
|
||||
targetScreenId: number;
|
||||
}>;
|
||||
}> {
|
||||
const layoutResult = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_pop
|
||||
WHERE screen_id = $1 AND company_code = $2`,
|
||||
[screenId, companyCode],
|
||||
);
|
||||
|
||||
if (!layoutResult?.layout_data) {
|
||||
return { linkedScreenIds: [], references: [] };
|
||||
}
|
||||
|
||||
const layoutData = layoutResult.layout_data;
|
||||
const references: Array<{
|
||||
componentId: string;
|
||||
referenceType: string;
|
||||
targetScreenId: number;
|
||||
}> = [];
|
||||
|
||||
const scanComponents = (components: Record<string, any>) => {
|
||||
for (const [compId, comp] of Object.entries(components)) {
|
||||
const config = (comp as any).config || {};
|
||||
|
||||
if (config.cart?.cartScreenId) {
|
||||
const sid = parseInt(config.cart.cartScreenId);
|
||||
if (!isNaN(sid) && sid !== screenId) {
|
||||
references.push({
|
||||
componentId: compId,
|
||||
referenceType: "cartScreenId",
|
||||
targetScreenId: sid,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (config.cartListMode?.sourceScreenId) {
|
||||
const sid =
|
||||
typeof config.cartListMode.sourceScreenId === "number"
|
||||
? config.cartListMode.sourceScreenId
|
||||
: parseInt(config.cartListMode.sourceScreenId);
|
||||
if (!isNaN(sid) && sid !== screenId) {
|
||||
references.push({
|
||||
componentId: compId,
|
||||
referenceType: "sourceScreenId",
|
||||
targetScreenId: sid,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(config.followUpActions)) {
|
||||
for (const action of config.followUpActions) {
|
||||
if (action.targetScreenId) {
|
||||
const sid = parseInt(action.targetScreenId);
|
||||
if (!isNaN(sid) && sid !== screenId) {
|
||||
references.push({
|
||||
componentId: compId,
|
||||
referenceType: "targetScreenId",
|
||||
targetScreenId: sid,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (config.action?.modalScreenId) {
|
||||
const sid = parseInt(config.action.modalScreenId);
|
||||
if (!isNaN(sid) && sid !== screenId) {
|
||||
references.push({
|
||||
componentId: compId,
|
||||
referenceType: "modalScreenId",
|
||||
targetScreenId: sid,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (layoutData.components) {
|
||||
scanComponents(layoutData.components);
|
||||
}
|
||||
|
||||
if (Array.isArray(layoutData.modals)) {
|
||||
for (const modal of layoutData.modals) {
|
||||
if (modal.components) {
|
||||
scanComponents(modal.components);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const linkedScreenIds = [
|
||||
...new Set(references.map((r) => r.targetScreenId)),
|
||||
];
|
||||
|
||||
return { linkedScreenIds, references };
|
||||
}
|
||||
|
||||
/**
|
||||
* POP 화면 배포 (최고관리자 화면을 특정 회사로 복사)
|
||||
* - screen_definitions + screen_layouts_pop 복사
|
||||
* - 화면 간 참조(cartScreenId, sourceScreenId 등) 자동 치환
|
||||
* - numberingRuleId 초기화
|
||||
*/
|
||||
async deployPopScreens(data: {
|
||||
screens: Array<{
|
||||
sourceScreenId: number;
|
||||
screenName: string;
|
||||
screenCode: string;
|
||||
}>;
|
||||
groupStructure?: {
|
||||
sourceGroupId: number;
|
||||
groupName: string;
|
||||
groupCode: string;
|
||||
children?: Array<{
|
||||
sourceGroupId: number;
|
||||
groupName: string;
|
||||
groupCode: string;
|
||||
screenIds: number[];
|
||||
}>;
|
||||
screenIds: number[];
|
||||
};
|
||||
targetCompanyCode: string;
|
||||
companyCode: string;
|
||||
userId: string;
|
||||
}): Promise<{
|
||||
deployedScreens: Array<{
|
||||
sourceScreenId: number;
|
||||
newScreenId: number;
|
||||
screenName: string;
|
||||
screenCode: string;
|
||||
}>;
|
||||
createdGroups?: number;
|
||||
}> {
|
||||
if (data.companyCode !== "*") {
|
||||
throw new Error("최고 관리자만 POP 화면을 배포할 수 있습니다.");
|
||||
}
|
||||
|
||||
return await transaction(async (client) => {
|
||||
const screenIdMap = new Map<number, number>();
|
||||
const deployedScreens: Array<{
|
||||
sourceScreenId: number;
|
||||
newScreenId: number;
|
||||
screenName: string;
|
||||
screenCode: string;
|
||||
}> = [];
|
||||
|
||||
// 1단계: screen_definitions 복사
|
||||
for (const screen of data.screens) {
|
||||
const sourceResult = await client.query<any>(
|
||||
`SELECT * FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
|
||||
[screen.sourceScreenId],
|
||||
);
|
||||
|
||||
if (sourceResult.rows.length === 0) {
|
||||
throw new Error(
|
||||
`원본 화면(ID: ${screen.sourceScreenId})을 찾을 수 없습니다.`,
|
||||
);
|
||||
}
|
||||
|
||||
const sourceScreen = sourceResult.rows[0];
|
||||
|
||||
const existingResult = await client.query<any>(
|
||||
`SELECT screen_id FROM screen_definitions
|
||||
WHERE screen_code = $1 AND company_code = $2 AND deleted_date IS NULL
|
||||
LIMIT 1`,
|
||||
[screen.screenCode, data.targetCompanyCode],
|
||||
);
|
||||
|
||||
if (existingResult.rows.length > 0) {
|
||||
throw new Error(
|
||||
`화면 코드 "${screen.screenCode}"가 대상 회사에 이미 존재합니다.`,
|
||||
);
|
||||
}
|
||||
|
||||
const newScreenResult = await client.query<any>(
|
||||
`INSERT INTO screen_definitions (
|
||||
screen_code, screen_name, description, company_code, table_name,
|
||||
is_active, created_by, created_date, updated_by, updated_date
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $7, NOW())
|
||||
RETURNING *`,
|
||||
[
|
||||
screen.screenCode,
|
||||
screen.screenName,
|
||||
sourceScreen.description,
|
||||
data.targetCompanyCode,
|
||||
sourceScreen.table_name,
|
||||
"Y",
|
||||
data.userId,
|
||||
],
|
||||
);
|
||||
|
||||
const newScreen = newScreenResult.rows[0];
|
||||
screenIdMap.set(screen.sourceScreenId, newScreen.screen_id);
|
||||
|
||||
deployedScreens.push({
|
||||
sourceScreenId: screen.sourceScreenId,
|
||||
newScreenId: newScreen.screen_id,
|
||||
screenName: screen.screenName,
|
||||
screenCode: screen.screenCode,
|
||||
});
|
||||
|
||||
logger.info("POP 화면 배포 - screen_definitions 생성", {
|
||||
sourceScreenId: screen.sourceScreenId,
|
||||
newScreenId: newScreen.screen_id,
|
||||
targetCompanyCode: data.targetCompanyCode,
|
||||
});
|
||||
}
|
||||
|
||||
// 2단계: screen_layouts_pop 복사 + 참조 치환
|
||||
for (const screen of data.screens) {
|
||||
const newScreenId = screenIdMap.get(screen.sourceScreenId);
|
||||
if (!newScreenId) continue;
|
||||
|
||||
// 원본 POP 레이아웃 조회 (company_code = '*' 우선, fallback)
|
||||
let layoutResult = await client.query<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_pop
|
||||
WHERE screen_id = $1 AND company_code = '*'`,
|
||||
[screen.sourceScreenId],
|
||||
);
|
||||
|
||||
let layoutData = layoutResult.rows[0]?.layout_data;
|
||||
if (!layoutData) {
|
||||
const fallbackResult = await client.query<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_pop
|
||||
WHERE screen_id = $1 LIMIT 1`,
|
||||
[screen.sourceScreenId],
|
||||
);
|
||||
layoutData = fallbackResult.rows[0]?.layout_data;
|
||||
}
|
||||
|
||||
if (!layoutData) {
|
||||
logger.warn("POP 레이아웃 없음, 건너뜀", {
|
||||
sourceScreenId: screen.sourceScreenId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const updatedLayoutData = this.updatePopLayoutScreenReferences(
|
||||
JSON.parse(JSON.stringify(layoutData)),
|
||||
screenIdMap,
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO screen_layouts_pop (screen_id, company_code, layout_data, created_at, updated_at, created_by, updated_by)
|
||||
VALUES ($1, $2, $3, NOW(), NOW(), $4, $4)
|
||||
ON CONFLICT (screen_id, company_code)
|
||||
DO UPDATE SET layout_data = $3, updated_at = NOW(), updated_by = $4`,
|
||||
[
|
||||
newScreenId,
|
||||
data.targetCompanyCode,
|
||||
JSON.stringify(updatedLayoutData),
|
||||
data.userId,
|
||||
],
|
||||
);
|
||||
|
||||
logger.info("POP 레이아웃 복사 완료", {
|
||||
sourceScreenId: screen.sourceScreenId,
|
||||
newScreenId,
|
||||
componentCount: Object.keys(updatedLayoutData.components || {})
|
||||
.length,
|
||||
});
|
||||
}
|
||||
|
||||
// 3단계: 그룹 구조 복사 (groupStructure가 있는 경우)
|
||||
let createdGroups = 0;
|
||||
if (data.groupStructure) {
|
||||
const gs = data.groupStructure;
|
||||
|
||||
// 대상 회사의 POP 루트 그룹 찾기/생성
|
||||
let popRootResult = await client.query<any>(
|
||||
`SELECT id FROM screen_groups
|
||||
WHERE hierarchy_path = 'POP' AND company_code = $1 LIMIT 1`,
|
||||
[data.targetCompanyCode],
|
||||
);
|
||||
|
||||
let popRootId: number;
|
||||
if (popRootResult.rows.length > 0) {
|
||||
popRootId = popRootResult.rows[0].id;
|
||||
} else {
|
||||
const createRootResult = await client.query<any>(
|
||||
`INSERT INTO screen_groups (group_name, group_code, hierarchy_path, company_code, writer, is_active, display_order)
|
||||
VALUES ('POP 화면', 'POP_ROOT', 'POP', $1, $2, 'Y', 0) RETURNING id`,
|
||||
[data.targetCompanyCode, data.userId],
|
||||
);
|
||||
popRootId = createRootResult.rows[0].id;
|
||||
}
|
||||
|
||||
// 메인 그룹 생성 (중복 코드 방지: _COPY 접미사 추가)
|
||||
const mainGroupCode = gs.groupCode + "_COPY";
|
||||
const dupCheck = await client.query<any>(
|
||||
`SELECT id FROM screen_groups WHERE group_code = $1 AND company_code = $2`,
|
||||
[mainGroupCode, data.targetCompanyCode],
|
||||
);
|
||||
|
||||
let mainGroupId: number;
|
||||
if (dupCheck.rows.length > 0) {
|
||||
mainGroupId = dupCheck.rows[0].id;
|
||||
} else {
|
||||
const mainGroupResult = await client.query<any>(
|
||||
`INSERT INTO screen_groups (group_name, group_code, hierarchy_path, company_code, parent_group_id, writer, is_active, display_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'Y', 0) RETURNING id`,
|
||||
[
|
||||
gs.groupName,
|
||||
mainGroupCode,
|
||||
`POP/${mainGroupCode}`,
|
||||
data.targetCompanyCode,
|
||||
popRootId,
|
||||
data.userId,
|
||||
],
|
||||
);
|
||||
mainGroupId = mainGroupResult.rows[0].id;
|
||||
createdGroups++;
|
||||
}
|
||||
|
||||
// 메인 그룹에 화면 연결
|
||||
for (const oldScreenId of gs.screenIds) {
|
||||
const newScreenId = screenIdMap.get(oldScreenId);
|
||||
if (!newScreenId) continue;
|
||||
await client.query(
|
||||
`INSERT INTO screen_group_screens (group_id, screen_id, screen_role, display_order, is_default, company_code)
|
||||
VALUES ($1, $2, 'main', 0, 'N', $3)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[mainGroupId, newScreenId, data.targetCompanyCode],
|
||||
);
|
||||
}
|
||||
|
||||
// 하위 그룹 생성 + 화면 연결
|
||||
if (gs.children) {
|
||||
for (let i = 0; i < gs.children.length; i++) {
|
||||
const child = gs.children[i];
|
||||
const childGroupCode = child.groupCode + "_COPY";
|
||||
|
||||
const childDupCheck = await client.query<any>(
|
||||
`SELECT id FROM screen_groups WHERE group_code = $1 AND company_code = $2`,
|
||||
[childGroupCode, data.targetCompanyCode],
|
||||
);
|
||||
|
||||
let childGroupId: number;
|
||||
if (childDupCheck.rows.length > 0) {
|
||||
childGroupId = childDupCheck.rows[0].id;
|
||||
} else {
|
||||
const childResult = await client.query<any>(
|
||||
`INSERT INTO screen_groups (group_name, group_code, hierarchy_path, company_code, parent_group_id, writer, is_active, display_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'Y', $7) RETURNING id`,
|
||||
[
|
||||
child.groupName,
|
||||
childGroupCode,
|
||||
`POP/${mainGroupCode}/${childGroupCode}`,
|
||||
data.targetCompanyCode,
|
||||
mainGroupId,
|
||||
data.userId,
|
||||
i,
|
||||
],
|
||||
);
|
||||
childGroupId = childResult.rows[0].id;
|
||||
createdGroups++;
|
||||
}
|
||||
|
||||
for (const oldScreenId of child.screenIds) {
|
||||
const newScreenId = screenIdMap.get(oldScreenId);
|
||||
if (!newScreenId) continue;
|
||||
await client.query(
|
||||
`INSERT INTO screen_group_screens (group_id, screen_id, screen_role, display_order, is_default, company_code)
|
||||
VALUES ($1, $2, 'main', 0, 'N', $3)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[childGroupId, newScreenId, data.targetCompanyCode],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("POP 그룹 구조 복사 완료", {
|
||||
targetCompanyCode: data.targetCompanyCode,
|
||||
createdGroups,
|
||||
mainGroupName: gs.groupName,
|
||||
});
|
||||
}
|
||||
|
||||
return { deployedScreens, createdGroups };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POP layout_data 내 screen_id 참조 치환
|
||||
* componentId, connectionId는 레이아웃 내부 식별자이므로 변경 불필요
|
||||
*/
|
||||
private updatePopLayoutScreenReferences(
|
||||
layoutData: any,
|
||||
screenIdMap: Map<number, number>,
|
||||
): any {
|
||||
if (!layoutData?.components) return layoutData;
|
||||
|
||||
const updateComponents = (
|
||||
components: Record<string, any>,
|
||||
): Record<string, any> => {
|
||||
const updated: Record<string, any> = {};
|
||||
|
||||
for (const [compId, comp] of Object.entries(components)) {
|
||||
const updatedComp = JSON.parse(JSON.stringify(comp));
|
||||
const config = updatedComp.config || {};
|
||||
|
||||
// cart.cartScreenId (string)
|
||||
if (config.cart?.cartScreenId) {
|
||||
const oldId = parseInt(config.cart.cartScreenId);
|
||||
const newId = screenIdMap.get(oldId);
|
||||
if (newId) {
|
||||
config.cart.cartScreenId = String(newId);
|
||||
logger.info(`POP 참조 치환: cartScreenId ${oldId} -> ${newId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// cartListMode.sourceScreenId (number)
|
||||
if (config.cartListMode?.sourceScreenId) {
|
||||
const oldId =
|
||||
typeof config.cartListMode.sourceScreenId === "number"
|
||||
? config.cartListMode.sourceScreenId
|
||||
: parseInt(config.cartListMode.sourceScreenId);
|
||||
const newId = screenIdMap.get(oldId);
|
||||
if (newId) {
|
||||
config.cartListMode.sourceScreenId = newId;
|
||||
logger.info(
|
||||
`POP 참조 치환: sourceScreenId ${oldId} -> ${newId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// followUpActions[].targetScreenId (string)
|
||||
if (Array.isArray(config.followUpActions)) {
|
||||
for (const action of config.followUpActions) {
|
||||
if (action.targetScreenId) {
|
||||
const oldId = parseInt(action.targetScreenId);
|
||||
const newId = screenIdMap.get(oldId);
|
||||
if (newId) {
|
||||
action.targetScreenId = String(newId);
|
||||
logger.info(
|
||||
`POP 참조 치환: targetScreenId ${oldId} -> ${newId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// action.modalScreenId (숫자형이면 화면 참조로 간주)
|
||||
if (config.action?.modalScreenId) {
|
||||
const oldId = parseInt(config.action.modalScreenId);
|
||||
if (!isNaN(oldId)) {
|
||||
const newId = screenIdMap.get(oldId);
|
||||
if (newId) {
|
||||
config.action.modalScreenId = String(newId);
|
||||
logger.info(
|
||||
`POP 참조 치환: modalScreenId ${oldId} -> ${newId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// numberingRuleId 초기화 (배포 후 대상 회사에서 재설정 필요)
|
||||
if (config.numberingRuleId) {
|
||||
logger.info(`POP 채번규칙 초기화: ${config.numberingRuleId}`);
|
||||
config.numberingRuleId = "";
|
||||
}
|
||||
if (config.autoGenMappings) {
|
||||
for (const mapping of Object.values(config.autoGenMappings) as any[]) {
|
||||
if (mapping?.numberingRuleId) {
|
||||
logger.info(
|
||||
`POP 채번규칙 초기화: ${mapping.numberingRuleId}`,
|
||||
);
|
||||
mapping.numberingRuleId = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updatedComp.config = config;
|
||||
updated[compId] = updatedComp;
|
||||
}
|
||||
|
||||
return updated;
|
||||
};
|
||||
|
||||
layoutData.components = updateComponents(layoutData.components);
|
||||
|
||||
if (Array.isArray(layoutData.modals)) {
|
||||
for (const modal of layoutData.modals) {
|
||||
if (modal.components) {
|
||||
modal.components = updateComponents(modal.components);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return layoutData;
|
||||
}
|
||||
}
|
||||
|
||||
// 서비스 인스턴스 export
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import {
|
|||
Settings,
|
||||
LayoutGrid,
|
||||
GitBranch,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { PopDesigner } from "@/components/pop/designer";
|
||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
|
|
@ -27,6 +28,7 @@ import {
|
|||
PopScreenPreview,
|
||||
PopScreenFlowView,
|
||||
PopScreenSettingModal,
|
||||
PopDeployModal,
|
||||
} from "@/components/pop/management";
|
||||
import { PopScreenGroup } from "@/lib/api/popScreenGroup";
|
||||
|
||||
|
|
@ -62,6 +64,10 @@ export default function PopScreenManagementPage() {
|
|||
// UI 상태
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [isSettingModalOpen, setIsSettingModalOpen] = useState(false);
|
||||
const [isDeployModalOpen, setIsDeployModalOpen] = useState(false);
|
||||
const [deployGroupScreens, setDeployGroupScreens] = useState<ScreenDefinition[]>([]);
|
||||
const [deployGroupName, setDeployGroupName] = useState("");
|
||||
const [deployGroupInfo, setDeployGroupInfo] = useState<any>(undefined);
|
||||
const [devicePreview, setDevicePreview] = useState<DevicePreview>("tablet");
|
||||
const [rightPanelView, setRightPanelView] = useState<RightPanelView>("preview");
|
||||
|
||||
|
|
@ -242,6 +248,21 @@ export default function PopScreenManagementPage() {
|
|||
<Button variant="outline" size="icon" onClick={loadScreens}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
{selectedScreen && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDeployGroupScreens([]);
|
||||
setDeployGroupName("");
|
||||
setDeployGroupInfo(undefined);
|
||||
setIsDeployModalOpen(true);
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
복사
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => setIsCreateOpen(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
새 POP 화면
|
||||
|
|
@ -271,7 +292,7 @@ export default function PopScreenManagementPage() {
|
|||
) : (
|
||||
<div className="flex-1 overflow-hidden flex">
|
||||
{/* 왼쪽: 카테고리 트리 + 화면 목록 */}
|
||||
<div className="w-[320px] min-w-[280px] max-w-[400px] flex flex-col border-r bg-background">
|
||||
<div className="w-[320px] min-w-[280px] max-w-[400px] flex flex-col border-r bg-background overflow-hidden">
|
||||
{/* 검색 */}
|
||||
<div className="shrink-0 p-3 border-b">
|
||||
<div className="relative">
|
||||
|
|
@ -297,6 +318,24 @@ export default function PopScreenManagementPage() {
|
|||
selectedScreen={selectedScreen}
|
||||
onScreenSelect={handleScreenSelect}
|
||||
onScreenDesign={handleDesignScreen}
|
||||
onScreenSettings={(screen) => {
|
||||
setSelectedScreen(screen);
|
||||
setIsSettingModalOpen(true);
|
||||
}}
|
||||
onScreenCopy={(screen) => {
|
||||
setSelectedScreen(screen);
|
||||
setDeployGroupScreens([]);
|
||||
setDeployGroupName("");
|
||||
setDeployGroupInfo(undefined);
|
||||
setIsDeployModalOpen(true);
|
||||
}}
|
||||
onGroupCopy={(groupScreensList, groupName, gInfo) => {
|
||||
setSelectedScreen(null);
|
||||
setDeployGroupScreens(groupScreensList);
|
||||
setDeployGroupName(groupName);
|
||||
setDeployGroupInfo(gInfo);
|
||||
setIsDeployModalOpen(true);
|
||||
}}
|
||||
onGroupSelect={handleGroupSelect}
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
|
|
@ -390,6 +429,18 @@ export default function PopScreenManagementPage() {
|
|||
}}
|
||||
/>
|
||||
|
||||
{/* POP 화면 배포 모달 */}
|
||||
<PopDeployModal
|
||||
open={isDeployModalOpen}
|
||||
onOpenChange={setIsDeployModalOpen}
|
||||
screen={selectedScreen}
|
||||
groupScreens={deployGroupScreens.length > 0 ? deployGroupScreens : undefined}
|
||||
groupName={deployGroupName || undefined}
|
||||
groupInfo={deployGroupInfo}
|
||||
allScreens={screens}
|
||||
onDeployed={loadScreens}
|
||||
/>
|
||||
|
||||
{/* Scroll to Top 버튼 */}
|
||||
<ScrollToTop />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export default function ScreenManagementPage() {
|
|||
const loadScreens = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await screenApi.getScreens({ page: 1, size: 1000, searchTerm: "" });
|
||||
const result = await screenApi.getScreens({ page: 1, size: 1000, searchTerm: "", excludePop: true });
|
||||
// screenApi.getScreens는 { data: ScreenDefinition[], total, page, size, totalPages } 형태 반환
|
||||
if (result.data && result.data.length > 0) {
|
||||
setScreens(result.data);
|
||||
|
|
|
|||
|
|
@ -286,7 +286,7 @@ function PopScreenViewPage() {
|
|||
)}
|
||||
|
||||
{/* POP 화면 컨텐츠 */}
|
||||
<div className={`flex-1 flex flex-col overflow-auto ${isPreviewMode ? "py-4 items-center" : ""}`}>
|
||||
<div className={`flex-1 flex flex-col overflow-auto ${isPreviewMode ? "py-4 items-center" : "bg-white"}`}>
|
||||
{/* 현재 모드 표시 (일반 모드) */}
|
||||
{!isPreviewMode && (
|
||||
<div className="absolute top-2 right-2 z-10 bg-black/50 text-white text-xs px-2 py-1 rounded">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
import { useDrag } from "react-dnd";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PopComponentType } from "../types/pop-layout";
|
||||
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search } from "lucide-react";
|
||||
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput } from "lucide-react";
|
||||
import { DND_ITEM_TYPES } from "../constants";
|
||||
|
||||
// 컴포넌트 정의
|
||||
|
|
@ -63,6 +63,12 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
|||
icon: Search,
|
||||
description: "조건 입력 (텍스트/날짜/선택/모달)",
|
||||
},
|
||||
{
|
||||
type: "pop-field",
|
||||
label: "입력 필드",
|
||||
icon: TextCursorInput,
|
||||
description: "저장용 값 입력 (섹션별 멀티필드)",
|
||||
},
|
||||
];
|
||||
|
||||
// 드래그 가능한 컴포넌트 아이템
|
||||
|
|
|
|||
|
|
@ -36,6 +36,15 @@ interface ConnectionEditorProps {
|
|||
onRemoveConnection?: (connectionId: string) => void;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 소스 컴포넌트에 filter 타입 sendable이 있는지 판단
|
||||
// ========================================
|
||||
|
||||
function hasFilterSendable(meta: ComponentConnectionMeta | undefined): boolean {
|
||||
if (!meta?.sendable) return false;
|
||||
return meta.sendable.some((s) => s.category === "filter" || s.type === "filter_value");
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// ConnectionEditor
|
||||
// ========================================
|
||||
|
|
@ -75,6 +84,8 @@ export default function ConnectionEditor({
|
|||
);
|
||||
}
|
||||
|
||||
const isFilterSource = hasFilterSendable(meta);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{hasSendable && (
|
||||
|
|
@ -83,6 +94,7 @@ export default function ConnectionEditor({
|
|||
meta={meta!}
|
||||
allComponents={allComponents}
|
||||
outgoing={outgoing}
|
||||
isFilterSource={isFilterSource}
|
||||
onAddConnection={onAddConnection}
|
||||
onUpdateConnection={onUpdateConnection}
|
||||
onRemoveConnection={onRemoveConnection}
|
||||
|
|
@ -92,7 +104,6 @@ export default function ConnectionEditor({
|
|||
{hasReceivable && (
|
||||
<ReceiveSection
|
||||
component={component}
|
||||
meta={meta!}
|
||||
allComponents={allComponents}
|
||||
incoming={incoming}
|
||||
/>
|
||||
|
|
@ -105,7 +116,6 @@ export default function ConnectionEditor({
|
|||
// 대상 컴포넌트에서 정보 추출
|
||||
// ========================================
|
||||
|
||||
/** 화면에 표시 중인 컬럼만 추출 */
|
||||
function extractDisplayColumns(comp: PopComponentDefinitionV5 | undefined): string[] {
|
||||
if (!comp?.config) return [];
|
||||
const cfg = comp.config as Record<string, unknown>;
|
||||
|
|
@ -126,7 +136,6 @@ function extractDisplayColumns(comp: PopComponentDefinitionV5 | undefined): stri
|
|||
return cols;
|
||||
}
|
||||
|
||||
/** 대상 컴포넌트의 데이터소스 테이블명 추출 */
|
||||
function extractTableName(comp: PopComponentDefinitionV5 | undefined): string {
|
||||
if (!comp?.config) return "";
|
||||
const cfg = comp.config as Record<string, unknown>;
|
||||
|
|
@ -143,6 +152,7 @@ interface SendSectionProps {
|
|||
meta: ComponentConnectionMeta;
|
||||
allComponents: PopComponentDefinitionV5[];
|
||||
outgoing: PopDataConnection[];
|
||||
isFilterSource: boolean;
|
||||
onAddConnection?: (conn: Omit<PopDataConnection, "id">) => void;
|
||||
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
|
||||
onRemoveConnection?: (connectionId: string) => void;
|
||||
|
|
@ -153,6 +163,7 @@ function SendSection({
|
|||
meta,
|
||||
allComponents,
|
||||
outgoing,
|
||||
isFilterSource,
|
||||
onAddConnection,
|
||||
onUpdateConnection,
|
||||
onRemoveConnection,
|
||||
|
|
@ -163,29 +174,42 @@ function SendSection({
|
|||
<div className="space-y-3">
|
||||
<Label className="flex items-center gap-1 text-xs font-medium">
|
||||
<ArrowRight className="h-3 w-3 text-blue-500" />
|
||||
이때 (보내기)
|
||||
보내기
|
||||
</Label>
|
||||
|
||||
{/* 기존 연결 목록 */}
|
||||
{outgoing.map((conn) => (
|
||||
<div key={conn.id}>
|
||||
{editingId === conn.id ? (
|
||||
<ConnectionForm
|
||||
component={component}
|
||||
meta={meta}
|
||||
allComponents={allComponents}
|
||||
initial={conn}
|
||||
onSubmit={(data) => {
|
||||
onUpdateConnection?.(conn.id, data);
|
||||
setEditingId(null);
|
||||
}}
|
||||
onCancel={() => setEditingId(null)}
|
||||
submitLabel="수정"
|
||||
/>
|
||||
isFilterSource ? (
|
||||
<FilterConnectionForm
|
||||
component={component}
|
||||
meta={meta}
|
||||
allComponents={allComponents}
|
||||
initial={conn}
|
||||
onSubmit={(data) => {
|
||||
onUpdateConnection?.(conn.id, data);
|
||||
setEditingId(null);
|
||||
}}
|
||||
onCancel={() => setEditingId(null)}
|
||||
submitLabel="수정"
|
||||
/>
|
||||
) : (
|
||||
<SimpleConnectionForm
|
||||
component={component}
|
||||
allComponents={allComponents}
|
||||
initial={conn}
|
||||
onSubmit={(data) => {
|
||||
onUpdateConnection?.(conn.id, data);
|
||||
setEditingId(null);
|
||||
}}
|
||||
onCancel={() => setEditingId(null)}
|
||||
submitLabel="수정"
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="flex items-center gap-1 rounded border bg-blue-50/50 px-3 py-2">
|
||||
<span className="flex-1 truncate text-xs">
|
||||
{conn.label || `${conn.sourceOutput} -> ${conn.targetInput}`}
|
||||
{conn.label || `→ ${allComponents.find((c) => c.id === conn.targetComponent)?.label || conn.targetComponent}`}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setEditingId(conn.id)}
|
||||
|
|
@ -206,23 +230,131 @@ function SendSection({
|
|||
</div>
|
||||
))}
|
||||
|
||||
{/* 새 연결 추가 */}
|
||||
<ConnectionForm
|
||||
component={component}
|
||||
meta={meta}
|
||||
allComponents={allComponents}
|
||||
onSubmit={(data) => onAddConnection?.(data)}
|
||||
submitLabel="연결 추가"
|
||||
/>
|
||||
{isFilterSource ? (
|
||||
<FilterConnectionForm
|
||||
component={component}
|
||||
meta={meta}
|
||||
allComponents={allComponents}
|
||||
onSubmit={(data) => onAddConnection?.(data)}
|
||||
submitLabel="연결 추가"
|
||||
/>
|
||||
) : (
|
||||
<SimpleConnectionForm
|
||||
component={component}
|
||||
allComponents={allComponents}
|
||||
onSubmit={(data) => onAddConnection?.(data)}
|
||||
submitLabel="연결 추가"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 연결 폼 (추가/수정 공용)
|
||||
// 단순 연결 폼 (이벤트 타입: "어디로" 1개만)
|
||||
// ========================================
|
||||
|
||||
interface ConnectionFormProps {
|
||||
interface SimpleConnectionFormProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
allComponents: PopComponentDefinitionV5[];
|
||||
initial?: PopDataConnection;
|
||||
onSubmit: (data: Omit<PopDataConnection, "id">) => void;
|
||||
onCancel?: () => void;
|
||||
submitLabel: string;
|
||||
}
|
||||
|
||||
function SimpleConnectionForm({
|
||||
component,
|
||||
allComponents,
|
||||
initial,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitLabel,
|
||||
}: SimpleConnectionFormProps) {
|
||||
const [selectedTargetId, setSelectedTargetId] = React.useState(
|
||||
initial?.targetComponent || ""
|
||||
);
|
||||
|
||||
const targetCandidates = allComponents.filter((c) => {
|
||||
if (c.id === component.id) return false;
|
||||
const reg = PopComponentRegistry.getComponent(c.type);
|
||||
return reg?.connectionMeta?.receivable && reg.connectionMeta.receivable.length > 0;
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!selectedTargetId) return;
|
||||
|
||||
const targetComp = allComponents.find((c) => c.id === selectedTargetId);
|
||||
const srcLabel = component.label || component.id;
|
||||
const tgtLabel = targetComp?.label || targetComp?.id || "?";
|
||||
|
||||
onSubmit({
|
||||
sourceComponent: component.id,
|
||||
sourceField: "",
|
||||
sourceOutput: "_auto",
|
||||
targetComponent: selectedTargetId,
|
||||
targetField: "",
|
||||
targetInput: "_auto",
|
||||
label: `${srcLabel} → ${tgtLabel}`,
|
||||
});
|
||||
|
||||
if (!initial) {
|
||||
setSelectedTargetId("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded border border-dashed p-3">
|
||||
{onCancel && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[10px] font-medium text-muted-foreground">연결 수정</p>
|
||||
<button onClick={onCancel} className="text-muted-foreground hover:text-foreground">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!onCancel && (
|
||||
<p className="text-[10px] font-medium text-muted-foreground">새 연결 추가</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-muted-foreground">어디로?</span>
|
||||
<Select
|
||||
value={selectedTargetId}
|
||||
onValueChange={setSelectedTargetId}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="컴포넌트 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetCandidates.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id} className="text-xs">
|
||||
{c.label || c.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 w-full text-xs"
|
||||
disabled={!selectedTargetId}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{!initial && <Plus className="mr-1 h-3 w-3" />}
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 필터 연결 폼 (검색 컴포넌트용: 기존 UI 유지)
|
||||
// ========================================
|
||||
|
||||
interface FilterConnectionFormProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
meta: ComponentConnectionMeta;
|
||||
allComponents: PopComponentDefinitionV5[];
|
||||
|
|
@ -232,7 +364,7 @@ interface ConnectionFormProps {
|
|||
submitLabel: string;
|
||||
}
|
||||
|
||||
function ConnectionForm({
|
||||
function FilterConnectionForm({
|
||||
component,
|
||||
meta,
|
||||
allComponents,
|
||||
|
|
@ -240,7 +372,7 @@ function ConnectionForm({
|
|||
onSubmit,
|
||||
onCancel,
|
||||
submitLabel,
|
||||
}: ConnectionFormProps) {
|
||||
}: FilterConnectionFormProps) {
|
||||
const [selectedOutput, setSelectedOutput] = React.useState(
|
||||
initial?.sourceOutput || meta.sendable[0]?.key || ""
|
||||
);
|
||||
|
|
@ -272,32 +404,26 @@ function ConnectionForm({
|
|||
? PopComponentRegistry.getComponent(targetComp.type)?.connectionMeta
|
||||
: null;
|
||||
|
||||
// 보내는 값 + 받는 컴포넌트가 결정되면 받는 방식 자동 매칭
|
||||
React.useEffect(() => {
|
||||
if (!selectedOutput || !targetMeta?.receivable?.length) return;
|
||||
// 이미 선택된 값이 있으면 건드리지 않음
|
||||
if (selectedTargetInput) return;
|
||||
|
||||
const receivables = targetMeta.receivable;
|
||||
// 1) 같은 key가 있으면 자동 매칭
|
||||
const exactMatch = receivables.find((r) => r.key === selectedOutput);
|
||||
if (exactMatch) {
|
||||
setSelectedTargetInput(exactMatch.key);
|
||||
return;
|
||||
}
|
||||
// 2) receivable이 1개뿐이면 자동 선택
|
||||
if (receivables.length === 1) {
|
||||
setSelectedTargetInput(receivables[0].key);
|
||||
}
|
||||
}, [selectedOutput, targetMeta, selectedTargetInput]);
|
||||
|
||||
// 화면에 표시 중인 컬럼
|
||||
const displayColumns = React.useMemo(
|
||||
() => extractDisplayColumns(targetComp || undefined),
|
||||
[targetComp]
|
||||
);
|
||||
|
||||
// DB 테이블 전체 컬럼 (비동기 조회)
|
||||
const tableName = React.useMemo(
|
||||
() => extractTableName(targetComp || undefined),
|
||||
[targetComp]
|
||||
|
|
@ -324,7 +450,6 @@ function ConnectionForm({
|
|||
return () => { cancelled = true; };
|
||||
}, [tableName]);
|
||||
|
||||
// 표시 컬럼과 데이터 전용 컬럼 분리
|
||||
const displaySet = React.useMemo(() => new Set(displayColumns), [displayColumns]);
|
||||
const dataOnlyColumns = React.useMemo(
|
||||
() => allDbColumns.filter((c) => !displaySet.has(c)),
|
||||
|
|
@ -388,7 +513,6 @@ function ConnectionForm({
|
|||
<p className="text-[10px] font-medium text-muted-foreground">새 연결 추가</p>
|
||||
)}
|
||||
|
||||
{/* 보내는 값 */}
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-muted-foreground">보내는 값</span>
|
||||
<Select value={selectedOutput} onValueChange={setSelectedOutput}>
|
||||
|
|
@ -405,7 +529,6 @@ function ConnectionForm({
|
|||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 받는 컴포넌트 */}
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-muted-foreground">받는 컴포넌트</span>
|
||||
<Select
|
||||
|
|
@ -429,7 +552,6 @@ function ConnectionForm({
|
|||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 받는 방식 */}
|
||||
{targetMeta && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-muted-foreground">받는 방식</span>
|
||||
|
|
@ -448,7 +570,6 @@ function ConnectionForm({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 필터 설정: event 타입 연결이면 숨김 */}
|
||||
{selectedTargetInput && !isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput) && (
|
||||
<div className="space-y-2 rounded bg-gray-50 p-2">
|
||||
<p className="text-[10px] font-medium text-muted-foreground">필터할 컬럼</p>
|
||||
|
|
@ -460,7 +581,6 @@ function ConnectionForm({
|
|||
</div>
|
||||
) : hasAnyColumns ? (
|
||||
<div className="space-y-2">
|
||||
{/* 표시 컬럼 그룹 */}
|
||||
{displayColumns.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[9px] font-medium text-green-600">화면 표시 컬럼</p>
|
||||
|
|
@ -482,7 +602,6 @@ function ConnectionForm({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 데이터 전용 컬럼 그룹 */}
|
||||
{dataOnlyColumns.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{displayColumns.length > 0 && (
|
||||
|
|
@ -522,7 +641,6 @@ function ConnectionForm({
|
|||
</p>
|
||||
)}
|
||||
|
||||
{/* 필터 방식 */}
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] text-muted-foreground">필터 방식</p>
|
||||
<Select value={filterMode} onValueChange={(v: any) => setFilterMode(v)}>
|
||||
|
|
@ -540,7 +658,6 @@ function ConnectionForm({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 제출 버튼 */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
|
@ -556,19 +673,17 @@ function ConnectionForm({
|
|||
}
|
||||
|
||||
// ========================================
|
||||
// 받기 섹션 (읽기 전용)
|
||||
// 받기 섹션 (읽기 전용: 연결된 소스만 표시)
|
||||
// ========================================
|
||||
|
||||
interface ReceiveSectionProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
meta: ComponentConnectionMeta;
|
||||
allComponents: PopComponentDefinitionV5[];
|
||||
incoming: PopDataConnection[];
|
||||
}
|
||||
|
||||
function ReceiveSection({
|
||||
component,
|
||||
meta,
|
||||
allComponents,
|
||||
incoming,
|
||||
}: ReceiveSectionProps) {
|
||||
|
|
@ -576,28 +691,11 @@ function ReceiveSection({
|
|||
<div className="space-y-3">
|
||||
<Label className="flex items-center gap-1 text-xs font-medium">
|
||||
<Unlink2 className="h-3 w-3 text-green-500" />
|
||||
이렇게 (받기)
|
||||
받기
|
||||
</Label>
|
||||
|
||||
<div className="space-y-1">
|
||||
{meta.receivable.map((r) => (
|
||||
<div
|
||||
key={r.key}
|
||||
className="rounded bg-green-50/50 px-3 py-2 text-xs text-gray-600"
|
||||
>
|
||||
<span className="font-medium">{r.label}</span>
|
||||
{r.description && (
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground">
|
||||
{r.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{incoming.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] text-muted-foreground">연결된 소스</p>
|
||||
<div className="space-y-1">
|
||||
{incoming.map((conn) => {
|
||||
const sourceComp = allComponents.find(
|
||||
(c) => c.id === conn.sourceComponent
|
||||
|
|
@ -605,9 +703,9 @@ function ReceiveSection({
|
|||
return (
|
||||
<div
|
||||
key={conn.id}
|
||||
className="flex items-center gap-2 rounded border bg-gray-50 px-3 py-2 text-xs"
|
||||
className="flex items-center gap-2 rounded border bg-green-50/50 px-3 py-2 text-xs"
|
||||
>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||
<ArrowRight className="h-3 w-3 text-green-500" />
|
||||
<span className="truncate">
|
||||
{sourceComp?.label || conn.sourceComponent}
|
||||
</span>
|
||||
|
|
@ -617,7 +715,7 @@ function ReceiveSection({
|
|||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
아직 연결된 소스가 없습니다. 보내는 컴포넌트에서 연결을 설정하세요.
|
||||
연결된 소스가 없습니다
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -651,5 +749,5 @@ function buildConnectionLabel(
|
|||
const colInfo = columns && columns.length > 0
|
||||
? ` [${columns.join(", ")}]`
|
||||
: "";
|
||||
return `${srcLabel} -> ${tgtLabel}${colInfo}`;
|
||||
return `${srcLabel} → ${tgtLabel}${colInfo}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
|||
"pop-button": "버튼",
|
||||
"pop-string-list": "리스트 목록",
|
||||
"pop-search": "검색",
|
||||
"pop-field": "입력",
|
||||
};
|
||||
|
||||
// ========================================
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
/**
|
||||
* POP 컴포넌트 타입
|
||||
*/
|
||||
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-button" | "pop-string-list" | "pop-search";
|
||||
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-button" | "pop-string-list" | "pop-search" | "pop-field";
|
||||
|
||||
/**
|
||||
* 데이터 흐름 정의
|
||||
|
|
@ -361,6 +361,7 @@ export const DEFAULT_COMPONENT_GRID_SIZE: Record<PopComponentType, { colSpan: nu
|
|||
"pop-button": { colSpan: 2, rowSpan: 1 },
|
||||
"pop-string-list": { colSpan: 4, rowSpan: 3 },
|
||||
"pop-search": { colSpan: 4, rowSpan: 2 },
|
||||
"pop-field": { colSpan: 6, rowSpan: 2 },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ import {
|
|||
ArrowUp,
|
||||
ArrowDown,
|
||||
Search,
|
||||
Settings,
|
||||
Copy,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -68,11 +70,27 @@ import {
|
|||
// 타입 정의
|
||||
// ============================================================
|
||||
|
||||
export interface GroupCopyInfo {
|
||||
sourceGroupId: number;
|
||||
groupName: string;
|
||||
groupCode: string;
|
||||
screenIds: number[];
|
||||
children: Array<{
|
||||
sourceGroupId: number;
|
||||
groupName: string;
|
||||
groupCode: string;
|
||||
screenIds: number[];
|
||||
}>;
|
||||
}
|
||||
|
||||
interface PopCategoryTreeProps {
|
||||
screens: ScreenDefinition[]; // POP 레이아웃이 있는 화면 목록
|
||||
selectedScreen: ScreenDefinition | null;
|
||||
onScreenSelect: (screen: ScreenDefinition) => void;
|
||||
onScreenDesign: (screen: ScreenDefinition) => void;
|
||||
onScreenSettings?: (screen: ScreenDefinition) => void;
|
||||
onScreenCopy?: (screen: ScreenDefinition) => void;
|
||||
onGroupCopy?: (screens: ScreenDefinition[], groupName: string, groupInfo?: GroupCopyInfo) => void;
|
||||
onGroupSelect?: (group: PopScreenGroup | null) => void;
|
||||
searchTerm?: string;
|
||||
}
|
||||
|
|
@ -87,6 +105,8 @@ interface TreeNodeProps {
|
|||
onGroupSelect: (group: PopScreenGroup) => void;
|
||||
onScreenSelect: (screen: ScreenDefinition) => void;
|
||||
onScreenDesign: (screen: ScreenDefinition) => void;
|
||||
onScreenSettings: (screen: ScreenDefinition) => void;
|
||||
onScreenCopy: (screen: ScreenDefinition) => void;
|
||||
onEditGroup: (group: PopScreenGroup) => void;
|
||||
onDeleteGroup: (group: PopScreenGroup) => void;
|
||||
onAddSubGroup: (parentGroup: PopScreenGroup) => void;
|
||||
|
|
@ -101,6 +121,7 @@ interface TreeNodeProps {
|
|||
onMoveScreenUp: (screen: ScreenDefinition, groupId: number) => void;
|
||||
onMoveScreenDown: (screen: ScreenDefinition, groupId: number) => void;
|
||||
onDeleteScreen: (screen: ScreenDefinition) => void;
|
||||
onGroupCopy: (screens: ScreenDefinition[], groupName: string, groupInfo?: GroupCopyInfo) => void;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
|
@ -118,6 +139,7 @@ function TreeNode({
|
|||
onMoveScreenUp,
|
||||
onMoveScreenDown,
|
||||
onDeleteScreen,
|
||||
onGroupCopy,
|
||||
expandedGroups,
|
||||
onToggle,
|
||||
selectedGroupId,
|
||||
|
|
@ -125,6 +147,8 @@ function TreeNode({
|
|||
onGroupSelect,
|
||||
onScreenSelect,
|
||||
onScreenDesign,
|
||||
onScreenSettings,
|
||||
onScreenCopy,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
onAddSubGroup,
|
||||
|
|
@ -134,7 +158,7 @@ function TreeNode({
|
|||
const hasChildren = (group.children && group.children.length > 0) || (group.screens && group.screens.length > 0);
|
||||
const isSelected = selectedGroupId === group.id;
|
||||
|
||||
// 그룹에 연결된 화면 목록
|
||||
// 그룹에 직접 연결된 화면 목록
|
||||
const groupScreens = useMemo(() => {
|
||||
if (!group.screens) return [];
|
||||
return group.screens
|
||||
|
|
@ -142,6 +166,20 @@ function TreeNode({
|
|||
.filter((s): s is ScreenDefinition => s !== undefined);
|
||||
}, [group.screens, screensMap]);
|
||||
|
||||
// 하위 그룹 포함 전체 화면 (복사용)
|
||||
const allDescendantScreens = useMemo(() => {
|
||||
const collected = new Map<number, ScreenDefinition>();
|
||||
const collectRecursive = (g: PopScreenGroup) => {
|
||||
g.screens?.forEach((gs) => {
|
||||
const screen = screensMap.get(gs.screen_id);
|
||||
if (screen) collected.set(screen.screenId, screen);
|
||||
});
|
||||
g.children?.forEach(collectRecursive);
|
||||
};
|
||||
collectRecursive(group);
|
||||
return Array.from(collected.values());
|
||||
}, [group, screensMap]);
|
||||
|
||||
// 루트 레벨(POP 화면)인지 확인
|
||||
const isRootLevel = level === 0;
|
||||
|
||||
|
|
@ -193,8 +231,15 @@ function TreeNode({
|
|||
<Folder className={cn("h-4 w-4 shrink-0", isRootLevel ? "text-orange-500" : "text-amber-500")} />
|
||||
)}
|
||||
|
||||
{/* 그룹명 - 루트는 볼드체 */}
|
||||
<span className={cn("flex-1 text-sm truncate", isRootLevel && "font-semibold")}>{group.group_name}</span>
|
||||
{/* 그룹명 - 루트는 볼드체 + 회사코드 표시 */}
|
||||
<span className={cn("flex-1 text-sm truncate", isRootLevel && "font-semibold")}>
|
||||
{group.group_name}
|
||||
{isRootLevel && group.company_code && (
|
||||
<span className="ml-1 text-[10px] text-muted-foreground font-normal">
|
||||
{group.company_code === "*" ? "(전체)" : `(${group.company_code})`}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* 화면 수 배지 */}
|
||||
{group.screen_count && group.screen_count > 0 && (
|
||||
|
|
@ -224,6 +269,34 @@ function TreeNode({
|
|||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</DropdownMenuItem>
|
||||
{allDescendantScreens.length > 0 && (
|
||||
<DropdownMenuItem onClick={() => {
|
||||
const buildGroupInfo = (g: PopScreenGroup): GroupCopyInfo => {
|
||||
const directScreenIds = (g.screens || [])
|
||||
.map((gs) => gs.screen_id)
|
||||
.filter((id) => screensMap.has(id));
|
||||
const children = (g.children || []).map((child) => ({
|
||||
sourceGroupId: child.id,
|
||||
groupName: child.group_name,
|
||||
groupCode: child.group_code,
|
||||
screenIds: (child.screens || [])
|
||||
.map((gs) => gs.screen_id)
|
||||
.filter((id) => screensMap.has(id)),
|
||||
}));
|
||||
return {
|
||||
sourceGroupId: g.id,
|
||||
groupName: g.group_name,
|
||||
groupCode: g.group_code,
|
||||
screenIds: directScreenIds,
|
||||
children,
|
||||
};
|
||||
};
|
||||
onGroupCopy(allDescendantScreens, group.group_name, buildGroupInfo(group));
|
||||
}}>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
카테고리 복사 ({allDescendantScreens.length}개 화면)
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onMoveGroupUp(group)}
|
||||
|
|
@ -267,6 +340,8 @@ function TreeNode({
|
|||
onGroupSelect={onGroupSelect}
|
||||
onScreenSelect={onScreenSelect}
|
||||
onScreenDesign={onScreenDesign}
|
||||
onScreenSettings={onScreenSettings}
|
||||
onScreenCopy={onScreenCopy}
|
||||
onEditGroup={onEditGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
onAddSubGroup={onAddSubGroup}
|
||||
|
|
@ -279,6 +354,7 @@ function TreeNode({
|
|||
onMoveScreenUp={onMoveScreenUp}
|
||||
onMoveScreenDown={onMoveScreenDown}
|
||||
onDeleteScreen={onDeleteScreen}
|
||||
onGroupCopy={onGroupCopy}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
|
@ -324,6 +400,14 @@ function TreeNode({
|
|||
<Edit className="h-4 w-4 mr-2" />
|
||||
설계
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onScreenSettings(screen)}>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
설정 (이름 변경)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onScreenCopy(screen)}>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
복사
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onMoveScreenUp(screen, group.id)}
|
||||
|
|
@ -378,6 +462,9 @@ export function PopCategoryTree({
|
|||
selectedScreen,
|
||||
onScreenSelect,
|
||||
onScreenDesign,
|
||||
onScreenSettings,
|
||||
onScreenCopy,
|
||||
onGroupCopy,
|
||||
onGroupSelect,
|
||||
searchTerm = "",
|
||||
}: PopCategoryTreeProps) {
|
||||
|
|
@ -412,6 +499,9 @@ export function PopCategoryTree({
|
|||
const [movingFromGroupId, setMovingFromGroupId] = useState<number | null>(null);
|
||||
const [moveSearchTerm, setMoveSearchTerm] = useState("");
|
||||
|
||||
// 미분류 회사코드별 접기/펼치기
|
||||
const [expandedCompanyCodes, setExpandedCompanyCodes] = useState<Set<string>>(new Set());
|
||||
|
||||
// 화면 맵 생성 (screen_id로 빠르게 조회)
|
||||
const screensMap = useMemo(() => {
|
||||
const map = new Map<number, ScreenDefinition>();
|
||||
|
|
@ -430,14 +520,6 @@ export function PopCategoryTree({
|
|||
// 그룹 목록 조회
|
||||
const data = await getPopScreenGroups(searchTerm);
|
||||
setGroups(data);
|
||||
|
||||
// 첫 로드 시 루트 그룹들 자동 확장
|
||||
if (expandedGroups.size === 0 && data.length > 0) {
|
||||
const rootIds = data
|
||||
.filter((g) => g.hierarchy_path === "POP" || g.hierarchy_path?.split("/").length === 2)
|
||||
.map((g) => g.id);
|
||||
setExpandedGroups(new Set(rootIds));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("POP 그룹 로드 실패:", error);
|
||||
toast.error("그룹 목록 로드에 실패했습니다.");
|
||||
|
|
@ -847,7 +929,7 @@ export function PopCategoryTree({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* 헤더 */}
|
||||
<div className="shrink-0 p-3 border-b flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">POP 카테고리</h3>
|
||||
|
|
@ -862,7 +944,7 @@ export function PopCategoryTree({
|
|||
</div>
|
||||
|
||||
{/* 트리 영역 */}
|
||||
<ScrollArea className="flex-1">
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="p-2">
|
||||
{treeData.length === 0 && ungroupedScreens.length === 0 ? (
|
||||
<div className="text-center text-sm text-muted-foreground py-8">
|
||||
|
|
@ -887,6 +969,8 @@ export function PopCategoryTree({
|
|||
onGroupSelect={handleGroupSelect}
|
||||
onScreenSelect={onScreenSelect}
|
||||
onScreenDesign={onScreenDesign}
|
||||
onScreenSettings={onScreenSettings || (() => {})}
|
||||
onScreenCopy={onScreenCopy || (() => {})}
|
||||
onEditGroup={(g) => openGroupModal(undefined, g)}
|
||||
onDeleteGroup={(g) => {
|
||||
setDeletingGroup(g);
|
||||
|
|
@ -902,66 +986,122 @@ export function PopCategoryTree({
|
|||
onMoveScreenUp={handleMoveScreenUp}
|
||||
onMoveScreenDown={handleMoveScreenDown}
|
||||
onDeleteScreen={handleDeleteScreen}
|
||||
onGroupCopy={onGroupCopy || (() => {})}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 미분류 화면 */}
|
||||
{/* 미분류 화면 - 회사코드별 그룹핑 */}
|
||||
{ungroupedScreens.length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<div className="text-xs text-muted-foreground px-2 mb-2">
|
||||
미분류 ({ungroupedScreens.length})
|
||||
</div>
|
||||
{ungroupedScreens.map((screen) => (
|
||||
<div
|
||||
key={`ungrouped-${screen.screenId}`}
|
||||
className={cn(
|
||||
"flex items-center gap-2 py-1.5 px-2 rounded-md cursor-pointer transition-colors",
|
||||
selectedScreen?.screenId === screen.screenId
|
||||
? "bg-primary/10 text-primary"
|
||||
: "hover:bg-muted",
|
||||
"group"
|
||||
)}
|
||||
onClick={() => onScreenSelect(screen)}
|
||||
onDoubleClick={() => onScreenDesign(screen)}
|
||||
>
|
||||
<Monitor className="h-4 w-4 text-gray-400 shrink-0" />
|
||||
<span className="flex-1 text-sm truncate">{screen.screenName}</span>
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">#{screen.screenId}</span>
|
||||
|
||||
{/* 더보기 메뉴 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-0 group-hover:opacity-100 shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
{(() => {
|
||||
const grouped = ungroupedScreens.reduce<Record<string, typeof ungroupedScreens>>((acc, screen) => {
|
||||
const code = screen.companyCode || "unknown";
|
||||
if (!acc[code]) acc[code] = [];
|
||||
acc[code].push(screen);
|
||||
return acc;
|
||||
}, {});
|
||||
const companyKeys = Object.keys(grouped).sort();
|
||||
|
||||
const toggleCompanyCode = (code: string) => {
|
||||
setExpandedCompanyCodes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(code)) {
|
||||
next.delete(code);
|
||||
} else {
|
||||
next.add(code);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return companyKeys.map((companyCode) => {
|
||||
const isExpanded = expandedCompanyCodes.has(companyCode);
|
||||
const label = companyCode === "*" ? "최고관리자" : companyCode;
|
||||
|
||||
return (
|
||||
<div key={`ungrouped-company-${companyCode}`}>
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-1 mt-1 bg-muted/50 rounded cursor-pointer select-none hover:bg-muted transition-colors"
|
||||
onClick={() => toggleCompanyCode(companyCode)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className="text-[10px] font-medium text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<Badge variant="outline" className="ml-auto h-4 text-[9px] px-1">
|
||||
{grouped[companyCode].length}
|
||||
</Badge>
|
||||
</div>
|
||||
{isExpanded && grouped[companyCode].map((screen) => (
|
||||
<div
|
||||
key={`ungrouped-${screen.screenId}`}
|
||||
className={cn(
|
||||
"flex items-center gap-2 py-1.5 px-2 rounded-md cursor-pointer transition-colors",
|
||||
selectedScreen?.screenId === screen.screenId
|
||||
? "bg-primary/10 text-primary"
|
||||
: "hover:bg-muted",
|
||||
"group",
|
||||
"pl-4"
|
||||
)}
|
||||
onClick={() => onScreenSelect(screen)}
|
||||
onDoubleClick={() => onScreenDesign(screen)}
|
||||
>
|
||||
<MoreVertical className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem onClick={() => onScreenDesign(screen)}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
설계
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => openMoveModal(screen, null)}>
|
||||
<MoveRight className="h-4 w-4 mr-2" />
|
||||
카테고리로 이동
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => handleDeleteScreen(screen)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
화면 삭제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
<Monitor className="h-4 w-4 text-gray-400 shrink-0" />
|
||||
<span className="flex-1 text-sm truncate">{screen.screenName}</span>
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">#{screen.screenId}</span>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-0 group-hover:opacity-100 shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreVertical className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem onClick={() => onScreenDesign(screen)}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
설계
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onScreenSettings?.(screen)}>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
설정 (이름 변경)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onScreenCopy?.(screen)}>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
복사
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => openMoveModal(screen, null)}>
|
||||
<MoveRight className="h-4 w-4 mr-2" />
|
||||
카테고리로 이동
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => handleDeleteScreen(screen)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
화면 삭제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,560 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Loader2, Link2, Monitor, Folder, ChevronRight } from "lucide-react";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { GroupCopyInfo } from "./PopCategoryTree";
|
||||
import { getCompanyList } from "@/lib/api/company";
|
||||
import { ScreenDefinition } from "@/types/screen";
|
||||
import { Company } from "@/types/company";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface LinkedScreenInfo {
|
||||
screenId: number;
|
||||
screenName: string;
|
||||
screenCode: string;
|
||||
references: Array<{
|
||||
componentId: string;
|
||||
referenceType: string;
|
||||
}>;
|
||||
deploy: boolean;
|
||||
newScreenName: string;
|
||||
newScreenCode: string;
|
||||
}
|
||||
|
||||
interface ScreenEntry {
|
||||
screenId: number;
|
||||
screenName: string;
|
||||
newScreenName: string;
|
||||
newScreenCode: string;
|
||||
included: boolean;
|
||||
}
|
||||
|
||||
interface PopDeployModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
screen: ScreenDefinition | null;
|
||||
groupScreens?: ScreenDefinition[];
|
||||
groupName?: string;
|
||||
groupInfo?: GroupCopyInfo;
|
||||
allScreens: ScreenDefinition[];
|
||||
onDeployed?: () => void;
|
||||
}
|
||||
|
||||
export function PopDeployModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
screen,
|
||||
groupScreens,
|
||||
groupName,
|
||||
groupInfo,
|
||||
allScreens,
|
||||
onDeployed,
|
||||
}: PopDeployModalProps) {
|
||||
const isGroupMode = !!(groupScreens && groupScreens.length > 0);
|
||||
|
||||
const [companies, setCompanies] = useState<Company[]>([]);
|
||||
const [targetCompanyCode, setTargetCompanyCode] = useState("");
|
||||
|
||||
// 단일 화면 모드
|
||||
const [screenName, setScreenName] = useState("");
|
||||
const [screenCode, setScreenCode] = useState("");
|
||||
const [linkedScreens, setLinkedScreens] = useState<LinkedScreenInfo[]>([]);
|
||||
|
||||
// 그룹 모드
|
||||
const [groupEntries, setGroupEntries] = useState<ScreenEntry[]>([]);
|
||||
|
||||
const [analyzing, setAnalyzing] = useState(false);
|
||||
const [deploying, setDeploying] = useState(false);
|
||||
|
||||
// 회사 목록 로드
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
getCompanyList({ status: "active" })
|
||||
.then((list) => {
|
||||
setCompanies(list.filter((c) => c.company_code !== "*"));
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// 모달 열릴 때 초기화
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
setTargetCompanyCode("");
|
||||
setLinkedScreens([]);
|
||||
|
||||
if (isGroupMode && groupScreens) {
|
||||
setGroupEntries(
|
||||
groupScreens.map((s) => ({
|
||||
screenId: s.screenId,
|
||||
screenName: s.screenName,
|
||||
newScreenName: s.screenName,
|
||||
newScreenCode: "",
|
||||
included: true,
|
||||
})),
|
||||
);
|
||||
setScreenName("");
|
||||
setScreenCode("");
|
||||
} else if (screen) {
|
||||
setScreenName(screen.screenName);
|
||||
setScreenCode("");
|
||||
setGroupEntries([]);
|
||||
analyzeLinks(screen.screenId);
|
||||
}
|
||||
}, [open, screen, groupScreens, isGroupMode]);
|
||||
|
||||
// 회사 선택 시 화면 코드 자동 생성
|
||||
useEffect(() => {
|
||||
if (!targetCompanyCode) return;
|
||||
|
||||
if (isGroupMode) {
|
||||
const count = groupEntries.filter((e) => e.included).length;
|
||||
if (count > 0) {
|
||||
screenApi
|
||||
.generateMultipleScreenCodes(targetCompanyCode, count)
|
||||
.then((codes) => {
|
||||
let codeIdx = 0;
|
||||
setGroupEntries((prev) =>
|
||||
prev.map((e) =>
|
||||
e.included
|
||||
? { ...e, newScreenCode: codes[codeIdx++] || "" }
|
||||
: e,
|
||||
),
|
||||
);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
} else {
|
||||
const count = 1 + linkedScreens.filter((ls) => ls.deploy).length;
|
||||
screenApi
|
||||
.generateMultipleScreenCodes(targetCompanyCode, count)
|
||||
.then((codes) => {
|
||||
setScreenCode(codes[0] || "");
|
||||
setLinkedScreens((prev) =>
|
||||
prev.map((ls, idx) => ({
|
||||
...ls,
|
||||
newScreenCode: codes[idx + 1] || "",
|
||||
})),
|
||||
);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
}, [targetCompanyCode]);
|
||||
|
||||
const analyzeLinks = async (screenId: number) => {
|
||||
setAnalyzing(true);
|
||||
try {
|
||||
const result = await screenApi.analyzePopScreenLinks(screenId);
|
||||
const linked: LinkedScreenInfo[] = result.linkedScreenIds.map(
|
||||
(linkedId) => {
|
||||
const linkedScreen = allScreens.find(
|
||||
(s) => s.screenId === linkedId,
|
||||
);
|
||||
const refs = result.references.filter(
|
||||
(r) => r.targetScreenId === linkedId,
|
||||
);
|
||||
return {
|
||||
screenId: linkedId,
|
||||
screenName: linkedScreen?.screenName || `화면 ${linkedId}`,
|
||||
screenCode: linkedScreen?.screenCode || "",
|
||||
references: refs.map((r) => ({
|
||||
componentId: r.componentId,
|
||||
referenceType: r.referenceType,
|
||||
})),
|
||||
deploy: true,
|
||||
newScreenName: linkedScreen?.screenName || `화면 ${linkedId}`,
|
||||
newScreenCode: "",
|
||||
};
|
||||
},
|
||||
);
|
||||
setLinkedScreens(linked);
|
||||
} catch (error) {
|
||||
console.error("연결 분석 실패:", error);
|
||||
} finally {
|
||||
setAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeploy = async () => {
|
||||
if (!targetCompanyCode) return;
|
||||
|
||||
setDeploying(true);
|
||||
try {
|
||||
let screensToSend: Array<{
|
||||
sourceScreenId: number;
|
||||
screenName: string;
|
||||
screenCode: string;
|
||||
}>;
|
||||
|
||||
if (isGroupMode) {
|
||||
screensToSend = groupEntries
|
||||
.filter((e) => e.included && e.newScreenCode)
|
||||
.map((e) => ({
|
||||
sourceScreenId: e.screenId,
|
||||
screenName: e.newScreenName,
|
||||
screenCode: e.newScreenCode,
|
||||
}));
|
||||
} else {
|
||||
if (!screen || !screenName || !screenCode) return;
|
||||
screensToSend = [
|
||||
{
|
||||
sourceScreenId: screen.screenId,
|
||||
screenName,
|
||||
screenCode,
|
||||
},
|
||||
...linkedScreens
|
||||
.filter((ls) => ls.deploy)
|
||||
.map((ls) => ({
|
||||
sourceScreenId: ls.screenId,
|
||||
screenName: ls.newScreenName,
|
||||
screenCode: ls.newScreenCode,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
if (screensToSend.length === 0) {
|
||||
toast.error("복사할 화면이 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const deployPayload: Parameters<typeof screenApi.deployPopScreens>[0] = {
|
||||
screens: screensToSend,
|
||||
targetCompanyCode,
|
||||
};
|
||||
|
||||
if (isGroupMode && groupInfo) {
|
||||
deployPayload.groupStructure = groupInfo;
|
||||
}
|
||||
|
||||
const result = await screenApi.deployPopScreens(deployPayload);
|
||||
|
||||
const groupMsg = result.createdGroups
|
||||
? ` (카테고리 ${result.createdGroups}개 생성)`
|
||||
: "";
|
||||
toast.success(
|
||||
`POP 화면 ${result.deployedScreens.length}개가 복사되었습니다.${groupMsg}`,
|
||||
);
|
||||
onOpenChange(false);
|
||||
onDeployed?.();
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.message || "복사에 실패했습니다.");
|
||||
} finally {
|
||||
setDeploying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const totalCount = isGroupMode
|
||||
? groupEntries.filter((e) => e.included).length
|
||||
: 1 + linkedScreens.filter((ls) => ls.deploy).length;
|
||||
|
||||
const canDeploy = isGroupMode
|
||||
? !deploying && targetCompanyCode && groupEntries.some((e) => e.included)
|
||||
: !deploying && targetCompanyCode && screenName && screenCode;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
POP 화면 복사
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
{isGroupMode
|
||||
? `"${groupName}" 카테고리의 화면 ${groupScreens!.length}개를 다른 회사로 복사합니다.`
|
||||
: screen
|
||||
? `"${screen.screenName}" (ID: ${screen.screenId}) 화면을 다른 회사로 복사합니다.`
|
||||
: "화면을 선택해주세요."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{/* 대상 회사 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">
|
||||
대상 회사 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={targetCompanyCode}
|
||||
onValueChange={setTargetCompanyCode}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="회사를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{companies.map((c) => (
|
||||
<SelectItem
|
||||
key={c.company_code}
|
||||
value={c.company_code}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
{c.company_name} ({c.company_code})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* ===== 그룹 모드: 카테고리 구조 + 화면 목록 ===== */}
|
||||
{isGroupMode ? (
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">
|
||||
복사 구조 ({groupEntries.filter((e) => e.included).length}개
|
||||
화면
|
||||
{groupInfo
|
||||
? ` + ${1 + (groupInfo.children?.length || 0)}개 카테고리`
|
||||
: ""}
|
||||
)
|
||||
</Label>
|
||||
<div className="mt-1 max-h-[280px] overflow-y-auto rounded-md border p-2">
|
||||
{groupInfo ? (
|
||||
<div className="space-y-0.5">
|
||||
{/* 메인 카테고리 */}
|
||||
<div className="flex items-center gap-1.5 rounded bg-muted/50 p-1.5 text-xs font-medium">
|
||||
<Folder className="h-3.5 w-3.5 shrink-0 text-amber-500" />
|
||||
<span>{groupInfo.groupName}</span>
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">
|
||||
새 카테고리 생성
|
||||
</span>
|
||||
</div>
|
||||
{/* 메인 카테고리의 직접 화면 */}
|
||||
{groupEntries
|
||||
.filter((e) => groupInfo.screenIds.includes(e.screenId))
|
||||
.map((entry) => (
|
||||
<div
|
||||
key={entry.screenId}
|
||||
className="flex items-center gap-2 rounded p-1.5 pl-6 text-xs hover:bg-muted/50"
|
||||
>
|
||||
<Checkbox
|
||||
checked={entry.included}
|
||||
onCheckedChange={(checked) => {
|
||||
setGroupEntries((prev) =>
|
||||
prev.map((e) =>
|
||||
e.screenId === entry.screenId
|
||||
? { ...e, included: !!checked }
|
||||
: e,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Monitor className="h-3.5 w-3.5 shrink-0 text-blue-500" />
|
||||
<span className="flex-1 truncate">
|
||||
{entry.screenName}
|
||||
</span>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
#{entry.screenId}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{/* 하위 카테고리들 */}
|
||||
{groupInfo.children?.map((child) => (
|
||||
<div key={child.sourceGroupId}>
|
||||
<div className="mt-1 flex items-center gap-1.5 rounded bg-muted/30 p-1.5 pl-4 text-xs font-medium">
|
||||
<Folder className="h-3.5 w-3.5 shrink-0 text-amber-400" />
|
||||
<span>{child.groupName}</span>
|
||||
</div>
|
||||
{groupEntries
|
||||
.filter((e) =>
|
||||
child.screenIds.includes(e.screenId),
|
||||
)
|
||||
.map((entry) => (
|
||||
<div
|
||||
key={entry.screenId}
|
||||
className="flex items-center gap-2 rounded p-1.5 pl-10 text-xs hover:bg-muted/50"
|
||||
>
|
||||
<Checkbox
|
||||
checked={entry.included}
|
||||
onCheckedChange={(checked) => {
|
||||
setGroupEntries((prev) =>
|
||||
prev.map((e) =>
|
||||
e.screenId === entry.screenId
|
||||
? { ...e, included: !!checked }
|
||||
: e,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Monitor className="h-3.5 w-3.5 shrink-0 text-blue-500" />
|
||||
<span className="flex-1 truncate">
|
||||
{entry.screenName}
|
||||
</span>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
#{entry.screenId}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{groupEntries.map((entry) => (
|
||||
<div
|
||||
key={entry.screenId}
|
||||
className="flex items-center gap-2 rounded p-1.5 text-xs hover:bg-muted/50"
|
||||
>
|
||||
<Checkbox
|
||||
checked={entry.included}
|
||||
onCheckedChange={(checked) => {
|
||||
setGroupEntries((prev) =>
|
||||
prev.map((e) =>
|
||||
e.screenId === entry.screenId
|
||||
? { ...e, included: !!checked }
|
||||
: e,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Monitor className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 truncate">
|
||||
{entry.screenName}
|
||||
</span>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
#{entry.screenId}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
||||
카테고리 구조와 화면 간 연결(cartScreenId 등)이 자동으로
|
||||
복사됩니다.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* ===== 단일 모드: 화면명 + 코드 ===== */}
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">
|
||||
새 화면명 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
value={screenName}
|
||||
onChange={(e) => setScreenName(e.target.value)}
|
||||
placeholder="화면 이름"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">
|
||||
화면 코드 (자동생성)
|
||||
</Label>
|
||||
<Input
|
||||
className="mt-1 h-8 bg-muted text-xs sm:h-10 sm:text-sm"
|
||||
value={screenCode}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 연결 화면 감지 */}
|
||||
{analyzing ? (
|
||||
<div className="flex items-center gap-2 rounded-md border p-3 text-xs text-muted-foreground sm:text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
연결된 화면을 분석 중입니다...
|
||||
</div>
|
||||
) : linkedScreens.length > 0 ? (
|
||||
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 dark:border-amber-700 dark:bg-amber-950/30">
|
||||
<div className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-amber-800 dark:text-amber-300 sm:text-sm">
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
연결된 POP 화면 {linkedScreens.length}개 감지됨
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{linkedScreens.map((ls) => (
|
||||
<div
|
||||
key={ls.screenId}
|
||||
className="flex items-center justify-between rounded bg-background p-2 text-xs"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{ls.screenName}</div>
|
||||
<div className="text-muted-foreground">
|
||||
ID: {ls.screenId} |{" "}
|
||||
{ls.references
|
||||
.map((r) => r.referenceType)
|
||||
.join(", ")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={ls.deploy}
|
||||
onCheckedChange={(checked) => {
|
||||
setLinkedScreens((prev) =>
|
||||
prev.map((item) =>
|
||||
item.screenId === ls.screenId
|
||||
? { ...item, deploy: !!checked }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs">함께 복사</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-[10px] text-amber-700 dark:text-amber-400 sm:text-xs">
|
||||
함께 복사하면 화면 간 연결(cartScreenId 등)이 새 ID로 자동
|
||||
치환됩니다.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
!analyzing && (
|
||||
<div className="rounded-md border bg-muted/30 p-3 text-xs text-muted-foreground sm:text-sm">
|
||||
연결된 POP 화면이 없습니다. 이 화면만 복사됩니다.
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
disabled={deploying}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDeploy}
|
||||
disabled={!canDeploy}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{deploying ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
복사 중...
|
||||
</>
|
||||
) : (
|
||||
`${totalCount}개 화면 복사`
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -166,19 +166,26 @@ export function PopScreenSettingModal({
|
|||
try {
|
||||
setSaving(true);
|
||||
|
||||
// 화면 기본 정보 업데이트
|
||||
const screenUpdate: Partial<ScreenDefinition> = {
|
||||
screenName,
|
||||
description: screenDescription,
|
||||
};
|
||||
|
||||
// screen_definitions 테이블에 화면명/설명 업데이트
|
||||
if (screenName !== screen.screenName || screenDescription !== (screen.description || "")) {
|
||||
await screenApi.updateScreenInfo(screen.screenId, {
|
||||
screenName,
|
||||
description: screenDescription,
|
||||
isActive: "Y",
|
||||
});
|
||||
}
|
||||
|
||||
// 레이아웃에 하위 화면 정보 저장
|
||||
const currentLayout = await screenApi.getLayoutPop(screen.screenId);
|
||||
const updatedLayout = {
|
||||
...currentLayout,
|
||||
version: "pop-1.0",
|
||||
subScreens: subScreens,
|
||||
// flow 배열 자동 생성 (메인 → 각 서브)
|
||||
flow: subScreens.map((sub) => ({
|
||||
from: sub.triggerFrom || "main",
|
||||
to: sub.id,
|
||||
|
|
@ -202,11 +209,11 @@ export function PopScreenSettingModal({
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] flex flex-col p-0">
|
||||
<DialogHeader className="p-4 pb-0 shrink-0">
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px] max-h-[90vh] flex flex-col p-0">
|
||||
<DialogHeader className="px-6 pt-6 pb-4 shrink-0">
|
||||
<DialogTitle className="text-base sm:text-lg">POP 화면 설정</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
{screen.screenName} ({screen.screenCode})
|
||||
{screen.screenName} [{screen.screenCode}]
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -215,57 +222,57 @@ export function PopScreenSettingModal({
|
|||
onValueChange={setActiveTab}
|
||||
className="flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
<TabsList className="shrink-0 mx-4 justify-start border-b rounded-none bg-transparent h-auto p-0">
|
||||
<TabsList className="shrink-0 mx-6 justify-start border-b rounded-none bg-transparent h-auto p-0">
|
||||
<TabsTrigger
|
||||
value="overview"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-3 py-2 text-xs sm:text-sm"
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
개요
|
||||
<FileText className="h-3.5 w-3.5 mr-1.5" />
|
||||
기본 정보
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="subscreens"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-3 py-2 text-xs sm:text-sm"
|
||||
>
|
||||
<Layers className="h-4 w-4 mr-2" />
|
||||
<Layers className="h-3.5 w-3.5 mr-1.5" />
|
||||
하위 화면
|
||||
{subScreens.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-2 text-xs">
|
||||
<Badge variant="secondary" className="ml-1.5 text-[10px] h-4 px-1.5">
|
||||
{subScreens.length}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="flow"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-3 py-2 text-xs sm:text-sm"
|
||||
>
|
||||
<GitBranch className="h-4 w-4 mr-2" />
|
||||
<GitBranch className="h-3.5 w-3.5 mr-1.5" />
|
||||
화면 흐름
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 개요 탭 */}
|
||||
<TabsContent value="overview" className="flex-1 m-0 p-4 overflow-auto">
|
||||
{/* 기본 정보 탭 */}
|
||||
<TabsContent value="overview" className="flex-1 m-0 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 max-w-[500px]">
|
||||
<div>
|
||||
<div className="space-y-4 p-6">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="screenName" className="text-xs sm:text-sm">
|
||||
화면명 *
|
||||
화면명 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="screenName"
|
||||
value={screenName}
|
||||
onChange={(e) => setScreenName(e.target.value)}
|
||||
placeholder="화면 이름"
|
||||
placeholder="화면 이름을 입력하세요"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="category" className="text-xs sm:text-sm">
|
||||
카테고리
|
||||
</Label>
|
||||
|
|
@ -283,7 +290,7 @@ export function PopScreenSettingModal({
|
|||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="description" className="text-xs sm:text-sm">
|
||||
설명
|
||||
</Label>
|
||||
|
|
@ -291,13 +298,13 @@ export function PopScreenSettingModal({
|
|||
id="description"
|
||||
value={screenDescription}
|
||||
onChange={(e) => setScreenDescription(e.target.value)}
|
||||
placeholder="화면에 대한 설명"
|
||||
placeholder="화면에 대한 설명을 입력하세요"
|
||||
rows={3}
|
||||
className="text-xs sm:text-sm resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="icon" className="text-xs sm:text-sm">
|
||||
아이콘
|
||||
</Label>
|
||||
|
|
@ -308,7 +315,7 @@ export function PopScreenSettingModal({
|
|||
placeholder="lucide 아이콘 이름 (예: Package)"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||
lucide-react 아이콘 이름을 입력하세요.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -317,19 +324,19 @@ export function PopScreenSettingModal({
|
|||
</TabsContent>
|
||||
|
||||
{/* 하위 화면 탭 */}
|
||||
<TabsContent value="subscreens" className="flex-1 m-0 p-4 overflow-auto">
|
||||
<div className="space-y-4">
|
||||
<TabsContent value="subscreens" className="flex-1 m-0 overflow-auto">
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
이 화면에서 열리는 모달, 드로어 등의 하위 화면을 관리합니다.
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
모달, 드로어 등 하위 화면을 관리합니다.
|
||||
</p>
|
||||
<Button size="sm" onClick={addSubScreen}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
<Button size="sm" className="h-8 text-xs" onClick={addSubScreen}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[300px]">
|
||||
<ScrollArea className="h-[280px]">
|
||||
{subScreens.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
<Layers className="h-8 w-8 mx-auto mb-3 opacity-50" />
|
||||
|
|
@ -340,12 +347,12 @@ export function PopScreenSettingModal({
|
|||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{subScreens.map((subScreen, index) => (
|
||||
{subScreens.map((subScreen) => (
|
||||
<div
|
||||
key={subScreen.id}
|
||||
className="flex items-start gap-3 p-3 border rounded-lg bg-muted/30"
|
||||
className="flex items-start gap-2 p-3 border rounded-lg bg-muted/30"
|
||||
>
|
||||
<GripVertical className="h-5 w-5 text-muted-foreground shrink-0 mt-1 cursor-grab" />
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground shrink-0 mt-1.5 cursor-grab" />
|
||||
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -363,7 +370,7 @@ export function PopScreenSettingModal({
|
|||
updateSubScreen(subScreen.id, "type", v)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs w-[100px]">
|
||||
<SelectTrigger className="h-8 text-xs w-[90px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -375,7 +382,7 @@ export function PopScreenSettingModal({
|
|||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||
트리거:
|
||||
</span>
|
||||
<Select
|
||||
|
|
@ -404,10 +411,10 @@ export function PopScreenSettingModal({
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeSubScreen(subScreen.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -424,11 +431,19 @@ export function PopScreenSettingModal({
|
|||
</Tabs>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="shrink-0 p-4 border-t flex items-center justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
<div className="shrink-0 px-6 py-4 border-t flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
*/
|
||||
|
||||
export { PopCategoryTree } from "./PopCategoryTree";
|
||||
export type { GroupCopyInfo } from "./PopCategoryTree";
|
||||
export { PopScreenPreview } from "./PopScreenPreview";
|
||||
export { PopScreenFlowView } from "./PopScreenFlowView";
|
||||
export { PopScreenSettingModal } from "./PopScreenSettingModal";
|
||||
export { PopDeployModal } from "./PopDeployModal";
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -61,6 +62,7 @@ export default function PopViewerWithModals({
|
|||
overrideGap,
|
||||
overridePadding,
|
||||
}: PopViewerWithModalsProps) {
|
||||
const router = useRouter();
|
||||
const [modalStack, setModalStack] = useState<OpenModal[]>([]);
|
||||
const { subscribe, publish } = usePopEvent(screenId);
|
||||
|
||||
|
|
@ -69,9 +71,21 @@ export default function PopViewerWithModals({
|
|||
() => layout.dataFlow?.connections ?? [],
|
||||
[layout.dataFlow?.connections]
|
||||
);
|
||||
|
||||
const componentTypes = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
if (layout.components) {
|
||||
for (const comp of Object.values(layout.components)) {
|
||||
map.set(comp.id, comp.type);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [layout.components]);
|
||||
|
||||
useConnectionResolver({
|
||||
screenId,
|
||||
connections: stableConnections,
|
||||
componentTypes,
|
||||
});
|
||||
|
||||
// 모달 열기/닫기 이벤트 구독
|
||||
|
|
@ -114,11 +128,30 @@ export default function PopViewerWithModals({
|
|||
});
|
||||
});
|
||||
|
||||
const unsubNavigate = subscribe("__pop_navigate__", (payload: unknown) => {
|
||||
const data = payload as {
|
||||
screenId?: string;
|
||||
params?: Record<string, string>;
|
||||
};
|
||||
|
||||
if (!data?.screenId) return;
|
||||
|
||||
if (data.screenId === "back") {
|
||||
router.back();
|
||||
} else {
|
||||
const query = data.params
|
||||
? "?" + new URLSearchParams(data.params).toString()
|
||||
: "";
|
||||
window.location.href = `/pop/screens/${data.screenId}${query}`;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubOpen();
|
||||
unsubClose();
|
||||
unsubNavigate();
|
||||
};
|
||||
}, [subscribe, publish, layout.modals]);
|
||||
}, [subscribe, publish, layout.modals, router]);
|
||||
|
||||
// 최상위 모달만 닫기 (X 버튼, overlay 클릭, ESC)
|
||||
const handleCloseTopModal = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -1021,7 +1021,7 @@ export function ScreenGroupTreeView({
|
|||
const loadGroupsData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await getScreenGroups({ size: 1000 }); // 모든 그룹 가져오기
|
||||
const response = await getScreenGroups({ size: 1000, excludePop: true });
|
||||
if (response.success && response.data) {
|
||||
setGroups(response.data);
|
||||
|
||||
|
|
|
|||
|
|
@ -224,7 +224,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
const loadGroups = async () => {
|
||||
try {
|
||||
setLoadingGroups(true);
|
||||
const response = await getScreenGroups();
|
||||
const response = await getScreenGroups({ excludePop: true });
|
||||
if (response.success && response.data) {
|
||||
setGroups(response.data);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -99,10 +99,8 @@ function dbRowToCartItem(dbRow: Record<string, unknown>): CartItemWithId {
|
|||
function cartItemToDbRecord(
|
||||
item: CartItemWithId,
|
||||
screenId: string,
|
||||
cartType: string = "pop",
|
||||
selectedColumns?: string[],
|
||||
): Record<string, unknown> {
|
||||
// selectedColumns가 있으면 해당 컬럼만 추출, 없으면 전체 저장
|
||||
const rowData =
|
||||
selectedColumns && selectedColumns.length > 0
|
||||
? Object.fromEntries(
|
||||
|
|
@ -111,7 +109,7 @@ function cartItemToDbRecord(
|
|||
: item.row;
|
||||
|
||||
return {
|
||||
cart_type: cartType,
|
||||
cart_type: "pop",
|
||||
screen_id: screenId,
|
||||
source_table: item.sourceTable,
|
||||
row_key: item.rowKey,
|
||||
|
|
@ -144,7 +142,6 @@ function areItemsEqual(a: CartItemWithId[], b: CartItemWithId[]): boolean {
|
|||
export function useCartSync(
|
||||
screenId: string,
|
||||
sourceTable: string,
|
||||
cartType?: string,
|
||||
): UseCartSyncReturn {
|
||||
const [cartItems, setCartItems] = useState<CartItemWithId[]>([]);
|
||||
const [savedItems, setSavedItems] = useState<CartItemWithId[]>([]);
|
||||
|
|
@ -153,21 +150,19 @@ export function useCartSync(
|
|||
|
||||
const screenIdRef = useRef(screenId);
|
||||
const sourceTableRef = useRef(sourceTable);
|
||||
const cartTypeRef = useRef(cartType || "pop");
|
||||
screenIdRef.current = screenId;
|
||||
sourceTableRef.current = sourceTable;
|
||||
cartTypeRef.current = cartType || "pop";
|
||||
|
||||
// ----- DB에서 장바구니 로드 -----
|
||||
const loadFromDb = useCallback(async () => {
|
||||
if (!screenId) return;
|
||||
if (!screenId || !sourceTable) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await dataApi.getTableData("cart_items", {
|
||||
size: 500,
|
||||
filters: {
|
||||
screen_id: screenId,
|
||||
cart_type: cartTypeRef.current,
|
||||
cart_type: "pop",
|
||||
status: "in_cart",
|
||||
},
|
||||
});
|
||||
|
|
@ -181,7 +176,7 @@ export function useCartSync(
|
|||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [screenId]);
|
||||
}, [screenId, sourceTable]);
|
||||
|
||||
// 마운트 시 자동 로드
|
||||
useEffect(() => {
|
||||
|
|
@ -286,18 +281,16 @@ export function useCartSync(
|
|||
const promises: Promise<unknown>[] = [];
|
||||
|
||||
for (const item of toDelete) {
|
||||
promises.push(dataApi.deleteRecord("cart_items", item.cartId!));
|
||||
promises.push(dataApi.updateRecord("cart_items", item.cartId!, { status: "cancelled" }));
|
||||
}
|
||||
|
||||
const currentCartType = cartTypeRef.current;
|
||||
|
||||
for (const item of toCreate) {
|
||||
const record = cartItemToDbRecord(item, currentScreenId, currentCartType, selectedColumns);
|
||||
const record = cartItemToDbRecord(item, currentScreenId, selectedColumns);
|
||||
promises.push(dataApi.createRecord("cart_items", record));
|
||||
}
|
||||
|
||||
for (const item of toUpdate) {
|
||||
const record = cartItemToDbRecord(item, currentScreenId, currentCartType, selectedColumns);
|
||||
const record = cartItemToDbRecord(item, currentScreenId, selectedColumns);
|
||||
promises.push(dataApi.updateRecord("cart_items", item.cartId!, record));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,47 +8,119 @@
|
|||
* 이벤트 규칙:
|
||||
* 소스: __comp_output__${sourceComponentId}__${outputKey}
|
||||
* 타겟: __comp_input__${targetComponentId}__${inputKey}
|
||||
*
|
||||
* _auto 모드:
|
||||
* sourceOutput="_auto"인 연결은 소스/타겟의 connectionMeta를 비교하여
|
||||
* key가 같고 category="event"인 쌍을 양방향으로 자동 라우팅한다.
|
||||
* (정방향: 소스->타겟, 역방향: 타겟->소스)
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { usePopEvent } from "./usePopEvent";
|
||||
import type { PopDataConnection } from "@/components/pop/designer/types/pop-layout";
|
||||
import {
|
||||
PopComponentRegistry,
|
||||
type ConnectionMetaItem,
|
||||
} from "@/lib/registry/PopComponentRegistry";
|
||||
|
||||
interface UseConnectionResolverOptions {
|
||||
screenId: string;
|
||||
connections: PopDataConnection[];
|
||||
componentTypes?: Map<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 소스/타겟의 connectionMeta에서 자동 매칭 가능한 이벤트 쌍을 찾는다.
|
||||
* 규칙: category="event"이고 key가 동일한 쌍
|
||||
*/
|
||||
function getAutoMatchPairs(
|
||||
sourceType: string,
|
||||
targetType: string
|
||||
): { sourceKey: string; targetKey: string }[] {
|
||||
const sourceDef = PopComponentRegistry.getComponent(sourceType);
|
||||
const targetDef = PopComponentRegistry.getComponent(targetType);
|
||||
|
||||
if (!sourceDef?.connectionMeta?.sendable || !targetDef?.connectionMeta?.receivable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const pairs: { sourceKey: string; targetKey: string }[] = [];
|
||||
|
||||
for (const s of sourceDef.connectionMeta.sendable) {
|
||||
if (s.category !== "event") continue;
|
||||
for (const r of targetDef.connectionMeta.receivable) {
|
||||
if (r.category !== "event") continue;
|
||||
if (s.key === r.key) {
|
||||
pairs.push({ sourceKey: s.key, targetKey: r.key });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pairs;
|
||||
}
|
||||
|
||||
export function useConnectionResolver({
|
||||
screenId,
|
||||
connections,
|
||||
componentTypes,
|
||||
}: UseConnectionResolverOptions): void {
|
||||
const { publish, subscribe } = usePopEvent(screenId);
|
||||
|
||||
// 연결 목록을 ref로 저장하여 콜백 안정성 확보
|
||||
const connectionsRef = useRef(connections);
|
||||
connectionsRef.current = connections;
|
||||
|
||||
const componentTypesRef = useRef(componentTypes);
|
||||
componentTypesRef.current = componentTypes;
|
||||
|
||||
useEffect(() => {
|
||||
if (!connections || connections.length === 0) return;
|
||||
|
||||
const unsubscribers: (() => void)[] = [];
|
||||
|
||||
// 소스별로 그룹핑하여 구독 생성
|
||||
const sourceGroups = new Map<string, PopDataConnection[]>();
|
||||
for (const conn of connections) {
|
||||
const sourceEvent = `__comp_output__${conn.sourceComponent}__${conn.sourceOutput || conn.sourceField}`;
|
||||
const existing = sourceGroups.get(sourceEvent) || [];
|
||||
existing.push(conn);
|
||||
sourceGroups.set(sourceEvent, existing);
|
||||
}
|
||||
const isAutoMode = conn.sourceOutput === "_auto" || !conn.sourceOutput;
|
||||
|
||||
for (const [sourceEvent, conns] of sourceGroups) {
|
||||
const unsub = subscribe(sourceEvent, (payload: unknown) => {
|
||||
for (const conn of conns) {
|
||||
if (isAutoMode && componentTypesRef.current) {
|
||||
const sourceType = componentTypesRef.current.get(conn.sourceComponent);
|
||||
const targetType = componentTypesRef.current.get(conn.targetComponent);
|
||||
|
||||
if (!sourceType || !targetType) continue;
|
||||
|
||||
// 정방향: 소스 sendable -> 타겟 receivable
|
||||
const forwardPairs = getAutoMatchPairs(sourceType, targetType);
|
||||
for (const pair of forwardPairs) {
|
||||
const sourceEvent = `__comp_output__${conn.sourceComponent}__${pair.sourceKey}`;
|
||||
const targetEvent = `__comp_input__${conn.targetComponent}__${pair.targetKey}`;
|
||||
|
||||
const unsub = subscribe(sourceEvent, (payload: unknown) => {
|
||||
publish(targetEvent, {
|
||||
value: payload,
|
||||
_connectionId: conn.id,
|
||||
});
|
||||
});
|
||||
unsubscribers.push(unsub);
|
||||
}
|
||||
|
||||
// 역방향: 타겟 sendable -> 소스 receivable
|
||||
const reversePairs = getAutoMatchPairs(targetType, sourceType);
|
||||
for (const pair of reversePairs) {
|
||||
const sourceEvent = `__comp_output__${conn.targetComponent}__${pair.sourceKey}`;
|
||||
const targetEvent = `__comp_input__${conn.sourceComponent}__${pair.targetKey}`;
|
||||
|
||||
const unsub = subscribe(sourceEvent, (payload: unknown) => {
|
||||
publish(targetEvent, {
|
||||
value: payload,
|
||||
_connectionId: conn.id,
|
||||
});
|
||||
});
|
||||
unsubscribers.push(unsub);
|
||||
}
|
||||
} else {
|
||||
const sourceEvent = `__comp_output__${conn.sourceComponent}__${conn.sourceOutput || conn.sourceField}`;
|
||||
|
||||
const unsub = subscribe(sourceEvent, (payload: unknown) => {
|
||||
const targetEvent = `__comp_input__${conn.targetComponent}__${conn.targetInput || conn.targetField}`;
|
||||
|
||||
// 항상 통일된 구조로 감싸서 전달: { value, filterConfig?, _connectionId }
|
||||
const enrichedPayload = {
|
||||
value: payload,
|
||||
filterConfig: conn.filterConfig,
|
||||
|
|
@ -56,9 +128,9 @@ export function useConnectionResolver({
|
|||
};
|
||||
|
||||
publish(targetEvent, enrichedPayload);
|
||||
}
|
||||
});
|
||||
unsubscribers.push(unsub);
|
||||
});
|
||||
unsubscribers.push(unsub);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
@ -16,6 +16,7 @@ export const screenApi = {
|
|||
size?: number;
|
||||
companyCode?: string;
|
||||
searchTerm?: string;
|
||||
excludePop?: boolean;
|
||||
}): Promise<PaginatedResponse<ScreenDefinition>> => {
|
||||
const response = await apiClient.get("/screen-management/screens", { params });
|
||||
const raw = response.data || {};
|
||||
|
|
@ -347,6 +348,59 @@ export const screenApi = {
|
|||
return response.data.data;
|
||||
},
|
||||
|
||||
// POP 화면 연결 분석 (다른 화면과의 참조 관계)
|
||||
analyzePopScreenLinks: async (
|
||||
screenId: number,
|
||||
): Promise<{
|
||||
linkedScreenIds: number[];
|
||||
references: Array<{
|
||||
componentId: string;
|
||||
referenceType: string;
|
||||
targetScreenId: number;
|
||||
}>;
|
||||
}> => {
|
||||
const response = await apiClient.get(
|
||||
`/screen-management/screens/${screenId}/pop-links`,
|
||||
);
|
||||
return response.data.data || { linkedScreenIds: [], references: [] };
|
||||
},
|
||||
|
||||
// POP 화면 배포 (다른 회사로 복사)
|
||||
deployPopScreens: async (data: {
|
||||
screens: Array<{
|
||||
sourceScreenId: number;
|
||||
screenName: string;
|
||||
screenCode: string;
|
||||
}>;
|
||||
targetCompanyCode: string;
|
||||
groupStructure?: {
|
||||
sourceGroupId: number;
|
||||
groupName: string;
|
||||
groupCode: string;
|
||||
screenIds: number[];
|
||||
children?: Array<{
|
||||
sourceGroupId: number;
|
||||
groupName: string;
|
||||
groupCode: string;
|
||||
screenIds: number[];
|
||||
}>;
|
||||
};
|
||||
}): Promise<{
|
||||
deployedScreens: Array<{
|
||||
sourceScreenId: number;
|
||||
newScreenId: number;
|
||||
screenName: string;
|
||||
screenCode: string;
|
||||
}>;
|
||||
createdGroups?: number;
|
||||
}> => {
|
||||
const response = await apiClient.post(
|
||||
`/screen-management/deploy-pop-screens`,
|
||||
data,
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// 메인 화면 + 모달 화면들 일괄 복사
|
||||
copyScreenWithModals: async (
|
||||
sourceScreenId: number,
|
||||
|
|
|
|||
|
|
@ -115,12 +115,14 @@ export async function getScreenGroups(params?: {
|
|||
page?: number;
|
||||
size?: number;
|
||||
searchTerm?: string;
|
||||
excludePop?: boolean;
|
||||
}): Promise<ApiResponse<ScreenGroup[]>> {
|
||||
try {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.page) queryParams.append("page", params.page.toString());
|
||||
if (params?.size) queryParams.append("size", params.size.toString());
|
||||
if (params?.searchTerm) queryParams.append("searchTerm", params.searchTerm);
|
||||
if (params?.excludePop) queryParams.append("excludePop", "true");
|
||||
|
||||
const response = await apiClient.get(`/screen-groups/groups?${queryParams.toString()}`);
|
||||
return response.data;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export interface ConnectionMetaItem {
|
|||
key: string;
|
||||
label: string;
|
||||
type: "filter_value" | "selected_row" | "action_trigger" | "data_refresh" | string;
|
||||
category?: "event" | "filter" | "data";
|
||||
description?: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import "./pop-button";
|
|||
import "./pop-string-list";
|
||||
import "./pop-search";
|
||||
|
||||
import "./pop-field";
|
||||
|
||||
// 향후 추가될 컴포넌트들:
|
||||
// import "./pop-field";
|
||||
// import "./pop-list";
|
||||
|
|
|
|||
|
|
@ -48,9 +48,20 @@ import {
|
|||
ChevronDown,
|
||||
ShoppingCart,
|
||||
ShoppingBag,
|
||||
PackageCheck,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { CollectedDataResponse, StatusChangeRule } from "./types";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { TableCombobox } from "./pop-shared/TableCombobox";
|
||||
import { ColumnCombobox } from "./pop-shared/ColumnCombobox";
|
||||
import {
|
||||
fetchTableList,
|
||||
fetchTableColumns,
|
||||
type TableInfo,
|
||||
type ColumnInfo,
|
||||
} from "./pop-dashboard/utils/dataFetcher";
|
||||
|
||||
// ========================================
|
||||
// STEP 1: 타입 정의
|
||||
|
|
@ -118,6 +129,7 @@ export type ButtonPreset =
|
|||
| "menu"
|
||||
| "modal-open"
|
||||
| "cart"
|
||||
| "inbound-confirm"
|
||||
| "custom";
|
||||
|
||||
/** row_data 저장 모드 */
|
||||
|
|
@ -141,6 +153,9 @@ export interface PopButtonConfig {
|
|||
action: ButtonMainAction;
|
||||
followUpActions?: FollowUpAction[];
|
||||
cart?: CartButtonConfig;
|
||||
statusChangeRules?: StatusChangeRule[];
|
||||
/** @deprecated inboundConfirm.statusChangeRules -> statusChangeRules로 이동 */
|
||||
inboundConfirm?: { statusChangeRules?: StatusChangeRule[] };
|
||||
}
|
||||
|
||||
// ========================================
|
||||
|
|
@ -180,6 +195,7 @@ const PRESET_LABELS: Record<ButtonPreset, string> = {
|
|||
menu: "메뉴 (드롭다운)",
|
||||
"modal-open": "모달 열기",
|
||||
cart: "장바구니 저장",
|
||||
"inbound-confirm": "입고 확정",
|
||||
custom: "직접 설정",
|
||||
};
|
||||
|
||||
|
|
@ -270,6 +286,13 @@ const PRESET_DEFAULTS: Record<ButtonPreset, Partial<PopButtonConfig>> = {
|
|||
confirm: { enabled: true, message: "장바구니에 담은 항목을 저장하시겠습니까?" },
|
||||
action: { type: "event" },
|
||||
},
|
||||
"inbound-confirm": {
|
||||
label: "입고 확정",
|
||||
variant: "default",
|
||||
icon: "PackageCheck",
|
||||
confirm: { enabled: true, message: "선택한 품목을 입고 확정하시겠습니까?" },
|
||||
action: { type: "event" },
|
||||
},
|
||||
custom: {
|
||||
label: "버튼",
|
||||
variant: "default",
|
||||
|
|
@ -341,6 +364,7 @@ const LUCIDE_ICON_MAP: Record<string, LucideIcon> = {
|
|||
Edit, Search, RefreshCw, Download, Upload, Send, Copy, Settings, ChevronDown,
|
||||
ShoppingCart,
|
||||
ShoppingBag,
|
||||
PackageCheck,
|
||||
};
|
||||
|
||||
/** Lucide 아이콘 동적 렌더링 */
|
||||
|
|
@ -389,10 +413,28 @@ export function PopButtonComponent({
|
|||
|
||||
// 장바구니 모드 상태
|
||||
const isCartMode = config?.preset === "cart";
|
||||
const isInboundConfirmMode = config?.preset === "inbound-confirm";
|
||||
const [cartCount, setCartCount] = useState(0);
|
||||
const [cartIsDirty, setCartIsDirty] = useState(false);
|
||||
const [cartSaving, setCartSaving] = useState(false);
|
||||
const [showCartConfirm, setShowCartConfirm] = useState(false);
|
||||
const [confirmProcessing, setConfirmProcessing] = useState(false);
|
||||
const [showInboundConfirm, setShowInboundConfirm] = useState(false);
|
||||
const [inboundSelectedCount, setInboundSelectedCount] = useState(0);
|
||||
|
||||
// 입고 확정 모드: 선택 항목 수 수신
|
||||
useEffect(() => {
|
||||
if (!isInboundConfirmMode || !componentId) return;
|
||||
const unsub = subscribe(
|
||||
`__comp_input__${componentId}__selected_items`,
|
||||
(payload: unknown) => {
|
||||
const data = payload as { value?: unknown[] } | undefined;
|
||||
const items = Array.isArray(data?.value) ? data.value : [];
|
||||
setInboundSelectedCount(items.length);
|
||||
}
|
||||
);
|
||||
return unsub;
|
||||
}, [isInboundConfirmMode, componentId, subscribe]);
|
||||
|
||||
// 장바구니 상태 수신 (카드 목록에서 count/isDirty 전달)
|
||||
useEffect(() => {
|
||||
|
|
@ -474,12 +516,117 @@ export function PopButtonComponent({
|
|||
}
|
||||
}, [cartSaving]);
|
||||
|
||||
// 입고 확정: 데이터 수집 → API 호출
|
||||
const handleInboundConfirm = useCallback(async () => {
|
||||
if (!componentId) return;
|
||||
setConfirmProcessing(true);
|
||||
|
||||
try {
|
||||
// 동기적 이벤트 수집 (connectionResolver가 동기 중계)
|
||||
const responses: CollectedDataResponse[] = [];
|
||||
const unsub = subscribe(
|
||||
`__comp_input__${componentId}__collected_data`,
|
||||
(payload: unknown) => {
|
||||
const enriched = payload as { value?: CollectedDataResponse };
|
||||
if (enriched?.value) {
|
||||
responses.push(enriched.value);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
publish(`__comp_output__${componentId}__collect_data`, {
|
||||
requestId: crypto.randomUUID(),
|
||||
action: "inbound-confirm",
|
||||
});
|
||||
|
||||
unsub();
|
||||
|
||||
if (responses.length === 0) {
|
||||
toast.error("연결된 컴포넌트에서 데이터를 수집할 수 없습니다. 연결 설정을 확인하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const cardListData = responses.find(r => r.componentType === "pop-card-list");
|
||||
const fieldData = responses.find(r => r.componentType === "pop-field");
|
||||
|
||||
const selectedItems = cardListData?.data?.items ?? [];
|
||||
if (selectedItems.length === 0) {
|
||||
toast.error("확정할 항목을 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const fieldValues = fieldData?.data?.values ?? {};
|
||||
|
||||
const statusChangeRules = config?.statusChangeRules ?? config?.inboundConfirm?.statusChangeRules ?? [];
|
||||
|
||||
const cardListMapping = cardListData?.mapping ?? null;
|
||||
const fieldMapping = fieldData?.mapping ?? null;
|
||||
|
||||
const result = await apiClient.post("/pop/execute-action", {
|
||||
action: "inbound-confirm",
|
||||
data: {
|
||||
items: selectedItems,
|
||||
fieldValues,
|
||||
},
|
||||
mappings: {
|
||||
cardList: cardListMapping,
|
||||
field: fieldMapping,
|
||||
},
|
||||
statusChanges: statusChangeRules,
|
||||
});
|
||||
|
||||
if (result.data?.success) {
|
||||
toast.success(`${selectedItems.length}건 입고 확정 완료`);
|
||||
publish(`__comp_output__${componentId}__action_completed`, {
|
||||
action: "inbound-confirm",
|
||||
success: true,
|
||||
count: selectedItems.length,
|
||||
});
|
||||
|
||||
// 후속 액션 실행 (navigate, refresh 등)
|
||||
const followUps = config?.followUpActions ?? [];
|
||||
for (const fa of followUps) {
|
||||
switch (fa.type) {
|
||||
case "navigate":
|
||||
if (fa.targetScreenId) {
|
||||
publish("__pop_navigate__", { screenId: fa.targetScreenId, params: fa.params });
|
||||
}
|
||||
break;
|
||||
case "refresh":
|
||||
publish("__pop_refresh__");
|
||||
break;
|
||||
case "event":
|
||||
if (fa.eventName) publish(fa.eventName, fa.eventPayload);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
toast.error(result.data?.message || "입고 확정에 실패했습니다.");
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "입고 확정 중 오류가 발생했습니다.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setConfirmProcessing(false);
|
||||
setShowInboundConfirm(false);
|
||||
}
|
||||
}, [componentId, subscribe, publish, config?.statusChangeRules, config?.inboundConfirm?.statusChangeRules, config?.followUpActions]);
|
||||
|
||||
// 클릭 핸들러
|
||||
const handleClick = useCallback(async () => {
|
||||
if (isDesignMode) {
|
||||
toast.info(
|
||||
`[디자인 모드] ${isCartMode ? "장바구니 저장" : ACTION_TYPE_LABELS[config?.action?.type || "save"]} 액션`
|
||||
);
|
||||
const modeLabel = isCartMode ? "장바구니 저장" : isInboundConfirmMode ? "입고 확정" : ACTION_TYPE_LABELS[config?.action?.type || "save"];
|
||||
toast.info(`[디자인 모드] ${modeLabel} 액션`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 입고 확정 모드: confirm 다이얼로그 후 데이터 수집 → API 호출
|
||||
if (isInboundConfirmMode) {
|
||||
if (config?.confirm?.enabled !== false) {
|
||||
setShowInboundConfirm(true);
|
||||
} else {
|
||||
await handleInboundConfirm();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -513,7 +660,7 @@ export function PopButtonComponent({
|
|||
confirm: config?.confirm,
|
||||
followUpActions: config?.followUpActions,
|
||||
});
|
||||
}, [isDesignMode, isCartMode, config, cartCount, cartIsDirty, execute, handleCartSave]);
|
||||
}, [isDesignMode, isCartMode, isInboundConfirmMode, config, cartCount, cartIsDirty, execute, handleCartSave, handleInboundConfirm]);
|
||||
|
||||
// 외형
|
||||
const buttonLabel = config?.label || label || "버튼";
|
||||
|
|
@ -541,6 +688,20 @@ export function PopButtonComponent({
|
|||
return "";
|
||||
}, [isCartMode, cartCount, cartIsDirty]);
|
||||
|
||||
// 입고 확정 2상태 아이콘: 미선택(기본 아이콘) / 선택됨(체크 아이콘)
|
||||
const inboundIconName = useMemo(() => {
|
||||
if (!isInboundConfirmMode) return iconName;
|
||||
return inboundSelectedCount > 0 ? (config?.icon || "PackageCheck") : (config?.icon || "PackageCheck");
|
||||
}, [isInboundConfirmMode, inboundSelectedCount, config?.icon, iconName]);
|
||||
|
||||
// 입고 확정 2상태 버튼 색상: 미선택(기본) / 선택됨(초록)
|
||||
const inboundButtonClass = useMemo(() => {
|
||||
if (!isInboundConfirmMode) return "";
|
||||
return inboundSelectedCount > 0
|
||||
? "bg-emerald-600 hover:bg-emerald-700 text-white border-emerald-600"
|
||||
: "";
|
||||
}, [isInboundConfirmMode, inboundSelectedCount]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
|
|
@ -548,16 +709,17 @@ export function PopButtonComponent({
|
|||
<Button
|
||||
variant={variant}
|
||||
onClick={handleClick}
|
||||
disabled={isLoading || cartSaving}
|
||||
disabled={isLoading || cartSaving || confirmProcessing}
|
||||
className={cn(
|
||||
"transition-transform active:scale-95",
|
||||
isIconOnly && "px-2",
|
||||
cartButtonClass,
|
||||
inboundButtonClass,
|
||||
)}
|
||||
>
|
||||
{(isCartMode ? cartIconName : iconName) && (
|
||||
{(isCartMode ? cartIconName : isInboundConfirmMode ? inboundIconName : iconName) && (
|
||||
<DynamicLucideIcon
|
||||
name={isCartMode ? cartIconName : iconName}
|
||||
name={isCartMode ? cartIconName : isInboundConfirmMode ? inboundIconName : iconName}
|
||||
size={16}
|
||||
className={isIconOnly ? "" : "mr-1.5"}
|
||||
/>
|
||||
|
|
@ -579,6 +741,16 @@ export function PopButtonComponent({
|
|||
{cartCount}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 입고 확정 선택 개수 배지 */}
|
||||
{isInboundConfirmMode && inboundSelectedCount > 0 && (
|
||||
<div
|
||||
className="absolute -top-2 -right-2 flex items-center justify-center rounded-full bg-emerald-600 text-white text-[10px] font-bold"
|
||||
style={{ minWidth: 18, height: 18, padding: "0 4px" }}
|
||||
>
|
||||
{inboundSelectedCount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -610,6 +782,35 @@ export function PopButtonComponent({
|
|||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 입고 확정 확인 다이얼로그 */}
|
||||
<AlertDialog open={showInboundConfirm} onOpenChange={setShowInboundConfirm}>
|
||||
<AlertDialogContent className="max-w-[95vw] sm:max-w-[400px]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-base sm:text-lg">
|
||||
입고 확정
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-xs sm:text-sm">
|
||||
{config?.confirm?.message || "선택한 품목을 입고 확정하시겠습니까?"}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="gap-2 sm:gap-0">
|
||||
<AlertDialogCancel
|
||||
disabled={confirmProcessing}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => { handleInboundConfirm(); }}
|
||||
disabled={confirmProcessing}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{confirmProcessing ? "처리 중..." : "확정"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 일반 확인 다이얼로그 */}
|
||||
<AlertDialog open={!!pendingConfirm} onOpenChange={(open) => { if (!open) cancelConfirm(); }}>
|
||||
<AlertDialogContent className="max-w-[95vw] sm:max-w-[400px]">
|
||||
|
|
@ -1029,7 +1230,7 @@ export function PopButtonConfigPanel({
|
|||
<div className="rounded-md border bg-muted/30 px-2.5 py-1.5">
|
||||
<p className="mb-1 text-[10px] font-medium text-muted-foreground">자동 설정</p>
|
||||
<CartMappingRow source="현재 화면 ID" target="screen_id" auto />
|
||||
<CartMappingRow source='장바구니 타입 ("pop")' target="cart_type" auto />
|
||||
<CartMappingRow source='장바구니 타입 (미사용)' target="cart_type" auto />
|
||||
<CartMappingRow source='상태 ("in_cart")' target="status" auto />
|
||||
<CartMappingRow source="회사 코드" target="company_code" auto />
|
||||
<CartMappingRow source="사용자 ID" target="user_id" auto />
|
||||
|
|
@ -1130,6 +1331,17 @@ export function PopButtonConfigPanel({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* 상태 변경 규칙 (cart 프리셋 제외 모두 표시) */}
|
||||
{config?.preset !== "cart" && (
|
||||
<>
|
||||
<SectionDivider label="상태 변경 규칙" />
|
||||
<StatusChangeRuleEditor
|
||||
rules={config?.statusChangeRules ?? config?.inboundConfirm?.statusChangeRules ?? []}
|
||||
onUpdate={(rules) => onUpdate({ ...config, statusChangeRules: rules })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 후속 액션 */}
|
||||
<SectionDivider label="후속 액션" />
|
||||
<FollowUpActionsEditor
|
||||
|
|
@ -1467,6 +1679,330 @@ function PopButtonPreviewComponent({
|
|||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 상태 변경 규칙 편집기
|
||||
// ========================================
|
||||
|
||||
const KNOWN_ITEM_FIELDS = [
|
||||
{ value: "__cart_id", label: "__cart_id (카드 항목 ID)" },
|
||||
{ value: "__cart_row_key", label: "__cart_row_key (원본 PK 값)" },
|
||||
{ value: "id", label: "id" },
|
||||
{ value: "row_key", label: "row_key" },
|
||||
];
|
||||
|
||||
function StatusChangeRuleEditor({
|
||||
rules,
|
||||
onUpdate,
|
||||
}: {
|
||||
rules: StatusChangeRule[];
|
||||
onUpdate: (rules: StatusChangeRule[]) => void;
|
||||
}) {
|
||||
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||
const [columnsMap, setColumnsMap] = useState<Record<string, ColumnInfo[]>>({});
|
||||
|
||||
useEffect(() => {
|
||||
fetchTableList().then(setTables);
|
||||
}, []);
|
||||
|
||||
const loadColumns = (tableName: string) => {
|
||||
if (!tableName || columnsMap[tableName]) return;
|
||||
fetchTableColumns(tableName).then((cols) => {
|
||||
setColumnsMap((prev) => ({ ...prev, [tableName]: cols }));
|
||||
});
|
||||
};
|
||||
|
||||
const updateRule = (idx: number, partial: Partial<StatusChangeRule>) => {
|
||||
const next = [...rules];
|
||||
next[idx] = { ...next[idx], ...partial };
|
||||
onUpdate(next);
|
||||
};
|
||||
|
||||
const removeRule = (idx: number) => {
|
||||
const next = [...rules];
|
||||
next.splice(idx, 1);
|
||||
onUpdate(next);
|
||||
};
|
||||
|
||||
const addRule = () => {
|
||||
onUpdate([
|
||||
...rules,
|
||||
{
|
||||
targetTable: "",
|
||||
targetColumn: "",
|
||||
valueType: "fixed",
|
||||
fixedValue: "",
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 px-1">
|
||||
{rules.map((rule, idx) => (
|
||||
<SingleRuleEditor
|
||||
key={idx}
|
||||
rule={rule}
|
||||
idx={idx}
|
||||
tables={tables}
|
||||
columns={columnsMap[rule.targetTable] ?? []}
|
||||
onLoadColumns={loadColumns}
|
||||
onUpdate={(partial) => updateRule(idx, partial)}
|
||||
onRemove={() => removeRule(idx)}
|
||||
/>
|
||||
))}
|
||||
<Button variant="outline" size="sm" className="w-full text-xs" onClick={addRule}>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
규칙 추가
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SingleRuleEditor({
|
||||
rule,
|
||||
idx,
|
||||
tables,
|
||||
columns,
|
||||
onLoadColumns,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
}: {
|
||||
rule: StatusChangeRule;
|
||||
idx: number;
|
||||
tables: TableInfo[];
|
||||
columns: ColumnInfo[];
|
||||
onLoadColumns: (tableName: string) => void;
|
||||
onUpdate: (partial: Partial<StatusChangeRule>) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (rule.targetTable) onLoadColumns(rule.targetTable);
|
||||
}, [rule.targetTable]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const conditions = rule.conditionalValue?.conditions ?? [];
|
||||
const defaultValue = rule.conditionalValue?.defaultValue ?? "";
|
||||
|
||||
const updateCondition = (cIdx: number, partial: Partial<(typeof conditions)[0]>) => {
|
||||
const next = [...conditions];
|
||||
next[cIdx] = { ...next[cIdx], ...partial };
|
||||
onUpdate({
|
||||
conditionalValue: { ...rule.conditionalValue, conditions: next, defaultValue },
|
||||
});
|
||||
};
|
||||
|
||||
const removeCondition = (cIdx: number) => {
|
||||
const next = [...conditions];
|
||||
next.splice(cIdx, 1);
|
||||
onUpdate({
|
||||
conditionalValue: { ...rule.conditionalValue, conditions: next, defaultValue },
|
||||
});
|
||||
};
|
||||
|
||||
const addCondition = () => {
|
||||
onUpdate({
|
||||
conditionalValue: {
|
||||
...rule.conditionalValue,
|
||||
conditions: [...conditions, { whenColumn: "", operator: "=" as const, whenValue: "", thenValue: "" }],
|
||||
defaultValue,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded border border-border p-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-medium text-muted-foreground">규칙 {idx + 1}</span>
|
||||
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={onRemove}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 대상 테이블 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">대상 테이블</Label>
|
||||
<TableCombobox
|
||||
tables={tables}
|
||||
value={rule.targetTable}
|
||||
onSelect={(v) => onUpdate({ targetTable: v, targetColumn: "" })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 변경 컬럼 */}
|
||||
{rule.targetTable && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">변경 컬럼</Label>
|
||||
<ColumnCombobox
|
||||
columns={columns}
|
||||
value={rule.targetColumn}
|
||||
onSelect={(v) => onUpdate({ targetColumn: v })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 조회 키 */}
|
||||
{rule.targetColumn && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-[10px]">조회 키</Label>
|
||||
<Select
|
||||
value={rule.lookupMode ?? "auto"}
|
||||
onValueChange={(v) => onUpdate({ lookupMode: v as "auto" | "manual" })}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-16 text-[10px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto" className="text-[10px]">자동</SelectItem>
|
||||
<SelectItem value="manual" className="text-[10px]">수동</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{(rule.lookupMode ?? "auto") === "auto" ? (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{rule.targetTable === "cart_items"
|
||||
? `카드 항목.__cart_id → ${rule.targetTable}.id`
|
||||
: `카드 항목.row_key → ${rule.targetTable}.${columns.find(c => c.isPrimaryKey)?.name ?? "PK(조회중)"}`}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<Select
|
||||
value={rule.manualItemField ?? ""}
|
||||
onValueChange={(v) => onUpdate({ manualItemField: v })}
|
||||
>
|
||||
<SelectTrigger className="h-7 flex-1 text-[10px]">
|
||||
<SelectValue placeholder="카드 항목 필드" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{KNOWN_ITEM_FIELDS.map((f) => (
|
||||
<SelectItem key={f.value} value={f.value} className="text-[10px]">{f.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">→</span>
|
||||
<ColumnCombobox
|
||||
columns={columns}
|
||||
value={rule.manualPkColumn ?? ""}
|
||||
onSelect={(v) => onUpdate({ manualPkColumn: v })}
|
||||
placeholder="대상 PK 컬럼"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 변경 값 타입 */}
|
||||
{rule.targetColumn && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">변경 값</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-1 text-[10px]">
|
||||
<input
|
||||
type="radio"
|
||||
name={`valueType-${idx}`}
|
||||
checked={rule.valueType === "fixed"}
|
||||
onChange={() => onUpdate({ valueType: "fixed" })}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
고정값
|
||||
</label>
|
||||
<label className="flex items-center gap-1 text-[10px]">
|
||||
<input
|
||||
type="radio"
|
||||
name={`valueType-${idx}`}
|
||||
checked={rule.valueType === "conditional"}
|
||||
onChange={() => onUpdate({ valueType: "conditional" })}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
조건부
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 고정값 */}
|
||||
{rule.valueType === "fixed" && (
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
value={rule.fixedValue ?? ""}
|
||||
onChange={(e) => onUpdate({ fixedValue: e.target.value })}
|
||||
className="h-7 text-xs"
|
||||
placeholder="변경할 값 입력"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 조건부 */}
|
||||
{rule.valueType === "conditional" && (
|
||||
<div className="space-y-2 rounded border border-dashed border-muted-foreground/30 p-2">
|
||||
{conditions.map((cond, cIdx) => (
|
||||
<div key={cIdx} className="space-y-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">만약</span>
|
||||
<ColumnCombobox
|
||||
columns={columns}
|
||||
value={cond.whenColumn}
|
||||
onSelect={(v) => updateCondition(cIdx, { whenColumn: v })}
|
||||
placeholder="컬럼"
|
||||
/>
|
||||
<Select
|
||||
value={cond.operator}
|
||||
onValueChange={(v) => updateCondition(cIdx, { operator: v as "=" | "!=" | ">" | "<" | ">=" | "<=" })}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-14 text-[10px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{["=", "!=", ">", "<", ">=", "<="].map((op) => (
|
||||
<SelectItem key={op} value={op} className="text-xs">{op}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={cond.whenValue}
|
||||
onChange={(e) => updateCondition(cIdx, { whenValue: e.target.value })}
|
||||
className="h-7 w-16 text-[10px]"
|
||||
placeholder="값"
|
||||
/>
|
||||
<Button variant="ghost" size="icon" className="h-5 w-5 shrink-0" onClick={() => removeCondition(cIdx)}>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 pl-4">
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">이면 -></span>
|
||||
<Input
|
||||
value={cond.thenValue}
|
||||
onChange={(e) => updateCondition(cIdx, { thenValue: e.target.value })}
|
||||
className="h-7 text-[10px]"
|
||||
placeholder="변경할 값"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button variant="ghost" size="sm" className="w-full text-[10px]" onClick={addCondition}>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
조건 추가
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">그 외 -></span>
|
||||
<Input
|
||||
value={defaultValue}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
conditionalValue: { ...rule.conditionalValue, conditions, defaultValue: e.target.value },
|
||||
})
|
||||
}
|
||||
className="h-7 text-[10px]"
|
||||
placeholder="기본값"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 레지스트리 등록
|
||||
PopComponentRegistry.registerComponent({
|
||||
id: "pop-button",
|
||||
|
|
@ -1486,11 +2022,15 @@ PopComponentRegistry.registerComponent({
|
|||
} as PopButtonConfig,
|
||||
connectionMeta: {
|
||||
sendable: [
|
||||
{ key: "cart_save_trigger", label: "저장 요청", type: "event", description: "장바구니 일괄 저장 요청 (카드 목록에 전달)" },
|
||||
{ key: "cart_save_trigger", label: "저장 요청", type: "event", category: "event", description: "장바구니 일괄 저장 요청 (카드 목록에 전달)" },
|
||||
{ key: "collect_data", label: "데이터 수집 요청", type: "event", category: "event", description: "연결된 컴포넌트에 데이터+매핑 수집 요청" },
|
||||
{ key: "action_completed", label: "액션 완료", type: "event", category: "event", description: "확정/저장 완료 후 결과 전달" },
|
||||
],
|
||||
receivable: [
|
||||
{ key: "cart_updated", label: "장바구니 상태", type: "event", description: "카드 목록에서 장바구니 변경 시 count/isDirty 수신" },
|
||||
{ key: "cart_save_completed", label: "저장 완료", type: "event", description: "카드 목록에서 DB 저장 완료 시 수신" },
|
||||
{ key: "cart_updated", label: "장바구니 상태", type: "event", category: "event", description: "카드 목록에서 장바구니 변경 시 count/isDirty 수신" },
|
||||
{ key: "cart_save_completed", label: "저장 완료", type: "event", category: "event", description: "카드 목록에서 DB 저장 완료 시 수신" },
|
||||
{ key: "collected_data", label: "수집된 데이터", type: "event", category: "event", description: "컴포넌트에서 수집한 데이터+매핑 응답" },
|
||||
{ key: "selected_items", label: "선택된 항목", type: "event", category: "event", description: "카드 목록에서 체크박스로 선택된 항목 수 수신" },
|
||||
],
|
||||
},
|
||||
touchOptimized: true,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { useRouter } from "next/navigation";
|
|||
import {
|
||||
Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight,
|
||||
ShoppingCart, X, Package, Truck, Box, Archive, Heart, Star,
|
||||
Trash2,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -28,12 +29,15 @@ import type {
|
|||
CardPresetSpec,
|
||||
CartItem,
|
||||
PackageEntry,
|
||||
CollectDataRequest,
|
||||
CollectedDataResponse,
|
||||
} from "../types";
|
||||
import {
|
||||
DEFAULT_CARD_IMAGE,
|
||||
CARD_PRESET_SPECS,
|
||||
} from "../types";
|
||||
import { dataApi } from "@/lib/api/data";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { usePopEvent } from "@/hooks/pop/usePopEvent";
|
||||
import { useCartSync } from "@/hooks/pop/useCartSync";
|
||||
import { NumberInputModal } from "./NumberInputModal";
|
||||
|
|
@ -121,6 +125,28 @@ function MarqueeText({
|
|||
);
|
||||
}
|
||||
|
||||
// cart_items 행의 row_data JSON을 풀어서 __cart_ 접두사 메타데이터와 병합
|
||||
function parseCartRow(dbRow: Record<string, unknown>): Record<string, unknown> {
|
||||
let rowData: Record<string, unknown> = {};
|
||||
try {
|
||||
const raw = dbRow.row_data;
|
||||
if (typeof raw === "string" && raw.trim()) rowData = JSON.parse(raw);
|
||||
else if (typeof raw === "object" && raw !== null) rowData = raw as Record<string, unknown>;
|
||||
} catch { rowData = {}; }
|
||||
|
||||
return {
|
||||
...rowData,
|
||||
__cart_id: dbRow.id,
|
||||
__cart_quantity: Number(dbRow.quantity) || 0,
|
||||
__cart_package_unit: dbRow.package_unit || "",
|
||||
__cart_package_entries: dbRow.package_entries,
|
||||
__cart_status: dbRow.status || "in_cart",
|
||||
__cart_memo: dbRow.memo || "",
|
||||
__cart_row_key: dbRow.row_key || "",
|
||||
__cart_modified: false,
|
||||
};
|
||||
}
|
||||
|
||||
interface PopCardListComponentProps {
|
||||
config?: PopCardListConfig;
|
||||
className?: string;
|
||||
|
|
@ -158,19 +184,34 @@ export function PopCardListComponent({
|
|||
currentColSpan,
|
||||
onRequestResize,
|
||||
}: PopCardListComponentProps) {
|
||||
const isHorizontalMode = (config?.scrollDirection || "vertical") === "horizontal";
|
||||
const maxGridColumns = config?.gridColumns || 2;
|
||||
const configGridRows = config?.gridRows || 3;
|
||||
const dataSource = config?.dataSource;
|
||||
const template = config?.cardTemplate;
|
||||
|
||||
const { subscribe, publish } = usePopEvent(screenId || "default");
|
||||
const router = useRouter();
|
||||
|
||||
// 장바구니 DB 동기화
|
||||
const sourceTableName = dataSource?.tableName || "";
|
||||
const cartType = config?.cartAction?.cartType;
|
||||
const cart = useCartSync(screenId || "", sourceTableName, cartType);
|
||||
// 장바구니 목록 모드 플래그 및 상태
|
||||
const isCartListMode = config?.cartListMode?.enabled === true;
|
||||
const [inheritedConfig, setInheritedConfig] = useState<Partial<PopCardListConfig> | null>(null);
|
||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
||||
|
||||
// 장바구니 목록 모드: 원본 화면의 설정 전체를 병합 (cardTemplate, inputField, packageConfig, cardSize 등)
|
||||
const effectiveConfig = useMemo<PopCardListConfig | undefined>(() => {
|
||||
if (!isCartListMode || !inheritedConfig) return config;
|
||||
return {
|
||||
...config,
|
||||
...inheritedConfig,
|
||||
cartListMode: config?.cartListMode,
|
||||
dataSource: config?.dataSource,
|
||||
} as PopCardListConfig;
|
||||
}, [config, inheritedConfig, isCartListMode]);
|
||||
|
||||
const isHorizontalMode = (effectiveConfig?.scrollDirection || "vertical") === "horizontal";
|
||||
const maxGridColumns = effectiveConfig?.gridColumns || 2;
|
||||
const configGridRows = effectiveConfig?.gridRows || 3;
|
||||
const dataSource = effectiveConfig?.dataSource;
|
||||
const effectiveTemplate = effectiveConfig?.cardTemplate;
|
||||
|
||||
// 장바구니 DB 동기화 (장바구니 목록 모드에서는 비활성화)
|
||||
const sourceTableName = (!isCartListMode && dataSource?.tableName) || "";
|
||||
const cart = useCartSync(screenId || "", sourceTableName);
|
||||
|
||||
// 데이터 상태
|
||||
const [rows, setRows] = useState<RowData[]>([]);
|
||||
|
|
@ -219,9 +260,9 @@ export function PopCardListComponent({
|
|||
const cartRef = useRef(cart);
|
||||
cartRef.current = cart;
|
||||
|
||||
// "저장 요청" 이벤트 수신: 버튼 컴포넌트가 장바구니 저장을 요청하면 saveToDb 실행
|
||||
// "저장 요청" 이벤트 수신: 버튼 컴포넌트가 장바구니 저장을 요청하면 saveToDb 실행 (장바구니 목록 모드 제외)
|
||||
useEffect(() => {
|
||||
if (!componentId) return;
|
||||
if (!componentId || isCartListMode) return;
|
||||
const unsub = subscribe(
|
||||
`__comp_input__${componentId}__cart_save_trigger`,
|
||||
async (payload: unknown) => {
|
||||
|
|
@ -233,16 +274,16 @@ export function PopCardListComponent({
|
|||
}
|
||||
);
|
||||
return unsub;
|
||||
}, [componentId, subscribe, publish]);
|
||||
}, [componentId, subscribe, publish, isCartListMode]);
|
||||
|
||||
// DB 로드 완료 후 초기 장바구니 상태를 버튼에 전달
|
||||
// DB 로드 완료 후 초기 장바구니 상태를 버튼에 전달 (장바구니 목록 모드 제외)
|
||||
useEffect(() => {
|
||||
if (!componentId || cart.loading) return;
|
||||
if (!componentId || cart.loading || isCartListMode) return;
|
||||
publish(`__comp_output__${componentId}__cart_updated`, {
|
||||
count: cart.cartCount,
|
||||
isDirty: cart.isDirty,
|
||||
});
|
||||
}, [componentId, cart.loading, cart.cartCount, cart.isDirty, publish]);
|
||||
}, [componentId, cart.loading, cart.cartCount, cart.isDirty, publish, isCartListMode]);
|
||||
|
||||
// 카드 선택 시 selected_row 이벤트 발행
|
||||
const handleCardSelect = useCallback((row: RowData) => {
|
||||
|
|
@ -278,7 +319,7 @@ export function PopCardListComponent({
|
|||
const missingImageCountRef = useRef(0);
|
||||
|
||||
|
||||
const cardSizeKey = config?.cardSize || "large";
|
||||
const cardSizeKey = effectiveConfig?.cardSize || "large";
|
||||
const spec: CardPresetSpec = CARD_PRESET_SPECS[cardSizeKey] || CARD_PRESET_SPECS.large;
|
||||
|
||||
// 브레이크포인트별 열 제한: colSpan >= 8이면 config 최대값 허용, 그 외 1열
|
||||
|
|
@ -454,7 +495,69 @@ export function PopCardListComponent({
|
|||
[dataSource]
|
||||
);
|
||||
|
||||
// 장바구니 목록 모드 설정을 직렬화 (의존성 안정화)
|
||||
const cartListModeKey = useMemo(
|
||||
() => JSON.stringify(config?.cartListMode || null),
|
||||
[config?.cartListMode]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// 장바구니 목록 모드: cart_items에서 직접 조회
|
||||
if (isCartListMode) {
|
||||
const cartListMode = config!.cartListMode!;
|
||||
|
||||
// 원본 화면 미선택 시 데이터 조회하지 않음
|
||||
if (!cartListMode.sourceScreenId) {
|
||||
setLoading(false);
|
||||
setRows([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchCartData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// 원본 화면 레이아웃에서 설정 전체 상속 (cardTemplate, inputField, packageConfig, cardSize 등)
|
||||
try {
|
||||
const layoutJson = await screenApi.getLayoutPop(cartListMode.sourceScreenId);
|
||||
const componentsMap = layoutJson?.components || {};
|
||||
const componentList = Object.values(componentsMap) as any[];
|
||||
const matched = cartListMode.sourceComponentId
|
||||
? componentList.find((c: any) => c.id === cartListMode.sourceComponentId)
|
||||
: componentList.find((c: any) => c.type === "pop-card-list");
|
||||
if (matched?.config) {
|
||||
setInheritedConfig(matched.config);
|
||||
}
|
||||
} catch {
|
||||
// 레이아웃 로드 실패 시 자체 config 폴백
|
||||
}
|
||||
|
||||
const cartFilters: Record<string, unknown> = {
|
||||
status: cartListMode.statusFilter || "in_cart",
|
||||
};
|
||||
if (cartListMode.sourceScreenId) {
|
||||
cartFilters.screen_id = String(cartListMode.sourceScreenId);
|
||||
}
|
||||
const result = await dataApi.getTableData("cart_items", {
|
||||
size: 500,
|
||||
filters: cartFilters,
|
||||
});
|
||||
|
||||
const parsed = (result.data || []).map(parseCartRow);
|
||||
setRows(parsed);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "장바구니 데이터 조회 실패";
|
||||
setError(message);
|
||||
setRows([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchCartData();
|
||||
return;
|
||||
}
|
||||
|
||||
// 기본 모드: 데이터 소스에서 조회
|
||||
if (!dataSource?.tableName) {
|
||||
setLoading(false);
|
||||
setRows([]);
|
||||
|
|
@ -467,10 +570,11 @@ export function PopCardListComponent({
|
|||
missingImageCountRef.current = 0;
|
||||
|
||||
try {
|
||||
// 서버에는 = 연산자 필터만 전달, 나머지는 클라이언트 후처리
|
||||
const filters: Record<string, unknown> = {};
|
||||
if (dataSource.filters && dataSource.filters.length > 0) {
|
||||
dataSource.filters.forEach((f) => {
|
||||
if (f.column && f.value) {
|
||||
if (f.column && f.value && (!f.operator || f.operator === "=")) {
|
||||
filters[f.column] = f.value;
|
||||
}
|
||||
});
|
||||
|
|
@ -499,7 +603,31 @@ export function PopCardListComponent({
|
|||
filters: Object.keys(filters).length > 0 ? filters : undefined,
|
||||
});
|
||||
|
||||
setRows(result.data || []);
|
||||
let fetchedRows = result.data || [];
|
||||
|
||||
// 서버에서 처리하지 못한 연산자 필터 클라이언트 후처리
|
||||
const clientFilters = (dataSource.filters || []).filter(
|
||||
(f) => f.column && f.value && f.operator && f.operator !== "="
|
||||
);
|
||||
if (clientFilters.length > 0) {
|
||||
fetchedRows = fetchedRows.filter((row) =>
|
||||
clientFilters.every((f) => {
|
||||
const cellVal = row[f.column];
|
||||
const filterVal = f.value;
|
||||
switch (f.operator) {
|
||||
case "!=": return String(cellVal ?? "") !== filterVal;
|
||||
case ">": return Number(cellVal) > Number(filterVal);
|
||||
case ">=": return Number(cellVal) >= Number(filterVal);
|
||||
case "<": return Number(cellVal) < Number(filterVal);
|
||||
case "<=": return Number(cellVal) <= Number(filterVal);
|
||||
case "like": return String(cellVal ?? "").toLowerCase().includes(filterVal.toLowerCase());
|
||||
default: return true;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
setRows(fetchedRows);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "데이터 조회 실패";
|
||||
setError(message);
|
||||
|
|
@ -510,16 +638,90 @@ export function PopCardListComponent({
|
|||
};
|
||||
|
||||
fetchData();
|
||||
}, [dataSourceKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [dataSourceKey, isCartListMode, cartListModeKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 이미지 URL 없는 항목 카운트 (내부 추적용, toast 미표시)
|
||||
useEffect(() => {
|
||||
if (!loading && rows.length > 0 && template?.image?.enabled && template?.image?.imageColumn) {
|
||||
const imageColumn = template.image.imageColumn;
|
||||
if (!loading && rows.length > 0 && effectiveTemplate?.image?.enabled && effectiveTemplate?.image?.imageColumn) {
|
||||
const imageColumn = effectiveTemplate.image.imageColumn;
|
||||
missingImageCountRef.current = rows.filter((row) => !row[imageColumn]).length;
|
||||
}
|
||||
}, [loading, rows, template?.image]);
|
||||
}, [loading, rows, effectiveTemplate?.image]);
|
||||
|
||||
// 장바구니 목록 모드: 항목 삭제 콜백
|
||||
const handleDeleteItem = useCallback((cartId: string) => {
|
||||
setRows(prev => prev.filter(r => String(r.__cart_id) !== cartId));
|
||||
setSelectedKeys(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(cartId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 장바구니 목록 모드: 수량 수정 콜백 (로컬만 업데이트, DB 미반영)
|
||||
const handleUpdateQuantity = useCallback((
|
||||
cartId: string,
|
||||
quantity: number,
|
||||
unit?: string,
|
||||
entries?: PackageEntry[],
|
||||
) => {
|
||||
setRows(prev => prev.map(r => {
|
||||
if (String(r.__cart_id) !== cartId) return r;
|
||||
return {
|
||||
...r,
|
||||
__cart_quantity: quantity,
|
||||
__cart_package_unit: unit || r.__cart_package_unit,
|
||||
__cart_package_entries: entries || r.__cart_package_entries,
|
||||
__cart_modified: true,
|
||||
};
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 데이터 수집 요청 수신: 버튼에서 collect_data 요청 → 선택 항목 + 매핑 응답
|
||||
useEffect(() => {
|
||||
if (!componentId) return;
|
||||
const unsub = subscribe(
|
||||
`__comp_input__${componentId}__collect_data`,
|
||||
(payload: unknown) => {
|
||||
const request = (payload as Record<string, unknown>)?.value as CollectDataRequest | undefined;
|
||||
|
||||
const selectedItems = isCartListMode
|
||||
? filteredRows.filter(r => selectedKeys.has(String(r.__cart_id ?? "")))
|
||||
: rows;
|
||||
|
||||
// CardListSaveMapping → SaveMapping 변환
|
||||
const sm = config?.saveMapping;
|
||||
const mapping = sm?.targetTable && sm.mappings.length > 0
|
||||
? {
|
||||
targetTable: sm.targetTable,
|
||||
columnMapping: Object.fromEntries(
|
||||
sm.mappings
|
||||
.filter(m => m.sourceField && m.targetColumn)
|
||||
.map(m => [m.sourceField, m.targetColumn])
|
||||
),
|
||||
}
|
||||
: null;
|
||||
|
||||
const response: CollectedDataResponse = {
|
||||
requestId: request?.requestId ?? "",
|
||||
componentId: componentId,
|
||||
componentType: "pop-card-list",
|
||||
data: { items: selectedItems },
|
||||
mapping,
|
||||
};
|
||||
|
||||
publish(`__comp_output__${componentId}__collected_data`, response);
|
||||
}
|
||||
);
|
||||
return unsub;
|
||||
}, [componentId, subscribe, publish, isCartListMode, filteredRows, rows, selectedKeys]);
|
||||
|
||||
// 장바구니 목록 모드: 선택 항목 이벤트 발행
|
||||
useEffect(() => {
|
||||
if (!componentId || !isCartListMode) return;
|
||||
const selectedItems = filteredRows.filter(r => selectedKeys.has(String(r.__cart_id ?? "")));
|
||||
publish(`__comp_output__${componentId}__selected_items`, selectedItems);
|
||||
}, [selectedKeys, filteredRows, componentId, isCartListMode, publish]);
|
||||
|
||||
// 카드 영역 스타일
|
||||
const cardAreaStyle: React.CSSProperties = {
|
||||
|
|
@ -549,7 +751,13 @@ export function PopCardListComponent({
|
|||
ref={containerRef}
|
||||
className={`flex h-full w-full flex-col ${className || ""}`}
|
||||
>
|
||||
{!dataSource?.tableName ? (
|
||||
{isCartListMode && !config?.cartListMode?.sourceScreenId ? (
|
||||
<div className="flex flex-1 items-center justify-center rounded-md border border-dashed bg-muted/30 p-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
원본 화면을 선택해주세요.
|
||||
</p>
|
||||
</div>
|
||||
) : !isCartListMode && !dataSource?.tableName ? (
|
||||
<div className="flex flex-1 items-center justify-center rounded-md border border-dashed bg-muted/30 p-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
데이터 소스를 설정해주세요.
|
||||
|
|
@ -569,6 +777,27 @@ export function PopCardListComponent({
|
|||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 장바구니 목록 모드: 선택 바 */}
|
||||
{isCartListMode && (
|
||||
<div className="flex shrink-0 items-center gap-3 border-b px-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedKeys.size === filteredRows.length && filteredRows.length > 0}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedKeys(new Set(filteredRows.map(r => String(r.__cart_id ?? ""))));
|
||||
} else {
|
||||
setSelectedKeys(new Set());
|
||||
}
|
||||
}}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{selectedKeys.size > 0 ? `${selectedKeys.size}개 선택됨` : "전체 선택"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 카드 영역 (스크롤 가능) */}
|
||||
<div
|
||||
ref={scrollAreaRef}
|
||||
|
|
@ -580,25 +809,39 @@ export function PopCardListComponent({
|
|||
}}
|
||||
>
|
||||
{displayCards.map((row, index) => {
|
||||
const codeValue = template?.header?.codeField && row[template.header.codeField]
|
||||
? String(row[template.header.codeField])
|
||||
const codeValue = effectiveTemplate?.header?.codeField && row[effectiveTemplate.header.codeField]
|
||||
? String(row[effectiveTemplate.header.codeField])
|
||||
: null;
|
||||
const rowKey = codeValue ? `${codeValue}-${index}` : `card-${index}`;
|
||||
return (
|
||||
<Card
|
||||
key={rowKey}
|
||||
row={row}
|
||||
template={template}
|
||||
template={effectiveTemplate}
|
||||
scaled={scaled}
|
||||
inputField={config?.inputField}
|
||||
packageConfig={config?.packageConfig}
|
||||
cartAction={config?.cartAction}
|
||||
inputField={effectiveConfig?.inputField}
|
||||
packageConfig={effectiveConfig?.packageConfig}
|
||||
cartAction={effectiveConfig?.cartAction}
|
||||
publish={publish}
|
||||
router={router}
|
||||
onSelect={handleCardSelect}
|
||||
cart={cart}
|
||||
codeFieldName={template?.header?.codeField}
|
||||
keyColumnName={effectiveConfig?.cartAction?.keyColumn || "id"}
|
||||
parentComponentId={componentId}
|
||||
isCartListMode={isCartListMode}
|
||||
isSelected={selectedKeys.has(String(row.__cart_id ?? ""))}
|
||||
onToggleSelect={() => {
|
||||
const cartId = row.__cart_id != null ? String(row.__cart_id) : "";
|
||||
if (!cartId) return;
|
||||
setSelectedKeys(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(cartId)) next.delete(cartId);
|
||||
else next.add(cartId);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onDeleteItem={handleDeleteItem}
|
||||
onUpdateQuantity={handleUpdateQuantity}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
@ -679,8 +922,13 @@ function Card({
|
|||
router,
|
||||
onSelect,
|
||||
cart,
|
||||
codeFieldName,
|
||||
keyColumnName,
|
||||
parentComponentId,
|
||||
isCartListMode,
|
||||
isSelected,
|
||||
onToggleSelect,
|
||||
onDeleteItem,
|
||||
onUpdateQuantity,
|
||||
}: {
|
||||
row: RowData;
|
||||
template?: CardTemplateConfig;
|
||||
|
|
@ -692,8 +940,13 @@ function Card({
|
|||
router: ReturnType<typeof useRouter>;
|
||||
onSelect?: (row: RowData) => void;
|
||||
cart: ReturnType<typeof useCartSync>;
|
||||
codeFieldName?: string;
|
||||
keyColumnName?: string;
|
||||
parentComponentId?: string;
|
||||
isCartListMode?: boolean;
|
||||
isSelected?: boolean;
|
||||
onToggleSelect?: () => void;
|
||||
onDeleteItem?: (cartId: string) => void;
|
||||
onUpdateQuantity?: (cartId: string, quantity: number, unit?: string, entries?: PackageEntry[]) => void;
|
||||
}) {
|
||||
const header = template?.header;
|
||||
const image = template?.image;
|
||||
|
|
@ -707,19 +960,28 @@ function Card({
|
|||
const codeValue = header?.codeField ? row[header.codeField] : null;
|
||||
const titleValue = header?.titleField ? row[header.titleField] : null;
|
||||
|
||||
// 장바구니 상태: codeField 값을 rowKey로 사용
|
||||
const rowKey = codeFieldName && row[codeFieldName] ? String(row[codeFieldName]) : "";
|
||||
const rowKey = keyColumnName && row[keyColumnName] ? String(row[keyColumnName]) : "";
|
||||
const isCarted = cart.isItemInCart(rowKey);
|
||||
const existingCartItem = cart.getCartItem(rowKey);
|
||||
|
||||
// DB에서 로드된 장바구니 품목이면 입력값 복원
|
||||
// DB에서 로드된 장바구니 품목이면 입력값 복원 (기본 모드)
|
||||
useEffect(() => {
|
||||
if (isCartListMode) return;
|
||||
if (existingCartItem && existingCartItem._origin === "db") {
|
||||
setInputValue(existingCartItem.quantity);
|
||||
setPackageUnit(existingCartItem.packageUnit);
|
||||
setPackageEntries(existingCartItem.packageEntries || []);
|
||||
}
|
||||
}, [existingCartItem?._origin, existingCartItem?.quantity, existingCartItem?.packageUnit]);
|
||||
}, [isCartListMode, existingCartItem?._origin, existingCartItem?.quantity, existingCartItem?.packageUnit]);
|
||||
|
||||
// 장바구니 목록 모드: __cart_quantity에서 초기값 복원
|
||||
useEffect(() => {
|
||||
if (!isCartListMode) return;
|
||||
const cartQty = Number(row.__cart_quantity) || 0;
|
||||
setInputValue(cartQty);
|
||||
const cartUnit = row.__cart_package_unit ? String(row.__cart_package_unit) : undefined;
|
||||
setPackageUnit(cartUnit);
|
||||
}, [isCartListMode, row.__cart_quantity, row.__cart_package_unit]);
|
||||
|
||||
const imageUrl =
|
||||
image?.enabled && image?.imageColumn && row[image.imageColumn]
|
||||
|
|
@ -771,6 +1033,9 @@ function Card({
|
|||
setInputValue(value);
|
||||
setPackageUnit(unit);
|
||||
setPackageEntries(entries || []);
|
||||
if (isCartListMode) {
|
||||
onUpdateQuantity?.(String(row.__cart_id), value, unit, entries);
|
||||
}
|
||||
};
|
||||
|
||||
// 담기: 로컬 상태에만 추가 + 연결 시스템으로 카운트 전달
|
||||
|
|
@ -806,6 +1071,23 @@ function Card({
|
|||
}
|
||||
};
|
||||
|
||||
// 장바구니 목록 모드: 개별 삭제
|
||||
const handleCartDelete = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const cartId = row.__cart_id != null ? String(row.__cart_id) : "";
|
||||
if (!cartId) return;
|
||||
|
||||
const ok = window.confirm("이 항목을 장바구니에서 삭제하시겠습니까?");
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
await dataApi.updateRecord("cart_items", cartId, { status: "cancelled" });
|
||||
onDeleteItem?.(cartId);
|
||||
} catch {
|
||||
toast.error("삭제에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// pop-icon 스타일 담기/취소 버튼 아이콘 크기 (스케일 반영)
|
||||
const iconSize = Math.max(14, Math.round(18 * (scaled.bodyTextSize / 11)));
|
||||
const cartLabel = cartAction?.label || "담기";
|
||||
|
|
@ -815,13 +1097,23 @@ function Card({
|
|||
onSelect?.(row);
|
||||
};
|
||||
|
||||
// 카드 테두리: 장바구니 목록 모드에서는 선택 상태, 기본 모드에서는 담기 상태 기준
|
||||
const borderClass = isCartListMode
|
||||
? isSelected
|
||||
? "border-primary border-2 hover:border-primary/80"
|
||||
: "hover:border-2 hover:border-blue-500"
|
||||
: isCarted
|
||||
? "border-emerald-500 border-2 hover:border-emerald-600"
|
||||
: "hover:border-2 hover:border-blue-500";
|
||||
|
||||
// 카드 헤더 배경: 장바구니 목록 모드에서는 선택 상태, 기본 모드에서는 담기 상태 기준
|
||||
const headerBgClass = isCartListMode
|
||||
? isSelected ? "bg-primary/10 dark:bg-primary/20" : "bg-muted/30"
|
||||
: isCarted ? "bg-emerald-50 dark:bg-emerald-950/30" : "bg-muted/30";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`cursor-pointer rounded-lg border bg-card shadow-sm transition-all duration-150 hover:shadow-md ${
|
||||
isCarted
|
||||
? "border-emerald-500 border-2 hover:border-emerald-600"
|
||||
: "hover:border-2 hover:border-blue-500"
|
||||
}`}
|
||||
className={`relative cursor-pointer rounded-lg border bg-card shadow-sm transition-all duration-150 hover:shadow-md ${borderClass}`}
|
||||
style={cardStyle}
|
||||
onClick={handleCardClick}
|
||||
role="button"
|
||||
|
|
@ -829,9 +1121,18 @@ function Card({
|
|||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleCardClick(); }}
|
||||
>
|
||||
{/* 헤더 영역 */}
|
||||
{(codeValue !== null || titleValue !== null) && (
|
||||
<div className={`border-b ${isCarted ? "bg-emerald-50 dark:bg-emerald-950/30" : "bg-muted/30"}`} style={headerStyle}>
|
||||
{(codeValue !== null || titleValue !== null || isCartListMode) && (
|
||||
<div className={`border-b ${headerBgClass}`} style={headerStyle}>
|
||||
<div className="flex items-center gap-2">
|
||||
{isCartListMode && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={(e) => { e.stopPropagation(); onToggleSelect?.(); }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-4 w-4 shrink-0 rounded border-input"
|
||||
/>
|
||||
)}
|
||||
{codeValue !== null && (
|
||||
<span
|
||||
className="shrink-0 font-medium text-muted-foreground"
|
||||
|
|
@ -892,8 +1193,8 @@ function Card({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 수량 버튼 + 담기/취소 버튼 (각각 독립 ON/OFF) */}
|
||||
{(inputField?.enabled || cartAction) && (
|
||||
{/* 오른쪽: 수량 버튼 + 담기/취소/삭제 버튼 */}
|
||||
{(inputField?.enabled || cartAction || isCartListMode) && (
|
||||
<div
|
||||
className="ml-2 flex shrink-0 flex-col items-stretch justify-center gap-2"
|
||||
style={{ minWidth: "100px" }}
|
||||
|
|
@ -903,7 +1204,7 @@ function Card({
|
|||
<button
|
||||
type="button"
|
||||
onClick={handleInputClick}
|
||||
className="rounded-lg border-2 border-gray-300 bg-white px-2 py-1.5 text-center hover:border-primary active:bg-gray-50"
|
||||
className="rounded-lg border-2 border-input bg-background px-2 py-1.5 text-center hover:border-primary active:bg-muted"
|
||||
>
|
||||
<span className="block text-lg font-bold leading-tight">
|
||||
{inputValue.toLocaleString()}
|
||||
|
|
@ -914,8 +1215,22 @@ function Card({
|
|||
</button>
|
||||
)}
|
||||
|
||||
{/* 담기/취소 버튼 (cartAction 존재 시 항상 표시) */}
|
||||
{cartAction && (
|
||||
{/* 장바구니 목록 모드: 삭제 버튼 */}
|
||||
{isCartListMode && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCartDelete}
|
||||
className="flex flex-col items-center justify-center gap-0.5 rounded-xl bg-destructive px-2 py-1.5 text-destructive-foreground transition-colors duration-150 hover:bg-destructive/90 active:bg-destructive/80"
|
||||
>
|
||||
<Trash2 size={iconSize} />
|
||||
<span style={{ fontSize: `${Math.max(9, scaled.bodyTextSize - 2)}px` }} className="font-semibold leading-tight">
|
||||
삭제
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 기본 모드: 담기/취소 버튼 (cartAction 존재 시) */}
|
||||
{!isCartListMode && cartAction && (
|
||||
<>
|
||||
{isCarted ? (
|
||||
<button
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -60,13 +60,17 @@ PopComponentRegistry.registerComponent({
|
|||
defaultProps: defaultConfig,
|
||||
connectionMeta: {
|
||||
sendable: [
|
||||
{ key: "selected_row", label: "선택된 행", type: "selected_row", description: "사용자가 선택한 카드의 행 데이터를 전달" },
|
||||
{ key: "cart_updated", label: "장바구니 상태", type: "event", description: "장바구니 변경 시 count/isDirty 전달" },
|
||||
{ key: "cart_save_completed", label: "저장 완료", type: "event", description: "장바구니 DB 저장 완료 후 결과 전달" },
|
||||
{ key: "selected_row", label: "선택된 행", type: "selected_row", category: "data", description: "사용자가 선택한 카드의 행 데이터를 전달" },
|
||||
{ key: "cart_updated", label: "장바구니 상태", type: "event", category: "event", description: "장바구니 변경 시 count/isDirty 전달" },
|
||||
{ key: "cart_save_completed", label: "저장 완료", type: "event", category: "event", description: "장바구니 DB 저장 완료 후 결과 전달" },
|
||||
{ key: "selected_items", label: "선택된 항목", type: "event", category: "event", description: "장바구니 모드에서 체크박스로 선택된 항목 배열" },
|
||||
{ key: "collected_data", label: "수집 응답", type: "event", category: "event", description: "데이터 수집 요청에 대한 응답 (선택 항목 + 매핑)" },
|
||||
],
|
||||
receivable: [
|
||||
{ key: "filter_condition", label: "필터 조건", type: "filter_value", description: "외부 컴포넌트에서 받은 필터 조건으로 카드 목록 필터링" },
|
||||
{ key: "cart_save_trigger", label: "저장 요청", type: "event", description: "장바구니 DB 일괄 저장 트리거 (버튼에서 수신)" },
|
||||
{ key: "filter_condition", label: "필터 조건", type: "filter_value", category: "filter", description: "외부 컴포넌트에서 받은 필터 조건으로 카드 목록 필터링" },
|
||||
{ key: "cart_save_trigger", label: "저장 요청", type: "event", category: "event", description: "장바구니 DB 일괄 저장 트리거 (버튼에서 수신)" },
|
||||
{ key: "confirm_trigger", label: "확정 트리거", type: "event", category: "event", description: "입고 확정 시 수정된 수량 일괄 반영 + 선택 항목 전달" },
|
||||
{ key: "collect_data", label: "수집 요청", type: "event", category: "event", description: "버튼에서 데이터+매핑 수집 요청 수신" },
|
||||
],
|
||||
},
|
||||
touchOptimized: true,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export interface ColumnInfo {
|
|||
name: string;
|
||||
type: string;
|
||||
udtName: string;
|
||||
isPrimaryKey?: boolean;
|
||||
}
|
||||
|
||||
// ===== SQL 값 이스케이프 =====
|
||||
|
|
@ -328,6 +329,7 @@ export async function fetchTableColumns(
|
|||
name: col.columnName || col.column_name || col.name,
|
||||
type: col.dataType || col.data_type || col.type || "unknown",
|
||||
udtName: col.dbType || col.udt_name || col.udtName || "unknown",
|
||||
isPrimaryKey: col.isPrimaryKey === true || col.isPrimaryKey === "true" || col.is_primary_key === true || col.is_primary_key === "true",
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,762 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { usePopEvent } from "@/hooks/pop";
|
||||
import { dataApi } from "@/lib/api/data";
|
||||
import type {
|
||||
PopFieldConfig,
|
||||
PopFieldItem,
|
||||
PopFieldSection,
|
||||
FieldSectionStyle,
|
||||
PopFieldReadSource,
|
||||
PopFieldAutoGenMapping,
|
||||
} from "./types";
|
||||
import type { CollectDataRequest, CollectedDataResponse } from "../types";
|
||||
import { DEFAULT_FIELD_CONFIG, DEFAULT_SECTION_APPEARANCES } from "./types";
|
||||
|
||||
// ========================================
|
||||
// Props
|
||||
// ========================================
|
||||
|
||||
interface PopFieldComponentProps {
|
||||
config?: PopFieldConfig;
|
||||
screenId?: string;
|
||||
componentId?: string;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 메인 컴포넌트
|
||||
// ========================================
|
||||
|
||||
export function PopFieldComponent({
|
||||
config,
|
||||
screenId,
|
||||
componentId,
|
||||
}: PopFieldComponentProps) {
|
||||
const cfg: PopFieldConfig = {
|
||||
...DEFAULT_FIELD_CONFIG,
|
||||
...config,
|
||||
sections: config?.sections?.length ? config.sections : DEFAULT_FIELD_CONFIG.sections,
|
||||
};
|
||||
const { publish, subscribe } = usePopEvent(screenId || "default");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [allValues, setAllValues] = useState<Record<string, unknown>>({});
|
||||
const [hiddenValues, setHiddenValues] = useState<Record<string, unknown>>({});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
|
||||
const hiddenMappings = cfg.saveConfig?.hiddenMappings ?? [];
|
||||
const autoGenMappings = cfg.saveConfig?.autoGenMappings ?? [];
|
||||
const visibleAutoGens = autoGenMappings.filter((m) => m.showInForm);
|
||||
|
||||
// ResizeObserver로 컨테이너 너비 감시
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !containerRef.current) return;
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
setContainerWidth(entry.contentRect.width);
|
||||
});
|
||||
ro.observe(containerRef.current);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// readSource 기반 DB 조회 + JSON 파싱
|
||||
const fetchReadSourceData = useCallback(
|
||||
async (pkValue: unknown, readSource: PopFieldReadSource) => {
|
||||
if (!readSource.tableName || !readSource.pkColumn || !pkValue) return;
|
||||
try {
|
||||
const res = await dataApi.getTableData(readSource.tableName, {
|
||||
page: 1,
|
||||
size: 1,
|
||||
filters: { [readSource.pkColumn]: String(pkValue) },
|
||||
});
|
||||
if (!Array.isArray(res.data) || res.data.length === 0) return;
|
||||
const row = res.data[0] as Record<string, unknown>;
|
||||
|
||||
const extracted: Record<string, unknown> = {};
|
||||
for (const mapping of readSource.fieldMappings || []) {
|
||||
if (mapping.valueSource === "json_extract" && mapping.columnName && mapping.jsonKey) {
|
||||
const raw = row[mapping.columnName];
|
||||
let parsed: Record<string, unknown> = {};
|
||||
if (typeof raw === "string") {
|
||||
try { parsed = JSON.parse(raw); } catch { /* ignore */ }
|
||||
} else if (typeof raw === "object" && raw !== null) {
|
||||
parsed = raw as Record<string, unknown>;
|
||||
}
|
||||
extracted[mapping.fieldId] = parsed[mapping.jsonKey] ?? "";
|
||||
} else if (mapping.valueSource === "db_column" && mapping.columnName) {
|
||||
extracted[mapping.fieldId] = row[mapping.columnName] ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
const allFieldsInConfig = cfg.sections.flatMap((s) => s.fields || []);
|
||||
const valuesUpdate: Record<string, unknown> = {};
|
||||
for (const [fieldId, val] of Object.entries(extracted)) {
|
||||
const f = allFieldsInConfig.find((fi) => fi.id === fieldId);
|
||||
const key = f?.fieldName || f?.id || fieldId;
|
||||
valuesUpdate[key] = val;
|
||||
}
|
||||
if (Object.keys(valuesUpdate).length > 0) {
|
||||
setAllValues((prev) => ({ ...prev, ...valuesUpdate }));
|
||||
}
|
||||
} catch {
|
||||
// 조회 실패 시 무시
|
||||
}
|
||||
},
|
||||
[cfg.sections]
|
||||
);
|
||||
|
||||
// set_value 이벤트 수신 (useConnectionResolver의 enrichedPayload도 처리)
|
||||
useEffect(() => {
|
||||
if (!componentId) return;
|
||||
const unsub = subscribe(
|
||||
`__comp_input__${componentId}__set_value`,
|
||||
(payload: unknown) => {
|
||||
const raw = payload as Record<string, unknown> | undefined;
|
||||
if (!raw) return;
|
||||
|
||||
// useConnectionResolver가 감싼 enrichedPayload인지 확인
|
||||
const isConnectionPayload = raw._connectionId !== undefined;
|
||||
const actual = isConnectionPayload
|
||||
? (raw.value as Record<string, unknown> | undefined)
|
||||
: raw;
|
||||
if (!actual) return;
|
||||
|
||||
const data = actual as {
|
||||
fieldName?: string;
|
||||
value?: unknown;
|
||||
values?: Record<string, unknown>;
|
||||
pkValue?: unknown;
|
||||
};
|
||||
|
||||
// row 객체가 통째로 온 경우 (pop-card-list selected_row 등)
|
||||
if (!data.fieldName && !data.values && !data.pkValue && typeof actual === "object") {
|
||||
const rowObj = actual as Record<string, unknown>;
|
||||
setAllValues((prev) => ({ ...prev, ...rowObj }));
|
||||
// 숨은 필드 값 추출 (valueSource 기반)
|
||||
if (hiddenMappings.length > 0) {
|
||||
const extracted: Record<string, unknown> = {};
|
||||
for (const hm of hiddenMappings) {
|
||||
if (hm.valueSource === "db_column" && hm.sourceDbColumn) {
|
||||
if (rowObj[hm.sourceDbColumn] !== undefined) {
|
||||
extracted[hm.targetColumn] = rowObj[hm.sourceDbColumn];
|
||||
}
|
||||
} else if (hm.valueSource === "json_extract" && hm.sourceJsonColumn && hm.sourceJsonKey) {
|
||||
const raw = rowObj[hm.sourceJsonColumn];
|
||||
let parsed: Record<string, unknown> = {};
|
||||
if (typeof raw === "string") {
|
||||
try { parsed = JSON.parse(raw); } catch { /* ignore */ }
|
||||
} else if (typeof raw === "object" && raw !== null) {
|
||||
parsed = raw as Record<string, unknown>;
|
||||
}
|
||||
if (parsed[hm.sourceJsonKey] !== undefined) {
|
||||
extracted[hm.targetColumn] = parsed[hm.sourceJsonKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.keys(extracted).length > 0) {
|
||||
setHiddenValues((prev) => ({ ...prev, ...extracted }));
|
||||
}
|
||||
}
|
||||
const pkCol = cfg.readSource?.pkColumn;
|
||||
const pkVal = pkCol ? rowObj[pkCol] : undefined;
|
||||
if (pkVal && cfg.readSource) {
|
||||
fetchReadSourceData(pkVal, cfg.readSource);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.values) {
|
||||
setAllValues((prev) => ({ ...prev, ...data.values }));
|
||||
} else if (data.fieldName) {
|
||||
setAllValues((prev) => ({
|
||||
...prev,
|
||||
[data.fieldName!]: data.value,
|
||||
}));
|
||||
}
|
||||
if (data.pkValue && cfg.readSource) {
|
||||
fetchReadSourceData(data.pkValue, cfg.readSource);
|
||||
}
|
||||
}
|
||||
);
|
||||
return unsub;
|
||||
}, [componentId, subscribe, cfg.readSource, fetchReadSourceData]);
|
||||
|
||||
// 데이터 수집 요청 수신: 버튼에서 collect_data 요청 → allValues + saveConfig 응답
|
||||
useEffect(() => {
|
||||
if (!componentId) return;
|
||||
const unsub = subscribe(
|
||||
`__comp_input__${componentId}__collect_data`,
|
||||
(payload: unknown) => {
|
||||
const request = (payload as Record<string, unknown>)?.value as CollectDataRequest | undefined;
|
||||
|
||||
const response: CollectedDataResponse = {
|
||||
requestId: request?.requestId ?? "",
|
||||
componentId: componentId,
|
||||
componentType: "pop-field",
|
||||
data: { values: allValues },
|
||||
mapping: cfg.saveConfig?.tableName
|
||||
? {
|
||||
targetTable: cfg.saveConfig.tableName,
|
||||
columnMapping: Object.fromEntries(
|
||||
(cfg.saveConfig.fieldMappings || []).map((m) => [m.fieldId, m.targetColumn])
|
||||
),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
publish(`__comp_output__${componentId}__collected_data`, response);
|
||||
}
|
||||
);
|
||||
return unsub;
|
||||
}, [componentId, subscribe, publish, allValues, cfg.saveConfig]);
|
||||
|
||||
// 필드 값 변경 핸들러
|
||||
const handleFieldChange = useCallback(
|
||||
(fieldName: string, value: unknown) => {
|
||||
setAllValues((prev) => {
|
||||
const next = { ...prev, [fieldName]: value };
|
||||
if (componentId) {
|
||||
publish(`__comp_output__${componentId}__value_changed`, {
|
||||
fieldName,
|
||||
value,
|
||||
allValues: next,
|
||||
hiddenValues,
|
||||
targetTable: cfg.saveConfig?.tableName || cfg.targetTable,
|
||||
saveConfig: cfg.saveConfig,
|
||||
readSource: cfg.readSource,
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setErrors((prev) => {
|
||||
if (!prev[fieldName]) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[fieldName];
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[componentId, publish, cfg.targetTable, cfg.saveConfig, cfg.readSource, hiddenValues]
|
||||
);
|
||||
|
||||
// readSource 설정 시 자동 샘플 데이터 조회 (디자인 모드 미리보기)
|
||||
const readSourceKey = cfg.readSource
|
||||
? `${cfg.readSource.tableName}__${cfg.readSource.pkColumn}__${(cfg.readSource.fieldMappings || []).map((m) => `${m.fieldId}:${m.columnName}:${m.jsonKey || ""}`).join(",")}`
|
||||
: "";
|
||||
const previewFetchedRef = useRef("");
|
||||
useEffect(() => {
|
||||
if (!cfg.readSource?.tableName || !cfg.readSource.fieldMappings?.length) return;
|
||||
if (previewFetchedRef.current === readSourceKey) return;
|
||||
previewFetchedRef.current = readSourceKey;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await dataApi.getTableData(cfg.readSource!.tableName, {
|
||||
page: 1,
|
||||
size: 1,
|
||||
});
|
||||
if (!Array.isArray(res.data) || res.data.length === 0) return;
|
||||
const row = res.data[0] as Record<string, unknown>;
|
||||
|
||||
const extracted: Record<string, unknown> = {};
|
||||
for (const mapping of cfg.readSource!.fieldMappings || []) {
|
||||
if (mapping.valueSource === "json_extract" && mapping.columnName && mapping.jsonKey) {
|
||||
const rawVal = row[mapping.columnName];
|
||||
let parsed: Record<string, unknown> = {};
|
||||
if (typeof rawVal === "string") {
|
||||
try { parsed = JSON.parse(rawVal); } catch { /* ignore */ }
|
||||
} else if (typeof rawVal === "object" && rawVal !== null) {
|
||||
parsed = rawVal as Record<string, unknown>;
|
||||
}
|
||||
extracted[mapping.fieldId] = parsed[mapping.jsonKey] ?? "";
|
||||
} else if (mapping.valueSource === "db_column" && mapping.columnName) {
|
||||
extracted[mapping.fieldId] = row[mapping.columnName] ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
const allFieldsInConfig = cfg.sections.flatMap((s) => s.fields || []);
|
||||
const valuesUpdate: Record<string, unknown> = {};
|
||||
for (const [fieldId, val] of Object.entries(extracted)) {
|
||||
const f = allFieldsInConfig.find((fi) => fi.id === fieldId);
|
||||
const key = f?.fieldName || f?.id || fieldId;
|
||||
valuesUpdate[key] = val;
|
||||
}
|
||||
if (Object.keys(valuesUpdate).length > 0) {
|
||||
setAllValues((prev) => ({ ...prev, ...valuesUpdate }));
|
||||
}
|
||||
} catch {
|
||||
// 미리보기 조회 실패 시 무시
|
||||
}
|
||||
})();
|
||||
}, [readSourceKey, cfg.readSource, cfg.sections]);
|
||||
|
||||
// "auto" 열 수 계산
|
||||
function resolveColumns(
|
||||
columns: "auto" | 1 | 2 | 3 | 4,
|
||||
fieldCount: number
|
||||
): number {
|
||||
if (columns !== "auto") return columns;
|
||||
if (containerWidth < 200) return 1;
|
||||
if (containerWidth < 400) return Math.min(2, fieldCount);
|
||||
if (containerWidth < 600) return Math.min(3, fieldCount);
|
||||
return Math.min(4, fieldCount);
|
||||
}
|
||||
|
||||
function migrateStyle(style: string): FieldSectionStyle {
|
||||
if (style === "display" || style === "input") return style;
|
||||
if (style === "summary") return "display";
|
||||
if (style === "form") return "input";
|
||||
return "input";
|
||||
}
|
||||
|
||||
function sectionClassName(section: PopFieldSection): string {
|
||||
const resolved = migrateStyle(section.style);
|
||||
const defaults = DEFAULT_SECTION_APPEARANCES[resolved];
|
||||
const a = section.appearance || {};
|
||||
const bg = a.bgColor || defaults.bgColor;
|
||||
const border = a.borderColor || defaults.borderColor;
|
||||
return cn("rounded-lg border px-4", bg, border, resolved === "display" ? "py-2" : "py-3");
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex h-full w-full flex-col gap-2 overflow-auto p-1"
|
||||
>
|
||||
{cfg.sections.map((section) => {
|
||||
const fields = section.fields || [];
|
||||
const fieldCount = fields.length;
|
||||
if (fieldCount === 0) return null;
|
||||
const cols = resolveColumns(section.columns, fieldCount);
|
||||
return (
|
||||
<div key={section.id} className={sectionClassName(section)}>
|
||||
{section.label && (
|
||||
<div className="mb-1 text-xs font-medium text-muted-foreground">
|
||||
{section.label}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="grid gap-3"
|
||||
style={{ gridTemplateColumns: `repeat(${cols}, 1fr)` }}
|
||||
>
|
||||
{fields.map((field) => {
|
||||
const fKey = field.fieldName || field.id;
|
||||
return (
|
||||
<FieldRenderer
|
||||
key={field.id}
|
||||
field={{ ...field, fieldName: fKey }}
|
||||
value={allValues[fKey]}
|
||||
showLabel={section.showLabels}
|
||||
error={errors[fKey]}
|
||||
onChange={handleFieldChange}
|
||||
sectionStyle={section.style}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{visibleAutoGens.length > 0 && (
|
||||
<div className="rounded-lg border border-border bg-background px-4 py-3">
|
||||
<div
|
||||
className="grid gap-3"
|
||||
style={{ gridTemplateColumns: `repeat(${resolveColumns("auto", visibleAutoGens.length)}, 1fr)` }}
|
||||
>
|
||||
{visibleAutoGens.map((ag) => (
|
||||
<AutoGenFieldDisplay key={ag.id} mapping={ag} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// FieldRenderer: 개별 필드 렌더링
|
||||
// ========================================
|
||||
|
||||
interface FieldRendererProps {
|
||||
field: PopFieldItem;
|
||||
value: unknown;
|
||||
showLabel: boolean;
|
||||
error?: string;
|
||||
onChange: (fieldName: string, value: unknown) => void;
|
||||
sectionStyle: FieldSectionStyle;
|
||||
}
|
||||
|
||||
function FieldRenderer({
|
||||
field,
|
||||
value,
|
||||
showLabel,
|
||||
error,
|
||||
onChange,
|
||||
sectionStyle,
|
||||
}: FieldRendererProps) {
|
||||
const handleChange = useCallback(
|
||||
(v: unknown) => onChange(field.fieldName, v),
|
||||
[onChange, field.fieldName]
|
||||
);
|
||||
|
||||
const resolvedStyle = sectionStyle === "summary" ? "display" : sectionStyle === "form" ? "input" : sectionStyle;
|
||||
const inputClassName = cn(
|
||||
"h-9 w-full rounded-md border px-3 text-sm",
|
||||
field.readOnly
|
||||
? "cursor-default bg-muted text-muted-foreground"
|
||||
: "bg-background",
|
||||
resolvedStyle === "display" &&
|
||||
"border-transparent bg-transparent text-sm font-medium"
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{showLabel && field.labelText && (
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{field.labelText}
|
||||
{field.validation?.required && (
|
||||
<span className="ml-0.5 text-destructive">*</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
{renderByType(field, value, handleChange, inputClassName)}
|
||||
{error && <p className="text-[10px] text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 서브타입별 렌더링 분기
|
||||
// ========================================
|
||||
|
||||
function renderByType(
|
||||
field: PopFieldItem,
|
||||
value: unknown,
|
||||
onChange: (v: unknown) => void,
|
||||
className: string
|
||||
) {
|
||||
switch (field.inputType) {
|
||||
case "text":
|
||||
return (
|
||||
<Input
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
readOnly={field.readOnly}
|
||||
placeholder={field.placeholder}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
case "number":
|
||||
return (
|
||||
<NumberFieldInput
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
case "date":
|
||||
return (
|
||||
<Input
|
||||
type="date"
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
readOnly={field.readOnly}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
case "select":
|
||||
return (
|
||||
<SelectFieldInput
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
case "auto":
|
||||
return <AutoFieldInput field={field} value={value} className={className} />;
|
||||
case "numpad":
|
||||
return (
|
||||
<NumpadFieldInput
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Input
|
||||
value={String(value ?? "")}
|
||||
readOnly
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// NumberFieldInput
|
||||
// ========================================
|
||||
|
||||
function NumberFieldInput({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: {
|
||||
field: PopFieldItem;
|
||||
value: unknown;
|
||||
onChange: (v: unknown) => void;
|
||||
className: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
value={value !== undefined && value !== null ? String(value) : ""}
|
||||
onChange={(e) => {
|
||||
const num = e.target.value === "" ? "" : Number(e.target.value);
|
||||
onChange(num);
|
||||
}}
|
||||
readOnly={field.readOnly}
|
||||
placeholder={field.placeholder}
|
||||
min={field.validation?.min}
|
||||
max={field.validation?.max}
|
||||
className={cn(className, "flex-1")}
|
||||
/>
|
||||
{field.unit && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{field.unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// SelectFieldInput
|
||||
// ========================================
|
||||
|
||||
function SelectFieldInput({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: {
|
||||
field: PopFieldItem;
|
||||
value: unknown;
|
||||
onChange: (v: unknown) => void;
|
||||
className: string;
|
||||
}) {
|
||||
const [options, setOptions] = useState<{ value: string; label: string }[]>(
|
||||
[]
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const source = field.selectSource;
|
||||
|
||||
useEffect(() => {
|
||||
if (!source) return;
|
||||
|
||||
if (source.type === "static" && source.staticOptions) {
|
||||
setOptions(source.staticOptions);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
source.type === "table" &&
|
||||
source.tableName &&
|
||||
source.valueColumn &&
|
||||
source.labelColumn
|
||||
) {
|
||||
setLoading(true);
|
||||
dataApi
|
||||
.getTableData(source.tableName, {
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
sortColumn: source.labelColumn,
|
||||
sortDirection: "asc",
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.data?.success && Array.isArray(res.data.data?.data)) {
|
||||
setOptions(
|
||||
res.data.data.data.map((row: Record<string, unknown>) => ({
|
||||
value: String(row[source.valueColumn!] ?? ""),
|
||||
label: String(row[source.labelColumn!] ?? ""),
|
||||
}))
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setOptions([]);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [source?.type, source?.tableName, source?.valueColumn, source?.labelColumn, source?.staticOptions]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={cn(className, "flex items-center justify-center")}>
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.readOnly) {
|
||||
const selectedLabel =
|
||||
options.find((o) => o.value === String(value ?? ""))?.label ??
|
||||
String(value ?? "-");
|
||||
return (
|
||||
<Input value={selectedLabel} readOnly className={className} />
|
||||
);
|
||||
}
|
||||
|
||||
if (!source) {
|
||||
return (
|
||||
<div className={cn(className, "flex items-center text-muted-foreground")}>
|
||||
옵션 소스를 설정해주세요
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={String(value ?? "")}
|
||||
onValueChange={(v) => onChange(v)}
|
||||
>
|
||||
<SelectTrigger className={cn(className, "justify-between")}>
|
||||
<SelectValue placeholder={field.placeholder || "선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.length === 0 ? (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
옵션이 없습니다
|
||||
</div>
|
||||
) : (
|
||||
options.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// AutoFieldInput (자동 채번 - 읽기전용)
|
||||
// ========================================
|
||||
|
||||
function AutoFieldInput({
|
||||
field,
|
||||
value,
|
||||
className,
|
||||
}: {
|
||||
field: PopFieldItem;
|
||||
value: unknown;
|
||||
className: string;
|
||||
}) {
|
||||
const displayValue = useMemo(() => {
|
||||
if (value) return String(value);
|
||||
if (!field.autoNumber) return "자동생성";
|
||||
|
||||
const { prefix, separator, dateFormat, sequenceDigits } = field.autoNumber;
|
||||
const parts: string[] = [];
|
||||
if (prefix) parts.push(prefix);
|
||||
if (dateFormat) {
|
||||
const now = new Date();
|
||||
const dateStr = dateFormat
|
||||
.replace("YYYY", String(now.getFullYear()))
|
||||
.replace("MM", String(now.getMonth() + 1).padStart(2, "0"))
|
||||
.replace("DD", String(now.getDate()).padStart(2, "0"));
|
||||
parts.push(dateStr);
|
||||
}
|
||||
if (sequenceDigits) {
|
||||
parts.push("0".repeat(sequenceDigits));
|
||||
}
|
||||
return parts.join(separator || "-") || "자동생성";
|
||||
}, [value, field.autoNumber]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
value={displayValue}
|
||||
readOnly
|
||||
className={cn(className, "cursor-default bg-muted text-muted-foreground")}
|
||||
placeholder="자동생성"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// AutoGenFieldDisplay (자동생성 필드 - showInForm일 때 표시)
|
||||
// ========================================
|
||||
|
||||
function AutoGenFieldDisplay({ mapping }: { mapping: PopFieldAutoGenMapping }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{mapping.label && (
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{mapping.label}
|
||||
</label>
|
||||
)}
|
||||
<div className="flex h-9 items-center rounded-md border border-dashed border-muted-foreground/30 bg-muted px-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
저장 시 자동발급
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// NumpadFieldInput (클릭 시 숫자 직접 입력)
|
||||
// ========================================
|
||||
|
||||
function NumpadFieldInput({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: {
|
||||
field: PopFieldItem;
|
||||
value: unknown;
|
||||
onChange: (v: unknown) => void;
|
||||
className: string;
|
||||
}) {
|
||||
const displayValue =
|
||||
value !== undefined && value !== null ? String(value) : "";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
value={displayValue}
|
||||
onChange={(e) => {
|
||||
const num = e.target.value === "" ? "" : Number(e.target.value);
|
||||
onChange(num);
|
||||
}}
|
||||
readOnly={field.readOnly}
|
||||
placeholder={field.placeholder || "수량 입력"}
|
||||
className={cn(className, "flex-1")}
|
||||
/>
|
||||
{field.unit && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{field.unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,99 @@
|
|||
"use client";
|
||||
|
||||
import { PopComponentRegistry } from "../../PopComponentRegistry";
|
||||
import { PopFieldComponent } from "./PopFieldComponent";
|
||||
import { PopFieldConfigPanel } from "./PopFieldConfig";
|
||||
import type { PopFieldConfig } from "./types";
|
||||
import { DEFAULT_FIELD_CONFIG, FIELD_INPUT_TYPE_LABELS } from "./types";
|
||||
|
||||
function PopFieldPreviewComponent({
|
||||
config,
|
||||
label,
|
||||
}: {
|
||||
config?: PopFieldConfig;
|
||||
label?: string;
|
||||
}) {
|
||||
const cfg: PopFieldConfig = {
|
||||
...DEFAULT_FIELD_CONFIG,
|
||||
...config,
|
||||
sections: config?.sections?.length ? config.sections : DEFAULT_FIELD_CONFIG.sections,
|
||||
};
|
||||
const totalFields = cfg.sections.reduce(
|
||||
(sum, s) => sum + (s.fields?.length || 0),
|
||||
0
|
||||
);
|
||||
const sectionCount = cfg.sections.length;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-2">
|
||||
<span className="text-[10px] font-medium text-muted-foreground">
|
||||
{label || "입력 필드"}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{cfg.sections.map((section) =>
|
||||
(section.fields || []).slice(0, 3).map((field) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="flex h-5 items-center rounded border border-dashed border-muted-foreground/30 px-1.5"
|
||||
>
|
||||
<span className="text-[8px] text-muted-foreground">
|
||||
{field.labelText || field.fieldName || FIELD_INPUT_TYPE_LABELS[field.inputType]}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[8px] text-muted-foreground">
|
||||
{sectionCount}섹션 / {totalFields}필드
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
PopComponentRegistry.registerComponent({
|
||||
id: "pop-field",
|
||||
name: "입력 필드",
|
||||
description: "저장용 값 입력 (섹션별 멀티필드, 읽기전용/입력 혼합)",
|
||||
category: "input",
|
||||
icon: "TextCursorInput",
|
||||
component: PopFieldComponent,
|
||||
configPanel: PopFieldConfigPanel,
|
||||
preview: PopFieldPreviewComponent,
|
||||
defaultProps: DEFAULT_FIELD_CONFIG,
|
||||
connectionMeta: {
|
||||
sendable: [
|
||||
{
|
||||
key: "value_changed",
|
||||
label: "값 변경",
|
||||
type: "value",
|
||||
category: "data",
|
||||
description: "필드값 변경 시 fieldName + value + allValues 전달",
|
||||
},
|
||||
{
|
||||
key: "collected_data",
|
||||
label: "수집 응답",
|
||||
type: "event",
|
||||
category: "event",
|
||||
description: "데이터 수집 요청에 대한 응답 (입력값 + 매핑)",
|
||||
},
|
||||
],
|
||||
receivable: [
|
||||
{
|
||||
key: "set_value",
|
||||
label: "값 설정",
|
||||
type: "value",
|
||||
category: "data",
|
||||
description: "외부에서 특정 필드 또는 일괄로 값 세팅",
|
||||
},
|
||||
{
|
||||
key: "collect_data",
|
||||
label: "수집 요청",
|
||||
type: "event",
|
||||
category: "event",
|
||||
description: "버튼에서 데이터+매핑 수집 요청 수신",
|
||||
},
|
||||
],
|
||||
},
|
||||
touchOptimized: true,
|
||||
supportedDevices: ["mobile", "tablet"],
|
||||
});
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
/**
|
||||
* pop-field 타입 정의
|
||||
* 섹션 기반 멀티필드: 하나의 컴포넌트 안에 여러 섹션, 각 섹션에 여러 필드
|
||||
* 저장용 값 입력 (text/number/date/select/auto/numpad)
|
||||
*/
|
||||
|
||||
import type { DataSourceFilter } from "../types";
|
||||
|
||||
// ===== 서브타입 =====
|
||||
|
||||
export type FieldInputType = "text" | "number" | "date" | "select" | "auto" | "numpad";
|
||||
|
||||
export const FIELD_INPUT_TYPE_LABELS: Record<FieldInputType, string> = {
|
||||
text: "텍스트",
|
||||
number: "숫자",
|
||||
date: "날짜",
|
||||
select: "선택",
|
||||
auto: "자동채번",
|
||||
numpad: "숫자패드",
|
||||
};
|
||||
|
||||
// ===== 섹션 스타일 =====
|
||||
|
||||
export type FieldSectionStyle = "display" | "input";
|
||||
|
||||
export const FIELD_SECTION_STYLE_LABELS: Record<FieldSectionStyle, string> = {
|
||||
display: "읽기 폼",
|
||||
input: "입력 폼",
|
||||
};
|
||||
|
||||
// 섹션 커스텀 외관 옵션
|
||||
export interface FieldSectionAppearance {
|
||||
bgColor?: string;
|
||||
borderColor?: string;
|
||||
textColor?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_SECTION_APPEARANCES: Record<FieldSectionStyle, FieldSectionAppearance> = {
|
||||
display: { bgColor: "bg-emerald-50", borderColor: "border-emerald-200", textColor: "text-foreground" },
|
||||
input: { bgColor: "bg-background", borderColor: "border-border", textColor: "text-foreground" },
|
||||
};
|
||||
|
||||
// ===== select 옵션 소스 =====
|
||||
|
||||
export type FieldSelectSourceType = "static" | "table";
|
||||
|
||||
export interface FieldSelectSource {
|
||||
type: FieldSelectSourceType;
|
||||
staticOptions?: { value: string; label: string }[];
|
||||
tableName?: string;
|
||||
valueColumn?: string;
|
||||
labelColumn?: string;
|
||||
filters?: DataSourceFilter[];
|
||||
}
|
||||
|
||||
// ===== 자동 채번 설정 =====
|
||||
|
||||
export interface AutoNumberConfig {
|
||||
prefix?: string;
|
||||
dateFormat?: string;
|
||||
separator?: string;
|
||||
sequenceDigits?: number;
|
||||
numberingRuleId?: string;
|
||||
}
|
||||
|
||||
// ===== 유효성 검증 =====
|
||||
|
||||
export interface PopFieldValidation {
|
||||
required?: boolean;
|
||||
min?: number;
|
||||
max?: number;
|
||||
pattern?: string;
|
||||
customMessage?: string;
|
||||
}
|
||||
|
||||
// ===== 개별 필드 정의 =====
|
||||
|
||||
export interface PopFieldItem {
|
||||
id: string;
|
||||
inputType: FieldInputType;
|
||||
fieldName: string;
|
||||
labelText?: string;
|
||||
placeholder?: string;
|
||||
defaultValue?: unknown;
|
||||
readOnly?: boolean;
|
||||
unit?: string;
|
||||
|
||||
selectSource?: FieldSelectSource;
|
||||
autoNumber?: AutoNumberConfig;
|
||||
validation?: PopFieldValidation;
|
||||
}
|
||||
|
||||
// ===== 섹션 정의 =====
|
||||
|
||||
export interface PopFieldSection {
|
||||
id: string;
|
||||
label?: string;
|
||||
style: FieldSectionStyle;
|
||||
columns: "auto" | 1 | 2 | 3 | 4;
|
||||
showLabels: boolean;
|
||||
appearance?: FieldSectionAppearance;
|
||||
fields: PopFieldItem[];
|
||||
}
|
||||
|
||||
// ===== 저장 설정: 값 소스 타입 =====
|
||||
|
||||
export type FieldValueSource = "direct" | "json_extract" | "db_column";
|
||||
|
||||
export const FIELD_VALUE_SOURCE_LABELS: Record<FieldValueSource, string> = {
|
||||
direct: "직접 입력",
|
||||
json_extract: "JSON 추출",
|
||||
db_column: "DB 컬럼",
|
||||
};
|
||||
|
||||
// ===== 저장 설정: 필드-컬럼 매핑 =====
|
||||
|
||||
export interface PopFieldSaveMapping {
|
||||
fieldId: string;
|
||||
valueSource: FieldValueSource;
|
||||
targetColumn: string;
|
||||
sourceJsonColumn?: string;
|
||||
sourceJsonKey?: string;
|
||||
}
|
||||
|
||||
// ===== 숨은 필드 매핑 (UI에 미표시, 전달 데이터에서 추출하여 저장) =====
|
||||
|
||||
export type HiddenValueSource = "json_extract" | "db_column";
|
||||
|
||||
export interface PopFieldHiddenMapping {
|
||||
id: string;
|
||||
label?: string;
|
||||
valueSource: HiddenValueSource;
|
||||
sourceJsonColumn?: string;
|
||||
sourceJsonKey?: string;
|
||||
sourceDbColumn?: string;
|
||||
targetColumn: string;
|
||||
}
|
||||
|
||||
// ===== 자동생성 필드 (서버 채번규칙으로 저장 시점 생성) =====
|
||||
|
||||
export interface PopFieldAutoGenMapping {
|
||||
id: string;
|
||||
linkedFieldId?: string;
|
||||
label: string;
|
||||
targetColumn: string;
|
||||
numberingRuleId?: string;
|
||||
showInForm: boolean;
|
||||
showResultModal: boolean;
|
||||
}
|
||||
|
||||
export interface PopFieldSaveConfig {
|
||||
tableName: string;
|
||||
fieldMappings: PopFieldSaveMapping[];
|
||||
hiddenMappings?: PopFieldHiddenMapping[];
|
||||
autoGenMappings?: PopFieldAutoGenMapping[];
|
||||
}
|
||||
|
||||
// ===== 읽기 데이터 소스 =====
|
||||
|
||||
export interface PopFieldReadMapping {
|
||||
fieldId: string;
|
||||
valueSource: FieldValueSource;
|
||||
columnName: string;
|
||||
jsonKey?: string;
|
||||
}
|
||||
|
||||
export interface PopFieldReadSource {
|
||||
tableName: string;
|
||||
pkColumn: string;
|
||||
fieldMappings: PopFieldReadMapping[];
|
||||
}
|
||||
|
||||
// ===== pop-field 전체 설정 (루트) =====
|
||||
|
||||
export interface PopFieldConfig {
|
||||
targetTable?: string;
|
||||
sections: PopFieldSection[];
|
||||
saveConfig?: PopFieldSaveConfig;
|
||||
readSource?: PopFieldReadSource;
|
||||
}
|
||||
|
||||
// ===== 기본값 =====
|
||||
|
||||
export const DEFAULT_FIELD_CONFIG: PopFieldConfig = {
|
||||
targetTable: "",
|
||||
sections: [
|
||||
{
|
||||
id: "section_display",
|
||||
label: "요약",
|
||||
style: "display",
|
||||
columns: "auto",
|
||||
showLabels: true,
|
||||
fields: [
|
||||
{ id: "f_disp_1", inputType: "text", fieldName: "", labelText: "항목1", readOnly: true },
|
||||
{ id: "f_disp_2", inputType: "text", fieldName: "", labelText: "항목2", readOnly: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "section_input",
|
||||
label: "입력",
|
||||
style: "input",
|
||||
columns: "auto",
|
||||
showLabels: true,
|
||||
fields: [
|
||||
{ id: "f_input_1", inputType: "text", fieldName: "", labelText: "필드1" },
|
||||
{ id: "f_input_2", inputType: "number", fieldName: "", labelText: "필드2", unit: "EA" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -36,10 +36,10 @@ PopComponentRegistry.registerComponent({
|
|||
defaultProps: DEFAULT_SEARCH_CONFIG,
|
||||
connectionMeta: {
|
||||
sendable: [
|
||||
{ key: "filter_value", label: "필터 값", type: "filter_value", description: "입력한 검색 조건을 다른 컴포넌트에 전달" },
|
||||
{ key: "filter_value", label: "필터 값", type: "filter_value", category: "filter", description: "입력한 검색 조건을 다른 컴포넌트에 전달" },
|
||||
],
|
||||
receivable: [
|
||||
{ key: "set_value", label: "값 설정", type: "filter_value", description: "외부에서 값을 받아 검색 필드에 세팅 (스캔, 모달 선택 등)" },
|
||||
{ key: "set_value", label: "값 설정", type: "filter_value", category: "filter", description: "외부에서 값을 받아 검색 필드에 세팅 (스캔, 모달 선택 등)" },
|
||||
],
|
||||
},
|
||||
touchOptimized: true,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ColumnInfo } from "../pop-dashboard/utils/dataFetcher";
|
||||
|
||||
interface ColumnComboboxProps {
|
||||
columns: ColumnInfo[];
|
||||
value: string;
|
||||
onSelect: (columnName: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function ColumnCombobox({
|
||||
columns,
|
||||
value,
|
||||
onSelect,
|
||||
placeholder = "컬럼을 선택하세요",
|
||||
}: ColumnComboboxProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return columns;
|
||||
const q = search.toLowerCase();
|
||||
return columns.filter((c) => c.name.toLowerCase().includes(q));
|
||||
}, [columns, search]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="mt-1 h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{value || placeholder}
|
||||
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder="컬럼명 검색..."
|
||||
className="text-xs"
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-4 text-center text-xs">
|
||||
검색 결과가 없습니다.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filtered.map((col) => (
|
||||
<CommandItem
|
||||
key={col.name}
|
||||
value={col.name}
|
||||
onSelect={() => {
|
||||
onSelect(col.name);
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3.5 w-3.5",
|
||||
value === col.name ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{col.name}</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{col.type}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TableInfo } from "../pop-dashboard/utils/dataFetcher";
|
||||
|
||||
interface TableComboboxProps {
|
||||
tables: TableInfo[];
|
||||
value: string;
|
||||
onSelect: (tableName: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function TableCombobox({
|
||||
tables,
|
||||
value,
|
||||
onSelect,
|
||||
placeholder = "테이블을 선택하세요",
|
||||
}: TableComboboxProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const selectedLabel = useMemo(() => {
|
||||
const found = tables.find((t) => t.tableName === value);
|
||||
return found ? (found.displayName || found.tableName) : "";
|
||||
}, [tables, value]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return tables;
|
||||
const q = search.toLowerCase();
|
||||
return tables.filter(
|
||||
(t) =>
|
||||
t.tableName.toLowerCase().includes(q) ||
|
||||
(t.displayName && t.displayName.toLowerCase().includes(q))
|
||||
);
|
||||
}, [tables, search]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="mt-1 h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{value ? selectedLabel : placeholder}
|
||||
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder="테이블명 또는 한글명 검색..."
|
||||
className="text-xs"
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-4 text-center text-xs">
|
||||
검색 결과가 없습니다.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filtered.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={table.tableName}
|
||||
onSelect={() => {
|
||||
onSelect(table.tableName);
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3.5 w-3.5",
|
||||
value === table.tableName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span>{table.displayName || table.tableName}</span>
|
||||
{table.displayName && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{table.tableName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
|
@ -35,10 +35,10 @@ PopComponentRegistry.registerComponent({
|
|||
defaultProps: defaultConfig,
|
||||
connectionMeta: {
|
||||
sendable: [
|
||||
{ key: "selected_row", label: "선택된 행", type: "selected_row", description: "사용자가 선택한 행 데이터를 전달" },
|
||||
{ key: "selected_row", label: "선택된 행", type: "selected_row", category: "data", description: "사용자가 선택한 행 데이터를 전달" },
|
||||
],
|
||||
receivable: [
|
||||
{ key: "filter_condition", label: "필터 조건", type: "filter_value", description: "외부 컴포넌트에서 받은 필터 조건으로 목록 필터링" },
|
||||
{ key: "filter_condition", label: "필터 조건", type: "filter_value", category: "filter", description: "외부 컴포넌트에서 받은 필터 조건으로 목록 필터링" },
|
||||
],
|
||||
},
|
||||
touchOptimized: true,
|
||||
|
|
|
|||
|
|
@ -509,7 +509,7 @@ export type CartItemStatus = "in_cart" | "confirmed" | "cancelled";
|
|||
export interface CartItemWithId extends CartItem {
|
||||
cartId?: string; // DB id (UUID, 저장 후 할당)
|
||||
sourceTable: string; // 원본 테이블명
|
||||
rowKey: string; // 원본 행 식별키 (codeField 값)
|
||||
rowKey: string; // 원본 행 식별키 (keyColumn 값, 기본 id)
|
||||
status: CartItemStatus;
|
||||
_origin: CartItemOrigin; // DB에서 로드 vs 로컬에서 추가
|
||||
memo?: string;
|
||||
|
|
@ -523,7 +523,7 @@ export type CartSaveMode = "cart" | "direct";
|
|||
|
||||
export interface CardCartActionConfig {
|
||||
saveMode: CartSaveMode; // "cart": 장바구니 임시저장, "direct": 대상 테이블 직접 저장
|
||||
cartType?: string; // 장바구니 구분값 (예: "purchase_inbound")
|
||||
keyColumn?: string; // 행 식별 키 컬럼 (기본: "id")
|
||||
label?: string; // 담기 라벨 (기본: "담기")
|
||||
cancelLabel?: string; // 취소 라벨 (기본: "취소")
|
||||
// 하위 호환: 이전 dataFields (사용하지 않지만 기존 데이터 보호)
|
||||
|
|
@ -608,6 +608,86 @@ export interface CardResponsiveConfig {
|
|||
fields?: Record<string, ResponsiveDisplayMode>;
|
||||
}
|
||||
|
||||
// ----- 장바구니 목록 모드 설정 -----
|
||||
|
||||
export interface CartListModeConfig {
|
||||
enabled: boolean;
|
||||
sourceScreenId?: number;
|
||||
sourceComponentId?: string;
|
||||
statusFilter?: string;
|
||||
}
|
||||
|
||||
// ----- 데이터 수집 패턴 (pop-button ↔ 컴포넌트 간 요청-응답) -----
|
||||
|
||||
export interface CollectDataRequest {
|
||||
requestId: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
export interface CollectedDataResponse {
|
||||
requestId: string;
|
||||
componentId: string;
|
||||
componentType: string;
|
||||
data: {
|
||||
items?: Record<string, unknown>[];
|
||||
values?: Record<string, unknown>;
|
||||
};
|
||||
mapping?: SaveMapping | null;
|
||||
}
|
||||
|
||||
export interface SaveMapping {
|
||||
targetTable: string;
|
||||
columnMapping: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface StatusChangeRule {
|
||||
targetTable: string;
|
||||
targetColumn: string;
|
||||
lookupMode?: "auto" | "manual";
|
||||
manualItemField?: string;
|
||||
manualPkColumn?: string;
|
||||
valueType: "fixed" | "conditional";
|
||||
fixedValue?: string;
|
||||
conditionalValue?: ConditionalValue;
|
||||
}
|
||||
|
||||
export interface ConditionalValue {
|
||||
conditions: StatusCondition[];
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
export interface StatusCondition {
|
||||
whenColumn: string;
|
||||
operator: "=" | "!=" | ">" | "<" | ">=" | "<=";
|
||||
whenValue: string;
|
||||
thenValue: string;
|
||||
}
|
||||
|
||||
export interface ExecuteActionPayload {
|
||||
inserts: {
|
||||
table: string;
|
||||
records: Record<string, unknown>[];
|
||||
}[];
|
||||
statusChanges: {
|
||||
table: string;
|
||||
column: string;
|
||||
value: string;
|
||||
where: Record<string, unknown>;
|
||||
}[];
|
||||
}
|
||||
|
||||
// ----- 저장 매핑 (장바구니 -> 대상 테이블) -----
|
||||
|
||||
export interface CardListSaveMappingEntry {
|
||||
sourceField: string;
|
||||
targetColumn: string;
|
||||
}
|
||||
|
||||
export interface CardListSaveMapping {
|
||||
targetTable: string;
|
||||
mappings: CardListSaveMappingEntry[];
|
||||
}
|
||||
|
||||
// ----- pop-card-list 전체 설정 -----
|
||||
|
||||
export interface PopCardListConfig {
|
||||
|
|
@ -620,10 +700,12 @@ export interface PopCardListConfig {
|
|||
gridColumns?: number;
|
||||
gridRows?: number;
|
||||
|
||||
// 반응형 표시 설정
|
||||
responsiveDisplay?: CardResponsiveConfig;
|
||||
|
||||
inputField?: CardInputFieldConfig;
|
||||
packageConfig?: CardPackageConfig;
|
||||
cartAction?: CardCartActionConfig;
|
||||
|
||||
cartListMode?: CartListModeConfig;
|
||||
saveMapping?: CardListSaveMapping;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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