Merge pull request 'jskim-node' (#423) from jskim-node into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/423
This commit is contained in:
commit
0b81c53127
|
|
@ -144,6 +144,14 @@ import aiAssistantProxy from "./routes/aiAssistantProxy"; // AI 어시스턴트
|
|||
import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력
|
||||
import moldRoutes from "./routes/moldRoutes"; // 금형 관리
|
||||
import shippingPlanRoutes from "./routes/shippingPlanRoutes"; // 출하계획 관리
|
||||
import shippingOrderRoutes from "./routes/shippingOrderRoutes"; // 출하지시 관리
|
||||
import workInstructionRoutes from "./routes/workInstructionRoutes"; // 작업지시 관리
|
||||
import salesReportRoutes from "./routes/salesReportRoutes"; // 영업 리포트
|
||||
import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석 리포트 (생산/재고/구매/품질/설비/금형)
|
||||
import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN)
|
||||
import materialStatusRoutes from "./routes/materialStatusRoutes"; // 자재현황
|
||||
import receivingRoutes from "./routes/receivingRoutes"; // 입고관리
|
||||
import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
|
|
@ -316,6 +324,8 @@ app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
|
|||
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
|
||||
app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리
|
||||
app.use("/api/production", productionRoutes); // 생산계획 관리
|
||||
app.use("/api/material-status", materialStatusRoutes); // 자재현황
|
||||
app.use("/api/process-info", processInfoRoutes); // 공정정보관리
|
||||
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
|
||||
app.use("/api/departments", departmentRoutes); // 부서 관리
|
||||
app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리
|
||||
|
|
@ -337,6 +347,12 @@ app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작
|
|||
app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력
|
||||
app.use("/api/mold", moldRoutes); // 금형 관리
|
||||
app.use("/api/shipping-plan", shippingPlanRoutes); // 출하계획 관리
|
||||
app.use("/api/shipping-order", shippingOrderRoutes); // 출하지시 관리
|
||||
app.use("/api/work-instruction", workInstructionRoutes); // 작업지시 관리
|
||||
app.use("/api/sales-report", salesReportRoutes); // 영업 리포트
|
||||
app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재고/구매/품질/설비/금형)
|
||||
app.use("/api/design", designRoutes); // 설계 모듈
|
||||
app.use("/api/receiving", receivingRoutes); // 입고관리
|
||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
|
||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||
|
|
|
|||
|
|
@ -0,0 +1,488 @@
|
|||
import { Response } from "express";
|
||||
import { query } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
function buildCompanyFilter(companyCode: string, alias: string, paramIdx: number) {
|
||||
if (companyCode === "*") return { condition: "", params: [] as any[], nextIdx: paramIdx };
|
||||
return {
|
||||
condition: `${alias}.company_code = $${paramIdx}`,
|
||||
params: [companyCode],
|
||||
nextIdx: paramIdx + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDateFilter(startDate: string | undefined, endDate: string | undefined, dateExpr: string, paramIdx: number) {
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = paramIdx;
|
||||
|
||||
if (startDate) {
|
||||
conditions.push(`${dateExpr} >= $${idx}`);
|
||||
params.push(startDate);
|
||||
idx++;
|
||||
}
|
||||
if (endDate) {
|
||||
conditions.push(`${dateExpr} <= $${idx}`);
|
||||
params.push(endDate);
|
||||
idx++;
|
||||
}
|
||||
|
||||
return { conditions, params, nextIdx: idx };
|
||||
}
|
||||
|
||||
function buildWhereClause(conditions: string[]): string {
|
||||
return conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
}
|
||||
|
||||
function extractFilterSet(rows: any[], field: string, labelField?: string): { value: string; label: string }[] {
|
||||
const set = new Map<string, string>();
|
||||
rows.forEach((r: any) => {
|
||||
const val = r[field];
|
||||
if (val && val !== "미지정") set.set(val, r[labelField || field] || val);
|
||||
});
|
||||
return [...set.entries()].map(([value, label]) => ({ value, label }));
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 생산 리포트
|
||||
// ============================================
|
||||
export async function getProductionReportData(req: any, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
|
||||
|
||||
const { startDate, endDate } = req.query;
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
const cf = buildCompanyFilter(companyCode, "wi", idx);
|
||||
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
|
||||
|
||||
const df = buildDateFilter(startDate, endDate, "COALESCE(wi.start_date, wi.created_date::date::text)", idx);
|
||||
conditions.push(...df.conditions); params.push(...df.params); idx = df.nextIdx;
|
||||
|
||||
const whereClause = buildWhereClause(conditions);
|
||||
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
COALESCE(wi.start_date, wi.created_date::date::text) as date,
|
||||
COALESCE(wi.routing, '미지정') as process,
|
||||
COALESCE(ei.equipment_name, wi.equipment_id, '미지정') as equipment,
|
||||
COALESCE(ii.item_name, wi.item_id, '미지정') as item,
|
||||
COALESCE(wi.worker, '미지정') as worker,
|
||||
CAST(COALESCE(NULLIF(wi.qty, ''), '0') AS numeric) as "planQty",
|
||||
COALESCE(pr.production_qty, 0) as "prodQty",
|
||||
COALESCE(pr.defect_qty, 0) as "defectQty",
|
||||
0 as "runTime",
|
||||
0 as "downTime",
|
||||
wi.status,
|
||||
wi.company_code
|
||||
FROM work_instruction wi
|
||||
LEFT JOIN (
|
||||
SELECT wo_id, company_code,
|
||||
SUM(CAST(COALESCE(NULLIF(production_qty, ''), '0') AS numeric)) as production_qty,
|
||||
SUM(CAST(COALESCE(NULLIF(defect_qty, ''), '0') AS numeric)) as defect_qty
|
||||
FROM production_record GROUP BY wo_id, company_code
|
||||
) pr ON wi.id = pr.wo_id AND wi.company_code = pr.company_code
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (equipment_code, company_code)
|
||||
equipment_code, equipment_name, equipment_type, company_code
|
||||
FROM equipment_info ORDER BY equipment_code, company_code, created_date DESC
|
||||
) ei ON wi.equipment_id = ei.equipment_code AND wi.company_code = ei.company_code
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (item_number, company_code)
|
||||
item_number, item_name, company_code
|
||||
FROM item_info ORDER BY item_number, company_code, created_date DESC
|
||||
) ii ON wi.item_id = ii.item_number AND wi.company_code = ii.company_code
|
||||
${whereClause}
|
||||
ORDER BY date DESC NULLS LAST
|
||||
`;
|
||||
|
||||
const dataRows = await query(dataQuery, params);
|
||||
|
||||
logger.info("생산 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
rows: dataRows,
|
||||
filterOptions: {
|
||||
processes: extractFilterSet(dataRows, "process"),
|
||||
equipment: extractFilterSet(dataRows, "equipment"),
|
||||
items: extractFilterSet(dataRows, "item"),
|
||||
workers: extractFilterSet(dataRows, "worker"),
|
||||
},
|
||||
totalCount: dataRows.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("생산 리포트 조회 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: "생산 리포트 데이터 조회에 실패했습니다", error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 재고 리포트
|
||||
// ============================================
|
||||
export async function getInventoryReportData(req: any, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
const cf = buildCompanyFilter(companyCode, "ist", idx);
|
||||
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
|
||||
|
||||
const whereClause = buildWhereClause(conditions);
|
||||
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
COALESCE(ist.updated_date, ist.created_date)::date::text as date,
|
||||
ist.item_code,
|
||||
COALESCE(ii.item_name, ist.item_code, '미지정') as item,
|
||||
COALESCE(wi.warehouse_name, ist.warehouse_code, '미지정') as warehouse,
|
||||
'일반' as category,
|
||||
CAST(COALESCE(NULLIF(ist.current_qty::text, ''), '0') AS numeric) as "currentQty",
|
||||
CAST(COALESCE(NULLIF(ist.safety_qty::text, ''), '0') AS numeric) as "safetyQty",
|
||||
COALESCE(ih_in.in_qty, 0) as "inQty",
|
||||
COALESCE(ih_out.out_qty, 0) as "outQty",
|
||||
0 as "stockValue",
|
||||
GREATEST(CAST(COALESCE(NULLIF(ist.safety_qty::text, ''), '0') AS numeric)
|
||||
- CAST(COALESCE(NULLIF(ist.current_qty::text, ''), '0') AS numeric), 0) as "shortageQty",
|
||||
CASE WHEN CAST(COALESCE(NULLIF(ist.current_qty::text, ''), '0') AS numeric) > 0
|
||||
AND COALESCE(ih_out.out_qty, 0) > 0
|
||||
THEN ROUND(COALESCE(ih_out.out_qty, 0)::numeric
|
||||
/ CAST(COALESCE(NULLIF(ist.current_qty::text, ''), '1') AS numeric), 2)
|
||||
ELSE 0 END as "turnover",
|
||||
ist.company_code
|
||||
FROM inventory_stock ist
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (item_number, company_code)
|
||||
item_number, item_name, company_code
|
||||
FROM item_info ORDER BY item_number, company_code, created_date DESC
|
||||
) ii ON ist.item_code = ii.item_number AND ist.company_code = ii.company_code
|
||||
LEFT JOIN warehouse_info wi ON ist.warehouse_code = wi.warehouse_code
|
||||
AND ist.company_code = wi.company_code
|
||||
LEFT JOIN (
|
||||
SELECT item_code, company_code,
|
||||
SUM(CAST(COALESCE(NULLIF(quantity::text, ''), '0') AS numeric)) as in_qty
|
||||
FROM inventory_history WHERE transaction_type = 'IN'
|
||||
GROUP BY item_code, company_code
|
||||
) ih_in ON ist.item_code = ih_in.item_code AND ist.company_code = ih_in.company_code
|
||||
LEFT JOIN (
|
||||
SELECT item_code, company_code,
|
||||
SUM(CAST(COALESCE(NULLIF(quantity::text, ''), '0') AS numeric)) as out_qty
|
||||
FROM inventory_history WHERE transaction_type = 'OUT'
|
||||
GROUP BY item_code, company_code
|
||||
) ih_out ON ist.item_code = ih_out.item_code AND ist.company_code = ih_out.company_code
|
||||
${whereClause}
|
||||
ORDER BY date DESC NULLS LAST
|
||||
`;
|
||||
|
||||
const dataRows = await query(dataQuery, params);
|
||||
|
||||
logger.info("재고 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
rows: dataRows,
|
||||
filterOptions: {
|
||||
items: extractFilterSet(dataRows, "item"),
|
||||
warehouses: extractFilterSet(dataRows, "warehouse"),
|
||||
categories: [
|
||||
{ value: "원자재", label: "원자재" }, { value: "부자재", label: "부자재" },
|
||||
{ value: "반제품", label: "반제품" }, { value: "완제품", label: "완제품" },
|
||||
{ value: "일반", label: "일반" },
|
||||
],
|
||||
},
|
||||
totalCount: dataRows.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("재고 리포트 조회 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: "재고 리포트 데이터 조회에 실패했습니다", error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 구매 리포트
|
||||
// ============================================
|
||||
export async function getPurchaseReportData(req: any, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
|
||||
|
||||
const { startDate, endDate } = req.query;
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
const cf = buildCompanyFilter(companyCode, "po", idx);
|
||||
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
|
||||
|
||||
const df = buildDateFilter(startDate, endDate, "COALESCE(po.order_date, po.created_date::date::text)", idx);
|
||||
conditions.push(...df.conditions); params.push(...df.params); idx = df.nextIdx;
|
||||
|
||||
const whereClause = buildWhereClause(conditions);
|
||||
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
COALESCE(po.order_date, po.created_date::date::text) as date,
|
||||
po.purchase_no,
|
||||
COALESCE(po.supplier_name, po.supplier_code, '미지정') as supplier,
|
||||
COALESCE(po.item_name, po.item_code, '미지정') as item,
|
||||
po.item_code,
|
||||
COALESCE(po.manager, '미지정') as manager,
|
||||
po.status,
|
||||
CAST(COALESCE(NULLIF(po.order_qty, ''), '0') AS numeric) as "orderQty",
|
||||
CAST(COALESCE(NULLIF(po.received_qty, ''), '0') AS numeric) as "receiveQty",
|
||||
CAST(COALESCE(NULLIF(po.unit_price, ''), '0') AS numeric) as "unitPrice",
|
||||
CAST(COALESCE(NULLIF(po.amount, ''), '0') AS numeric) as "orderAmt",
|
||||
CAST(COALESCE(NULLIF(po.received_qty, ''), '0') AS numeric)
|
||||
* CAST(COALESCE(NULLIF(po.unit_price, ''), '0') AS numeric) as "receiveAmt",
|
||||
1 as "orderCnt",
|
||||
po.company_code
|
||||
FROM purchase_order_mng po
|
||||
${whereClause}
|
||||
ORDER BY date DESC NULLS LAST
|
||||
`;
|
||||
|
||||
const dataRows = await query(dataQuery, params);
|
||||
|
||||
logger.info("구매 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
rows: dataRows,
|
||||
filterOptions: {
|
||||
suppliers: extractFilterSet(dataRows, "supplier"),
|
||||
items: extractFilterSet(dataRows, "item"),
|
||||
managers: extractFilterSet(dataRows, "manager"),
|
||||
statuses: extractFilterSet(dataRows, "status"),
|
||||
},
|
||||
totalCount: dataRows.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("구매 리포트 조회 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: "구매 리포트 데이터 조회에 실패했습니다", error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 품질 리포트
|
||||
// ============================================
|
||||
export async function getQualityReportData(req: any, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
|
||||
|
||||
const { startDate, endDate } = req.query;
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
const cf = buildCompanyFilter(companyCode, "pr", idx);
|
||||
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
|
||||
|
||||
const df = buildDateFilter(startDate, endDate, "COALESCE(pr.production_date, pr.created_date::date::text)", idx);
|
||||
conditions.push(...df.conditions); params.push(...df.params); idx = df.nextIdx;
|
||||
|
||||
const whereClause = buildWhereClause(conditions);
|
||||
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
COALESCE(pr.production_date, pr.created_date::date::text) as date,
|
||||
COALESCE(ii.item_name, wi.item_id, '미지정') as item,
|
||||
'일반검사' as "defectType",
|
||||
COALESCE(wi.routing, '미지정') as process,
|
||||
COALESCE(pr.worker_name, '미지정') as inspector,
|
||||
CAST(COALESCE(NULLIF(pr.production_qty, ''), '0') AS numeric) as "inspQty",
|
||||
CAST(COALESCE(NULLIF(pr.production_qty, ''), '0') AS numeric)
|
||||
- CAST(COALESCE(NULLIF(pr.defect_qty, ''), '0') AS numeric) as "passQty",
|
||||
CAST(COALESCE(NULLIF(pr.defect_qty, ''), '0') AS numeric) as "defectQty",
|
||||
0 as "reworkQty",
|
||||
0 as "scrapQty",
|
||||
0 as "claimCnt",
|
||||
pr.company_code
|
||||
FROM production_record pr
|
||||
LEFT JOIN work_instruction wi ON pr.wo_id = wi.id AND pr.company_code = wi.company_code
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (item_number, company_code)
|
||||
item_number, item_name, company_code
|
||||
FROM item_info ORDER BY item_number, company_code, created_date DESC
|
||||
) ii ON wi.item_id = ii.item_number AND wi.company_code = ii.company_code
|
||||
${whereClause}
|
||||
ORDER BY date DESC NULLS LAST
|
||||
`;
|
||||
|
||||
const dataRows = await query(dataQuery, params);
|
||||
|
||||
logger.info("품질 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
rows: dataRows,
|
||||
filterOptions: {
|
||||
items: extractFilterSet(dataRows, "item"),
|
||||
defectTypes: [
|
||||
{ value: "외관불량", label: "외관불량" }, { value: "치수불량", label: "치수불량" },
|
||||
{ value: "기능불량", label: "기능불량" }, { value: "재질불량", label: "재질불량" },
|
||||
{ value: "일반검사", label: "일반검사" },
|
||||
],
|
||||
processes: extractFilterSet(dataRows, "process"),
|
||||
inspectors: extractFilterSet(dataRows, "inspector"),
|
||||
},
|
||||
totalCount: dataRows.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("품질 리포트 조회 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: "품질 리포트 데이터 조회에 실패했습니다", error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 설비 리포트
|
||||
// ============================================
|
||||
export async function getEquipmentReportData(req: any, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
const cf = buildCompanyFilter(companyCode, "ei", idx);
|
||||
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
|
||||
|
||||
const whereClause = buildWhereClause(conditions);
|
||||
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
COALESCE(ei.updated_date, ei.created_date)::date::text as date,
|
||||
ei.equipment_code,
|
||||
COALESCE(ei.equipment_name, ei.equipment_code) as equipment,
|
||||
COALESCE(ei.equipment_type, '미지정') as "equipType",
|
||||
COALESCE(ei.location, '미지정') as line,
|
||||
COALESCE(ui.user_name, ei.manager_id, '미지정') as manager,
|
||||
ei.status,
|
||||
CAST(COALESCE(NULLIF(ei.capacity_per_day::text, ''), '0') AS numeric) as "runTime",
|
||||
0 as "downTime",
|
||||
100 as "opRate",
|
||||
0 as "faultCnt",
|
||||
0 as "mtbf",
|
||||
0 as "mttr",
|
||||
0 as "maintCost",
|
||||
CAST(COALESCE(NULLIF(ei.capacity_per_day::text, ''), '0') AS numeric) as "prodQty",
|
||||
ei.company_code
|
||||
FROM equipment_info ei
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (user_id) user_id, user_name FROM user_info
|
||||
) ui ON ei.manager_id = ui.user_id
|
||||
${whereClause}
|
||||
ORDER BY equipment ASC
|
||||
`;
|
||||
|
||||
const dataRows = await query(dataQuery, params);
|
||||
|
||||
logger.info("설비 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
rows: dataRows,
|
||||
filterOptions: {
|
||||
equipment: extractFilterSet(dataRows, "equipment"),
|
||||
equipTypes: extractFilterSet(dataRows, "equipType"),
|
||||
lines: extractFilterSet(dataRows, "line"),
|
||||
managers: extractFilterSet(dataRows, "manager"),
|
||||
},
|
||||
totalCount: dataRows.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("설비 리포트 조회 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: "설비 리포트 데이터 조회에 실패했습니다", error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 금형 리포트
|
||||
// ============================================
|
||||
export async function getMoldReportData(req: any, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
const cf = buildCompanyFilter(companyCode, "mm", idx);
|
||||
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
|
||||
|
||||
const whereClause = buildWhereClause(conditions);
|
||||
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
COALESCE(mm.updated_date, mm.created_date)::date::text as date,
|
||||
mm.mold_code,
|
||||
COALESCE(mm.mold_name, mm.mold_code) as mold,
|
||||
COALESCE(mm.mold_type, mm.category, '미지정') as "moldType",
|
||||
COALESCE(ii.item_name, '미지정') as item,
|
||||
COALESCE(mm.manufacturer, '미지정') as maker,
|
||||
mm.operation_status as status,
|
||||
CAST(COALESCE(NULLIF(mm.shot_count::text, ''), '0') AS numeric) as "shotCnt",
|
||||
CAST(COALESCE(NULLIF(mm.warranty_shot_count::text, ''), '0') AS numeric) as "guaranteeShot",
|
||||
CASE WHEN CAST(COALESCE(NULLIF(mm.warranty_shot_count::text, ''), '0') AS numeric) > 0
|
||||
THEN ROUND(
|
||||
CAST(COALESCE(NULLIF(mm.shot_count::text, ''), '0') AS numeric) * 100.0
|
||||
/ CAST(COALESCE(NULLIF(mm.warranty_shot_count::text, ''), '1') AS numeric), 1)
|
||||
ELSE 0 END as "lifeRate",
|
||||
0 as "repairCnt",
|
||||
0 as "repairCost",
|
||||
0 as "prodQty",
|
||||
0 as "defectRate",
|
||||
CAST(COALESCE(NULLIF(mm.cavity_count::text, ''), '0') AS numeric) as "cavityUse",
|
||||
mm.company_code
|
||||
FROM mold_mng mm
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (item_number, company_code)
|
||||
item_number, item_name, company_code
|
||||
FROM item_info ORDER BY item_number, company_code, created_date DESC
|
||||
) ii ON mm.mold_code = ii.item_number AND mm.company_code = ii.company_code
|
||||
${whereClause}
|
||||
ORDER BY mold ASC
|
||||
`;
|
||||
|
||||
const dataRows = await query(dataQuery, params);
|
||||
|
||||
logger.info("금형 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
rows: dataRows,
|
||||
filterOptions: {
|
||||
molds: extractFilterSet(dataRows, "mold"),
|
||||
moldTypes: extractFilterSet(dataRows, "moldType"),
|
||||
items: extractFilterSet(dataRows, "item"),
|
||||
makers: extractFilterSet(dataRows, "maker"),
|
||||
},
|
||||
totalCount: dataRows.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("금형 리포트 조회 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: "금형 리포트 데이터 조회에 실패했습니다", error: error.message });
|
||||
}
|
||||
}
|
||||
|
|
@ -126,29 +126,41 @@ export class BatchManagementController {
|
|||
*/
|
||||
static async createBatchConfig(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { batchName, description, cronSchedule, mappings, isActive } =
|
||||
req.body;
|
||||
const {
|
||||
batchName, description, cronSchedule, mappings, isActive,
|
||||
executionType, nodeFlowId, nodeFlowContext,
|
||||
} = req.body;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
if (
|
||||
!batchName ||
|
||||
!cronSchedule ||
|
||||
!mappings ||
|
||||
!Array.isArray(mappings)
|
||||
) {
|
||||
if (!batchName || !cronSchedule) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message:
|
||||
"필수 필드가 누락되었습니다. (batchName, cronSchedule, mappings)",
|
||||
message: "필수 필드가 누락되었습니다. (batchName, cronSchedule)",
|
||||
});
|
||||
}
|
||||
|
||||
const batchConfig = await BatchService.createBatchConfig({
|
||||
batchName,
|
||||
description,
|
||||
cronSchedule,
|
||||
mappings,
|
||||
isActive: isActive !== undefined ? isActive : true,
|
||||
} as CreateBatchConfigRequest);
|
||||
// 노드 플로우 타입은 매핑 없이 생성 가능
|
||||
if (executionType !== "node_flow" && (!mappings || !Array.isArray(mappings))) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "매핑 타입은 mappings 배열이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const batchConfig = await BatchService.createBatchConfig(
|
||||
{
|
||||
batchName,
|
||||
description,
|
||||
cronSchedule,
|
||||
mappings: mappings || [],
|
||||
isActive: isActive === false || isActive === "N" ? "N" : "Y",
|
||||
companyCode: companyCode || "",
|
||||
executionType: executionType || "mapping",
|
||||
nodeFlowId: nodeFlowId || null,
|
||||
nodeFlowContext: nodeFlowContext || null,
|
||||
} as CreateBatchConfigRequest,
|
||||
req.user?.userId
|
||||
);
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
|
|
@ -768,4 +780,287 @@ export class BatchManagementController {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 노드 플로우 목록 조회 (배치 설정에서 플로우 선택용)
|
||||
* GET /api/batch-management/node-flows
|
||||
*/
|
||||
static async getNodeFlows(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
let flowQuery: string;
|
||||
let flowParams: any[] = [];
|
||||
|
||||
if (companyCode === "*") {
|
||||
flowQuery = `
|
||||
SELECT flow_id, flow_name, flow_description AS description, company_code,
|
||||
COALESCE(jsonb_array_length(
|
||||
CASE WHEN flow_data IS NOT NULL AND flow_data::text != ''
|
||||
THEN (flow_data::jsonb -> 'nodes')
|
||||
ELSE '[]'::jsonb END
|
||||
), 0) AS node_count
|
||||
FROM node_flows
|
||||
ORDER BY flow_name
|
||||
`;
|
||||
} else {
|
||||
flowQuery = `
|
||||
SELECT flow_id, flow_name, flow_description AS description, company_code,
|
||||
COALESCE(jsonb_array_length(
|
||||
CASE WHEN flow_data IS NOT NULL AND flow_data::text != ''
|
||||
THEN (flow_data::jsonb -> 'nodes')
|
||||
ELSE '[]'::jsonb END
|
||||
), 0) AS node_count
|
||||
FROM node_flows
|
||||
WHERE company_code = $1
|
||||
ORDER BY flow_name
|
||||
`;
|
||||
flowParams = [companyCode];
|
||||
}
|
||||
|
||||
const result = await query(flowQuery, flowParams);
|
||||
return res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
console.error("노드 플로우 목록 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "노드 플로우 목록 조회 실패",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 대시보드 통계 조회
|
||||
* GET /api/batch-management/stats
|
||||
* totalBatches, activeBatches, todayExecutions, todayFailures, prevDayExecutions, prevDayFailures
|
||||
* 멀티테넌시: company_code 필터링 필수
|
||||
*/
|
||||
static async getBatchStats(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
// 전체/활성 배치 수
|
||||
let configQuery: string;
|
||||
let configParams: any[] = [];
|
||||
if (companyCode === "*") {
|
||||
configQuery = `
|
||||
SELECT
|
||||
COUNT(*)::int AS total,
|
||||
COUNT(*) FILTER (WHERE is_active = 'Y')::int AS active
|
||||
FROM batch_configs
|
||||
`;
|
||||
} else {
|
||||
configQuery = `
|
||||
SELECT
|
||||
COUNT(*)::int AS total,
|
||||
COUNT(*) FILTER (WHERE is_active = 'Y')::int AS active
|
||||
FROM batch_configs
|
||||
WHERE company_code = $1
|
||||
`;
|
||||
configParams = [companyCode];
|
||||
}
|
||||
const configResult = await query<{ total: number; active: number }>(
|
||||
configQuery,
|
||||
configParams
|
||||
);
|
||||
|
||||
// 오늘/어제 실행·실패 수 (KST 기준 날짜)
|
||||
const logParams: any[] = [];
|
||||
let logWhere = "";
|
||||
if (companyCode && companyCode !== "*") {
|
||||
logWhere = " AND company_code = $1";
|
||||
logParams.push(companyCode);
|
||||
}
|
||||
const todayLogQuery = `
|
||||
SELECT
|
||||
COUNT(*)::int AS today_executions,
|
||||
COUNT(*) FILTER (WHERE execution_status = 'FAILED')::int AS today_failures
|
||||
FROM batch_execution_logs
|
||||
WHERE (start_time AT TIME ZONE 'Asia/Seoul')::date = (NOW() AT TIME ZONE 'Asia/Seoul')::date
|
||||
${logWhere}
|
||||
`;
|
||||
const prevDayLogQuery = `
|
||||
SELECT
|
||||
COUNT(*)::int AS prev_executions,
|
||||
COUNT(*) FILTER (WHERE execution_status = 'FAILED')::int AS prev_failures
|
||||
FROM batch_execution_logs
|
||||
WHERE (start_time AT TIME ZONE 'Asia/Seoul')::date = (NOW() AT TIME ZONE 'Asia/Seoul')::date - INTERVAL '1 day'
|
||||
${logWhere}
|
||||
`;
|
||||
const [todayResult, prevResult] = await Promise.all([
|
||||
query<{ today_executions: number; today_failures: number }>(
|
||||
todayLogQuery,
|
||||
logParams
|
||||
),
|
||||
query<{ prev_executions: number; prev_failures: number }>(
|
||||
prevDayLogQuery,
|
||||
logParams
|
||||
),
|
||||
]);
|
||||
|
||||
const config = configResult[0];
|
||||
const today = todayResult[0];
|
||||
const prev = prevResult[0];
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
totalBatches: config?.total ?? 0,
|
||||
activeBatches: config?.active ?? 0,
|
||||
todayExecutions: today?.today_executions ?? 0,
|
||||
todayFailures: today?.today_failures ?? 0,
|
||||
prevDayExecutions: prev?.prev_executions ?? 0,
|
||||
prevDayFailures: prev?.prev_failures ?? 0,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("배치 통계 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 통계 조회 실패",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치별 최근 24시간 스파크라인 (1시간 단위 집계)
|
||||
* GET /api/batch-management/batch-configs/:id/sparkline
|
||||
* 멀티테넌시: company_code 필터링 필수
|
||||
*/
|
||||
static async getBatchSparkline(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user?.companyCode;
|
||||
const batchId = Number(id);
|
||||
if (!id || isNaN(batchId)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "올바른 배치 ID를 제공해주세요.",
|
||||
});
|
||||
}
|
||||
|
||||
const params: any[] = [batchId];
|
||||
let companyFilter = "";
|
||||
if (companyCode && companyCode !== "*") {
|
||||
companyFilter = " AND bel.company_code = $2";
|
||||
params.push(companyCode);
|
||||
}
|
||||
|
||||
// KST 기준 최근 24시간 1시간 단위 슬롯 + 집계 (generate_series로 24개 보장)
|
||||
const sparklineQuery = `
|
||||
WITH kst_slots AS (
|
||||
SELECT to_char(s, 'YYYY-MM-DD"T"HH24:00:00') AS hour
|
||||
FROM generate_series(
|
||||
(NOW() AT TIME ZONE 'Asia/Seoul') - INTERVAL '23 hours',
|
||||
(NOW() AT TIME ZONE 'Asia/Seoul'),
|
||||
INTERVAL '1 hour'
|
||||
) AS s
|
||||
),
|
||||
agg AS (
|
||||
SELECT
|
||||
to_char(date_trunc('hour', (bel.start_time AT TIME ZONE 'Asia/Seoul')) AT TIME ZONE 'Asia/Seoul', 'YYYY-MM-DD"T"HH24:00:00') AS hour,
|
||||
COUNT(*) FILTER (WHERE bel.execution_status = 'SUCCESS')::int AS success,
|
||||
COUNT(*) FILTER (WHERE bel.execution_status = 'FAILED')::int AS failed
|
||||
FROM batch_execution_logs bel
|
||||
WHERE bel.batch_config_id = $1
|
||||
AND bel.start_time >= (NOW() AT TIME ZONE 'Asia/Seoul') - INTERVAL '24 hours'
|
||||
${companyFilter}
|
||||
GROUP BY date_trunc('hour', (bel.start_time AT TIME ZONE 'Asia/Seoul'))
|
||||
)
|
||||
SELECT
|
||||
k.hour,
|
||||
COALESCE(a.success, 0) AS success,
|
||||
COALESCE(a.failed, 0) AS failed
|
||||
FROM kst_slots k
|
||||
LEFT JOIN agg a ON k.hour = a.hour
|
||||
ORDER BY k.hour
|
||||
`;
|
||||
const data = await query<{
|
||||
hour: string;
|
||||
success: number;
|
||||
failed: number;
|
||||
}>(sparklineQuery, params);
|
||||
|
||||
return res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error("스파크라인 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "스파크라인 데이터 조회 실패",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치별 최근 실행 로그 (최대 20건)
|
||||
* GET /api/batch-management/batch-configs/:id/recent-logs
|
||||
* 멀티테넌시: company_code 필터링 필수
|
||||
*/
|
||||
static async getBatchRecentLogs(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user?.companyCode;
|
||||
const batchId = Number(id);
|
||||
const limit = Math.min(Number(req.query.limit) || 20, 20);
|
||||
if (!id || isNaN(batchId)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "올바른 배치 ID를 제공해주세요.",
|
||||
});
|
||||
}
|
||||
|
||||
let logsQuery: string;
|
||||
let logsParams: any[];
|
||||
if (companyCode === "*") {
|
||||
logsQuery = `
|
||||
SELECT
|
||||
id,
|
||||
start_time AS started_at,
|
||||
end_time AS finished_at,
|
||||
execution_status AS status,
|
||||
total_records,
|
||||
success_records,
|
||||
failed_records,
|
||||
error_message,
|
||||
duration_ms
|
||||
FROM batch_execution_logs
|
||||
WHERE batch_config_id = $1
|
||||
ORDER BY start_time DESC
|
||||
LIMIT $2
|
||||
`;
|
||||
logsParams = [batchId, limit];
|
||||
} else {
|
||||
logsQuery = `
|
||||
SELECT
|
||||
id,
|
||||
start_time AS started_at,
|
||||
end_time AS finished_at,
|
||||
execution_status AS status,
|
||||
total_records,
|
||||
success_records,
|
||||
failed_records,
|
||||
error_message,
|
||||
duration_ms
|
||||
FROM batch_execution_logs
|
||||
WHERE batch_config_id = $1 AND company_code = $2
|
||||
ORDER BY start_time DESC
|
||||
LIMIT $3
|
||||
`;
|
||||
logsParams = [batchId, companyCode, limit];
|
||||
}
|
||||
|
||||
const result = await query(logsQuery, logsParams);
|
||||
return res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
console.error("최근 실행 이력 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "최근 실행 이력 조회 실패",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,946 @@
|
|||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { query, getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// 회사코드 필터 조건 생성 헬퍼
|
||||
function companyFilter(companyCode: string, paramIndex: number, alias?: string): { condition: string; param: string; nextIndex: number } {
|
||||
const col = alias ? `${alias}.company_code` : "company_code";
|
||||
if (companyCode === "*") {
|
||||
return { condition: "", param: "", nextIndex: paramIndex };
|
||||
}
|
||||
return { condition: `${col} = $${paramIndex}`, param: companyCode, nextIndex: paramIndex + 1 };
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 설계의뢰/설변요청 (DR/ECR) CRUD
|
||||
// ============================================
|
||||
|
||||
export async function getDesignRequestList(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const { source_type, status, priority, search } = req.query;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let pi = 1;
|
||||
|
||||
if (companyCode !== "*") {
|
||||
conditions.push(`r.company_code = $${pi}`);
|
||||
params.push(companyCode);
|
||||
pi++;
|
||||
}
|
||||
if (source_type) { conditions.push(`r.source_type = $${pi}`); params.push(source_type); pi++; }
|
||||
if (status) { conditions.push(`r.status = $${pi}`); params.push(status); pi++; }
|
||||
if (priority) { conditions.push(`r.priority = $${pi}`); params.push(priority); pi++; }
|
||||
if (search) {
|
||||
conditions.push(`(r.target_name ILIKE $${pi} OR r.request_no ILIKE $${pi} OR r.requester ILIKE $${pi})`);
|
||||
params.push(`%${search}%`);
|
||||
pi++;
|
||||
}
|
||||
|
||||
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const sql = `
|
||||
SELECT r.*,
|
||||
COALESCE(json_agg(json_build_object('id', h.id, 'step', h.step, 'history_date', h.history_date, 'user_name', h.user_name, 'description', h.description)) FILTER (WHERE h.id IS NOT NULL), '[]') AS history,
|
||||
COALESCE((SELECT json_agg(i.impact_type) FROM dsn_request_impact i WHERE i.request_id = r.id), '[]') AS impact
|
||||
FROM dsn_design_request r
|
||||
LEFT JOIN dsn_request_history h ON h.request_id = r.id
|
||||
${where}
|
||||
GROUP BY r.id
|
||||
ORDER BY r.created_date DESC
|
||||
`;
|
||||
const result = await query(sql, params);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("설계의뢰 목록 조회 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDesignRequestDetail(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const { id } = req.params;
|
||||
|
||||
const conditions = [`r.id = $1`];
|
||||
const params: any[] = [id];
|
||||
if (companyCode !== "*") { conditions.push(`r.company_code = $2`); params.push(companyCode); }
|
||||
|
||||
const sql = `
|
||||
SELECT r.*,
|
||||
COALESCE((SELECT json_agg(json_build_object('id', h.id, 'step', h.step, 'history_date', h.history_date, 'user_name', h.user_name, 'description', h.description) ORDER BY h.created_date) FROM dsn_request_history h WHERE h.request_id = r.id), '[]') AS history,
|
||||
COALESCE((SELECT json_agg(i.impact_type) FROM dsn_request_impact i WHERE i.request_id = r.id), '[]') AS impact
|
||||
FROM dsn_design_request r
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
`;
|
||||
const result = await query(sql, params);
|
||||
if (!result.length) { res.status(404).json({ success: false, message: "의뢰를 찾을 수 없습니다." }); return; }
|
||||
res.json({ success: true, data: result[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("설계의뢰 상세 조회 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function createDesignRequest(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const userId = req.user!.userId;
|
||||
const {
|
||||
request_no, source_type, request_date, due_date, priority, status,
|
||||
target_name, customer, req_dept, requester, designer, order_no,
|
||||
design_type, spec, change_type, drawing_no, urgency, reason,
|
||||
content, apply_timing, review_memo, project_id, ecn_no,
|
||||
impact, history,
|
||||
} = req.body;
|
||||
|
||||
const sql = `
|
||||
INSERT INTO dsn_design_request (
|
||||
request_no, source_type, request_date, due_date, priority, status,
|
||||
target_name, customer, req_dept, requester, designer, order_no,
|
||||
design_type, spec, change_type, drawing_no, urgency, reason,
|
||||
content, apply_timing, review_memo, project_id, ecn_no,
|
||||
writer, company_code
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25)
|
||||
RETURNING *
|
||||
`;
|
||||
const result = await client.query(sql, [
|
||||
request_no, source_type || "dr", request_date, due_date, priority || "보통", status || "신규접수",
|
||||
target_name, customer, req_dept, requester, designer, order_no,
|
||||
design_type, spec, change_type, drawing_no, urgency || "보통", reason,
|
||||
content, apply_timing, review_memo, project_id, ecn_no,
|
||||
userId, companyCode,
|
||||
]);
|
||||
|
||||
const requestId = result.rows[0].id;
|
||||
|
||||
if (impact?.length) {
|
||||
for (const imp of impact) {
|
||||
await client.query(
|
||||
`INSERT INTO dsn_request_impact (request_id, impact_type, writer, company_code) VALUES ($1,$2,$3,$4)`,
|
||||
[requestId, imp, userId, companyCode]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (history?.length) {
|
||||
for (const h of history) {
|
||||
await client.query(
|
||||
`INSERT INTO dsn_request_history (request_id, step, history_date, user_name, description, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||
[requestId, h.step, h.history_date, h.user_name, h.description, userId, companyCode]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("설계의뢰 생성 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateDesignRequest(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const userId = req.user!.userId;
|
||||
const { id } = req.params;
|
||||
const {
|
||||
request_no, source_type, request_date, due_date, priority, status, approval_step,
|
||||
target_name, customer, req_dept, requester, designer, order_no,
|
||||
design_type, spec, change_type, drawing_no, urgency, reason,
|
||||
content, apply_timing, review_memo, project_id, ecn_no,
|
||||
impact, history,
|
||||
} = req.body;
|
||||
|
||||
const conditions = [`id = $1`];
|
||||
const params: any[] = [id];
|
||||
let pi = 2;
|
||||
if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; }
|
||||
|
||||
const setClauses = [];
|
||||
const setParams: any[] = [];
|
||||
const fields: Record<string, any> = {
|
||||
request_no, source_type, request_date, due_date, priority, status, approval_step,
|
||||
target_name, customer, req_dept, requester, designer, order_no,
|
||||
design_type, spec, change_type, drawing_no, urgency, reason,
|
||||
content, apply_timing, review_memo, project_id, ecn_no,
|
||||
};
|
||||
for (const [key, val] of Object.entries(fields)) {
|
||||
if (val !== undefined) {
|
||||
setClauses.push(`${key} = $${pi}`);
|
||||
setParams.push(val);
|
||||
pi++;
|
||||
}
|
||||
}
|
||||
setClauses.push(`updated_date = now()`);
|
||||
|
||||
const sql = `UPDATE dsn_design_request SET ${setClauses.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`;
|
||||
const result = await client.query(sql, [...params, ...setParams]);
|
||||
if (!result.rowCount) { await client.query("ROLLBACK"); res.status(404).json({ success: false, message: "의뢰를 찾을 수 없습니다." }); return; }
|
||||
|
||||
if (impact !== undefined) {
|
||||
await client.query(`DELETE FROM dsn_request_impact WHERE request_id = $1`, [id]);
|
||||
for (const imp of impact) {
|
||||
await client.query(
|
||||
`INSERT INTO dsn_request_impact (request_id, impact_type, writer, company_code) VALUES ($1,$2,$3,$4)`,
|
||||
[id, imp, userId, companyCode]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (history !== undefined) {
|
||||
await client.query(`DELETE FROM dsn_request_history WHERE request_id = $1`, [id]);
|
||||
for (const h of history) {
|
||||
await client.query(
|
||||
`INSERT INTO dsn_request_history (request_id, step, history_date, user_name, description, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||
[id, h.step, h.history_date, h.user_name, h.description, userId, companyCode]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("설계의뢰 수정 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteDesignRequest(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const { id } = req.params;
|
||||
|
||||
const conditions = [`id = $1`];
|
||||
const params: any[] = [id];
|
||||
if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); }
|
||||
|
||||
const sql = `DELETE FROM dsn_design_request WHERE ${conditions.join(" AND ")} RETURNING id`;
|
||||
const result = await query(sql, params);
|
||||
if (!result.length) { res.status(404).json({ success: false, message: "의뢰를 찾을 수 없습니다." }); return; }
|
||||
res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
logger.error("설계의뢰 삭제 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 이력 추가 (단건)
|
||||
export async function addRequestHistory(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const userId = req.user!.userId;
|
||||
const { id } = req.params;
|
||||
const { step, history_date, user_name, description } = req.body;
|
||||
|
||||
const sql = `INSERT INTO dsn_request_history (request_id, step, history_date, user_name, description, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`;
|
||||
const result = await query(sql, [id, step, history_date, user_name, description, userId, companyCode]);
|
||||
res.json({ success: true, data: result[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("의뢰 이력 추가 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 설계 프로젝트 CRUD
|
||||
// ============================================
|
||||
|
||||
export async function getProjectList(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const { status, search } = req.query;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let pi = 1;
|
||||
|
||||
if (companyCode !== "*") { conditions.push(`p.company_code = $${pi}`); params.push(companyCode); pi++; }
|
||||
if (status) { conditions.push(`p.status = $${pi}`); params.push(status); pi++; }
|
||||
if (search) {
|
||||
conditions.push(`(p.name ILIKE $${pi} OR p.project_no ILIKE $${pi} OR p.customer ILIKE $${pi})`);
|
||||
params.push(`%${search}%`);
|
||||
pi++;
|
||||
}
|
||||
|
||||
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const sql = `
|
||||
SELECT p.*,
|
||||
COALESCE(
|
||||
(SELECT json_agg(json_build_object(
|
||||
'id', t.id, 'name', t.name, 'category', t.category, 'assignee', t.assignee,
|
||||
'start_date', t.start_date, 'end_date', t.end_date, 'status', t.status,
|
||||
'progress', t.progress, 'priority', t.priority, 'remark', t.remark, 'sort_order', t.sort_order
|
||||
) ORDER BY t.sort_order, t.start_date)
|
||||
FROM dsn_project_task t WHERE t.project_id = p.id), '[]'
|
||||
) AS tasks
|
||||
FROM dsn_project p
|
||||
${where}
|
||||
ORDER BY p.created_date DESC
|
||||
`;
|
||||
const result = await query(sql, params);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("프로젝트 목록 조회 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProjectDetail(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const { id } = req.params;
|
||||
|
||||
const conditions = [`p.id = $1`];
|
||||
const params: any[] = [id];
|
||||
if (companyCode !== "*") { conditions.push(`p.company_code = $2`); params.push(companyCode); }
|
||||
|
||||
const sql = `
|
||||
SELECT p.*,
|
||||
COALESCE(
|
||||
(SELECT json_agg(json_build_object(
|
||||
'id', t.id, 'name', t.name, 'category', t.category, 'assignee', t.assignee,
|
||||
'start_date', t.start_date, 'end_date', t.end_date, 'status', t.status,
|
||||
'progress', t.progress, 'priority', t.priority, 'remark', t.remark, 'sort_order', t.sort_order
|
||||
) ORDER BY t.sort_order, t.start_date)
|
||||
FROM dsn_project_task t WHERE t.project_id = p.id), '[]'
|
||||
) AS tasks
|
||||
FROM dsn_project p
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
`;
|
||||
const result = await query(sql, params);
|
||||
if (!result.length) { res.status(404).json({ success: false, message: "프로젝트를 찾을 수 없습니다." }); return; }
|
||||
res.json({ success: true, data: result[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("프로젝트 상세 조회 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function createProject(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const userId = req.user!.userId;
|
||||
const { project_no, name, status: pStatus, pm, customer, start_date, end_date, source_no, description, progress, parent_id, relation_type, tasks } = req.body;
|
||||
|
||||
const result = await client.query(
|
||||
`INSERT INTO dsn_project (project_no, name, status, pm, customer, start_date, end_date, source_no, description, progress, parent_id, relation_type, writer, company_code)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING *`,
|
||||
[project_no, name, pStatus || "계획", pm, customer, start_date, end_date, source_no, description, progress || "0", parent_id, relation_type, userId, companyCode]
|
||||
);
|
||||
|
||||
const projectId = result.rows[0].id;
|
||||
if (tasks?.length) {
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
const t = tasks[i];
|
||||
await client.query(
|
||||
`INSERT INTO dsn_project_task (project_id, name, category, assignee, start_date, end_date, status, progress, priority, remark, sort_order, writer, company_code)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`,
|
||||
[projectId, t.name, t.category, t.assignee, t.start_date, t.end_date, t.status || "대기", t.progress || "0", t.priority || "보통", t.remark, String(i), userId, companyCode]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("프로젝트 생성 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateProject(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const { id } = req.params;
|
||||
const { project_no, name, status: pStatus, pm, customer, start_date, end_date, source_no, description, progress, parent_id, relation_type } = req.body;
|
||||
|
||||
const conditions = [`id = $1`];
|
||||
const params: any[] = [id];
|
||||
let pi = 2;
|
||||
if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; }
|
||||
|
||||
const sets: string[] = [];
|
||||
const fields: Record<string, any> = { project_no, name, status: pStatus, pm, customer, start_date, end_date, source_no, description, progress, parent_id, relation_type };
|
||||
for (const [key, val] of Object.entries(fields)) {
|
||||
if (val !== undefined) { sets.push(`${key} = $${pi}`); params.push(val); pi++; }
|
||||
}
|
||||
sets.push(`updated_date = now()`);
|
||||
|
||||
const result = await query(`UPDATE dsn_project SET ${sets.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`, params);
|
||||
if (!result.length) { res.status(404).json({ success: false, message: "프로젝트를 찾을 수 없습니다." }); return; }
|
||||
res.json({ success: true, data: result[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("프로젝트 수정 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteProject(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const { id } = req.params;
|
||||
const conditions = [`id = $1`];
|
||||
const params: any[] = [id];
|
||||
if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); }
|
||||
|
||||
const result = await query(`DELETE FROM dsn_project WHERE ${conditions.join(" AND ")} RETURNING id`, params);
|
||||
if (!result.length) { res.status(404).json({ success: false, message: "프로젝트를 찾을 수 없습니다." }); return; }
|
||||
res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
logger.error("프로젝트 삭제 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 프로젝트 태스크 CRUD
|
||||
// ============================================
|
||||
|
||||
export async function getTasksByProject(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const { projectId } = req.params;
|
||||
|
||||
const conditions = [`t.project_id = $1`];
|
||||
const params: any[] = [projectId];
|
||||
if (companyCode !== "*") { conditions.push(`t.company_code = $2`); params.push(companyCode); }
|
||||
|
||||
const sql = `
|
||||
SELECT t.*,
|
||||
COALESCE((SELECT json_agg(json_build_object('id', w.id, 'start_dt', w.start_dt, 'end_dt', w.end_dt, 'hours', w.hours, 'description', w.description, 'progress_before', w.progress_before, 'progress_after', w.progress_after, 'author', w.author, 'sub_item_id', w.sub_item_id) ORDER BY w.start_dt) FROM dsn_work_log w WHERE w.task_id = t.id), '[]') AS work_logs,
|
||||
COALESCE((SELECT json_agg(json_build_object('id', i.id, 'title', i.title, 'status', i.status, 'priority', i.priority, 'description', i.description, 'registered_by', i.registered_by, 'registered_date', i.registered_date, 'resolved_date', i.resolved_date)) FROM dsn_task_issue i WHERE i.task_id = t.id), '[]') AS issues,
|
||||
COALESCE((SELECT json_agg(json_build_object('id', s.id, 'name', s.name, 'weight', s.weight, 'progress', s.progress, 'status', s.status) ORDER BY s.created_date) FROM dsn_task_sub_item s WHERE s.task_id = t.id), '[]') AS sub_items
|
||||
FROM dsn_project_task t
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY t.sort_order, t.start_date
|
||||
`;
|
||||
const result = await query(sql, params);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("태스크 목록 조회 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTask(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const userId = req.user!.userId;
|
||||
const { projectId } = req.params;
|
||||
const { name, category, assignee, start_date, end_date, status, progress, priority, remark, sort_order } = req.body;
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO dsn_project_task (project_id, name, category, assignee, start_date, end_date, status, progress, priority, remark, sort_order, writer, company_code)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING *`,
|
||||
[projectId, name, category, assignee, start_date, end_date, status || "대기", progress || "0", priority || "보통", remark, sort_order || "0", userId, companyCode]
|
||||
);
|
||||
res.json({ success: true, data: result[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("태스크 생성 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateTask(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const { taskId } = req.params;
|
||||
const { name, category, assignee, start_date, end_date, status, progress, priority, remark, sort_order } = req.body;
|
||||
|
||||
const conditions = [`id = $1`];
|
||||
const params: any[] = [taskId];
|
||||
let pi = 2;
|
||||
if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; }
|
||||
|
||||
const sets: string[] = [];
|
||||
const fields: Record<string, any> = { name, category, assignee, start_date, end_date, status, progress, priority, remark, sort_order };
|
||||
for (const [key, val] of Object.entries(fields)) {
|
||||
if (val !== undefined) { sets.push(`${key} = $${pi}`); params.push(val); pi++; }
|
||||
}
|
||||
sets.push(`updated_date = now()`);
|
||||
|
||||
const result = await query(`UPDATE dsn_project_task SET ${sets.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`, params);
|
||||
if (!result.length) { res.status(404).json({ success: false, message: "태스크를 찾을 수 없습니다." }); return; }
|
||||
res.json({ success: true, data: result[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("태스크 수정 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTask(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const { taskId } = req.params;
|
||||
const conditions = [`id = $1`];
|
||||
const params: any[] = [taskId];
|
||||
if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); }
|
||||
|
||||
const result = await query(`DELETE FROM dsn_project_task WHERE ${conditions.join(" AND ")} RETURNING id`, params);
|
||||
if (!result.length) { res.status(404).json({ success: false, message: "태스크를 찾을 수 없습니다." }); return; }
|
||||
res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
logger.error("태스크 삭제 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 작업일지 CRUD
|
||||
// ============================================
|
||||
|
||||
export async function getWorkLogsByTask(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const { taskId } = req.params;
|
||||
|
||||
const conditions = [`w.task_id = $1`];
|
||||
const params: any[] = [taskId];
|
||||
if (companyCode !== "*") { conditions.push(`w.company_code = $2`); params.push(companyCode); }
|
||||
|
||||
const sql = `
|
||||
SELECT w.*,
|
||||
COALESCE((SELECT json_agg(json_build_object('id', a.id, 'file_name', a.file_name, 'file_type', a.file_type, 'file_size', a.file_size)) FROM dsn_work_attachment a WHERE a.work_log_id = w.id), '[]') AS attachments,
|
||||
COALESCE((SELECT json_agg(json_build_object('id', p.id, 'item', p.item, 'qty', p.qty, 'unit', p.unit, 'reason', p.reason, 'status', p.status)) FROM dsn_purchase_req p WHERE p.work_log_id = w.id), '[]') AS purchase_reqs,
|
||||
COALESCE((SELECT json_agg(json_build_object(
|
||||
'id', c.id, 'to_user', c.to_user, 'to_dept', c.to_dept, 'title', c.title, 'description', c.description, 'status', c.status, 'due_date', c.due_date,
|
||||
'responses', COALESCE((SELECT json_agg(json_build_object('id', cr.id, 'response_date', cr.response_date, 'user_name', cr.user_name, 'content', cr.content)) FROM dsn_coop_response cr WHERE cr.coop_req_id = c.id), '[]')
|
||||
)) FROM dsn_coop_req c WHERE c.work_log_id = w.id), '[]') AS coop_reqs
|
||||
FROM dsn_work_log w
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY w.start_dt DESC
|
||||
`;
|
||||
const result = await query(sql, params);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("작업일지 조회 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function createWorkLog(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const userId = req.user!.userId;
|
||||
const { taskId } = req.params;
|
||||
const { start_dt, end_dt, hours, description, progress_before, progress_after, author, sub_item_id } = req.body;
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO dsn_work_log (task_id, start_dt, end_dt, hours, description, progress_before, progress_after, author, sub_item_id, writer, company_code)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING *`,
|
||||
[taskId, start_dt, end_dt, hours || "0", description, progress_before || "0", progress_after || "0", author, sub_item_id, userId, companyCode]
|
||||
);
|
||||
res.json({ success: true, data: result[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("작업일지 생성 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteWorkLog(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const { workLogId } = req.params;
|
||||
const conditions = [`id = $1`];
|
||||
const params: any[] = [workLogId];
|
||||
if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); }
|
||||
|
||||
const result = await query(`DELETE FROM dsn_work_log WHERE ${conditions.join(" AND ")} RETURNING id`, params);
|
||||
if (!result.length) { res.status(404).json({ success: false, message: "작업일지를 찾을 수 없습니다." }); return; }
|
||||
res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
logger.error("작업일지 삭제 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 태스크 하위항목 CRUD
|
||||
// ============================================
|
||||
|
||||
export async function createSubItem(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const userId = req.user!.userId;
|
||||
const { taskId } = req.params;
|
||||
const { name, weight, progress, status } = req.body;
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO dsn_task_sub_item (task_id, name, weight, progress, status, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
|
||||
[taskId, name, weight || "0", progress || "0", status || "대기", userId, companyCode]
|
||||
);
|
||||
res.json({ success: true, data: result[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("하위항목 생성 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSubItem(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const { subItemId } = req.params;
|
||||
const { name, weight, progress, status } = req.body;
|
||||
|
||||
const conditions = [`id = $1`];
|
||||
const params: any[] = [subItemId];
|
||||
let pi = 2;
|
||||
if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; }
|
||||
|
||||
const sets: string[] = [];
|
||||
const fields: Record<string, any> = { name, weight, progress, status };
|
||||
for (const [key, val] of Object.entries(fields)) {
|
||||
if (val !== undefined) { sets.push(`${key} = $${pi}`); params.push(val); pi++; }
|
||||
}
|
||||
sets.push(`updated_date = now()`);
|
||||
|
||||
const result = await query(`UPDATE dsn_task_sub_item SET ${sets.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`, params);
|
||||
if (!result.length) { res.status(404).json({ success: false, message: "하위항목을 찾을 수 없습니다." }); return; }
|
||||
res.json({ success: true, data: result[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("하위항목 수정 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSubItem(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const { subItemId } = req.params;
|
||||
const conditions = [`id = $1`];
|
||||
const params: any[] = [subItemId];
|
||||
if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); }
|
||||
|
||||
const result = await query(`DELETE FROM dsn_task_sub_item WHERE ${conditions.join(" AND ")} RETURNING id`, params);
|
||||
if (!result.length) { res.status(404).json({ success: false, message: "하위항목을 찾을 수 없습니다." }); return; }
|
||||
res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
logger.error("하위항목 삭제 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 태스크 이슈 CRUD
|
||||
// ============================================
|
||||
|
||||
export async function createIssue(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const userId = req.user!.userId;
|
||||
const { taskId } = req.params;
|
||||
const { title, status, priority, description, registered_by, registered_date } = req.body;
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO dsn_task_issue (task_id, title, status, priority, description, registered_by, registered_date, writer, company_code)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *`,
|
||||
[taskId, title, status || "등록", priority || "보통", description, registered_by, registered_date, userId, companyCode]
|
||||
);
|
||||
res.json({ success: true, data: result[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("이슈 생성 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateIssue(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const { issueId } = req.params;
|
||||
const { title, status, priority, description, resolved_date } = req.body;
|
||||
|
||||
const conditions = [`id = $1`];
|
||||
const params: any[] = [issueId];
|
||||
let pi = 2;
|
||||
if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; }
|
||||
|
||||
const sets: string[] = [];
|
||||
const fields: Record<string, any> = { title, status, priority, description, resolved_date };
|
||||
for (const [key, val] of Object.entries(fields)) {
|
||||
if (val !== undefined) { sets.push(`${key} = $${pi}`); params.push(val); pi++; }
|
||||
}
|
||||
sets.push(`updated_date = now()`);
|
||||
|
||||
const result = await query(`UPDATE dsn_task_issue SET ${sets.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`, params);
|
||||
if (!result.length) { res.status(404).json({ success: false, message: "이슈를 찾을 수 없습니다." }); return; }
|
||||
res.json({ success: true, data: result[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("이슈 수정 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ECN (설변통보) CRUD
|
||||
// ============================================
|
||||
|
||||
export async function getEcnList(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const { status, search } = req.query;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let pi = 1;
|
||||
|
||||
if (companyCode !== "*") { conditions.push(`e.company_code = $${pi}`); params.push(companyCode); pi++; }
|
||||
if (status) { conditions.push(`e.status = $${pi}`); params.push(status); pi++; }
|
||||
if (search) {
|
||||
conditions.push(`(e.ecn_no ILIKE $${pi} OR e.target ILIKE $${pi})`);
|
||||
params.push(`%${search}%`);
|
||||
pi++;
|
||||
}
|
||||
|
||||
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const sql = `
|
||||
SELECT e.*,
|
||||
COALESCE((SELECT json_agg(json_build_object('id', h.id, 'status', h.status, 'history_date', h.history_date, 'user_name', h.user_name, 'description', h.description) ORDER BY h.created_date) FROM dsn_ecn_history h WHERE h.ecn_id = e.id), '[]') AS history,
|
||||
COALESCE((SELECT json_agg(nd.dept_name) FROM dsn_ecn_notify_dept nd WHERE nd.ecn_id = e.id), '[]') AS notify_depts
|
||||
FROM dsn_ecn e
|
||||
${where}
|
||||
ORDER BY e.created_date DESC
|
||||
`;
|
||||
const result = await query(sql, params);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("ECN 목록 조회 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function createEcn(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const userId = req.user!.userId;
|
||||
const { ecn_no, ecr_id, ecn_date, apply_date, status, target, drawing_before, drawing_after, designer, before_content, after_content, reason, remark, notify_depts, history } = req.body;
|
||||
|
||||
const result = await client.query(
|
||||
`INSERT INTO dsn_ecn (ecn_no, ecr_id, ecn_date, apply_date, status, target, drawing_before, drawing_after, designer, before_content, after_content, reason, remark, writer, company_code)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING *`,
|
||||
[ecn_no, ecr_id, ecn_date, apply_date, status || "ECN발행", target, drawing_before, drawing_after, designer, before_content, after_content, reason, remark, userId, companyCode]
|
||||
);
|
||||
|
||||
const ecnId = result.rows[0].id;
|
||||
|
||||
if (notify_depts?.length) {
|
||||
for (const dept of notify_depts) {
|
||||
await client.query(`INSERT INTO dsn_ecn_notify_dept (ecn_id, dept_name, writer, company_code) VALUES ($1,$2,$3,$4)`, [ecnId, dept, userId, companyCode]);
|
||||
}
|
||||
}
|
||||
|
||||
if (history?.length) {
|
||||
for (const h of history) {
|
||||
await client.query(
|
||||
`INSERT INTO dsn_ecn_history (ecn_id, status, history_date, user_name, description, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||
[ecnId, h.status, h.history_date, h.user_name, h.description, userId, companyCode]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("ECN 생성 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateEcn(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const userId = req.user!.userId;
|
||||
const { id } = req.params;
|
||||
const { ecn_no, ecn_date, apply_date, status, target, drawing_before, drawing_after, designer, before_content, after_content, reason, remark, notify_depts, history } = req.body;
|
||||
|
||||
const conditions = [`id = $1`];
|
||||
const params: any[] = [id];
|
||||
let pi = 2;
|
||||
if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; }
|
||||
|
||||
const sets: string[] = [];
|
||||
const fields: Record<string, any> = { ecn_no, ecn_date, apply_date, status, target, drawing_before, drawing_after, designer, before_content, after_content, reason, remark };
|
||||
for (const [key, val] of Object.entries(fields)) {
|
||||
if (val !== undefined) { sets.push(`${key} = $${pi}`); params.push(val); pi++; }
|
||||
}
|
||||
sets.push(`updated_date = now()`);
|
||||
|
||||
const result = await client.query(`UPDATE dsn_ecn SET ${sets.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`, params);
|
||||
if (!result.rowCount) { await client.query("ROLLBACK"); res.status(404).json({ success: false, message: "ECN을 찾을 수 없습니다." }); return; }
|
||||
|
||||
if (notify_depts !== undefined) {
|
||||
await client.query(`DELETE FROM dsn_ecn_notify_dept WHERE ecn_id = $1`, [id]);
|
||||
for (const dept of notify_depts) {
|
||||
await client.query(`INSERT INTO dsn_ecn_notify_dept (ecn_id, dept_name, writer, company_code) VALUES ($1,$2,$3,$4)`, [id, dept, userId, companyCode]);
|
||||
}
|
||||
}
|
||||
if (history !== undefined) {
|
||||
await client.query(`DELETE FROM dsn_ecn_history WHERE ecn_id = $1`, [id]);
|
||||
for (const h of history) {
|
||||
await client.query(
|
||||
`INSERT INTO dsn_ecn_history (ecn_id, status, history_date, user_name, description, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||
[id, h.status, h.history_date, h.user_name, h.description, userId, companyCode]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("ECN 수정 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteEcn(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const { id } = req.params;
|
||||
const conditions = [`id = $1`];
|
||||
const params: any[] = [id];
|
||||
if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); }
|
||||
|
||||
const result = await query(`DELETE FROM dsn_ecn WHERE ${conditions.join(" AND ")} RETURNING id`, params);
|
||||
if (!result.length) { res.status(404).json({ success: false, message: "ECN을 찾을 수 없습니다." }); return; }
|
||||
res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
logger.error("ECN 삭제 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 나의 업무 (My Work) - 로그인 사용자 기준
|
||||
// ============================================
|
||||
|
||||
export async function getMyWork(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const userName = req.user!.userName;
|
||||
const { status, project_id } = req.query;
|
||||
|
||||
const conditions = [`t.assignee = $1`];
|
||||
const params: any[] = [userName];
|
||||
let pi = 2;
|
||||
|
||||
if (companyCode !== "*") { conditions.push(`t.company_code = $${pi}`); params.push(companyCode); pi++; }
|
||||
if (status) { conditions.push(`t.status = $${pi}`); params.push(status); pi++; }
|
||||
if (project_id) { conditions.push(`t.project_id = $${pi}`); params.push(project_id); pi++; }
|
||||
|
||||
const sql = `
|
||||
SELECT t.*,
|
||||
p.project_no, p.name AS project_name, p.customer AS project_customer, p.status AS project_status,
|
||||
COALESCE((SELECT json_agg(json_build_object('id', s.id, 'name', s.name, 'weight', s.weight, 'progress', s.progress, 'status', s.status) ORDER BY s.created_date) FROM dsn_task_sub_item s WHERE s.task_id = t.id), '[]') AS sub_items,
|
||||
COALESCE((SELECT json_agg(json_build_object(
|
||||
'id', w.id, 'start_dt', w.start_dt, 'end_dt', w.end_dt, 'hours', w.hours, 'description', w.description, 'sub_item_id', w.sub_item_id,
|
||||
'attachments', COALESCE((SELECT json_agg(json_build_object('id', a.id, 'file_name', a.file_name, 'file_type', a.file_type, 'file_size', a.file_size)) FROM dsn_work_attachment a WHERE a.work_log_id = w.id), '[]'),
|
||||
'purchase_reqs', COALESCE((SELECT json_agg(json_build_object('id', pr.id, 'item', pr.item, 'qty', pr.qty, 'unit', pr.unit, 'reason', pr.reason, 'status', pr.status)) FROM dsn_purchase_req pr WHERE pr.work_log_id = w.id), '[]'),
|
||||
'coop_reqs', COALESCE((SELECT json_agg(json_build_object(
|
||||
'id', c.id, 'to_user', c.to_user, 'to_dept', c.to_dept, 'title', c.title, 'description', c.description, 'status', c.status, 'due_date', c.due_date,
|
||||
'responses', COALESCE((SELECT json_agg(json_build_object('id', cr.id, 'response_date', cr.response_date, 'user_name', cr.user_name, 'content', cr.content)) FROM dsn_coop_response cr WHERE cr.coop_req_id = c.id), '[]')
|
||||
)) FROM dsn_coop_req c WHERE c.work_log_id = w.id), '[]')
|
||||
) ORDER BY w.start_dt DESC) FROM dsn_work_log w WHERE w.task_id = t.id), '[]') AS work_logs
|
||||
FROM dsn_project_task t
|
||||
JOIN dsn_project p ON p.id = t.project_id
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY
|
||||
CASE t.status WHEN '진행중' THEN 1 WHEN '대기' THEN 2 WHEN '검토중' THEN 3 ELSE 4 END,
|
||||
t.end_date ASC
|
||||
`;
|
||||
const result = await query(sql, params);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("나의 업무 조회 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 구매요청 / 협업요청 CRUD (my-work에서 사용)
|
||||
// ============================================
|
||||
|
||||
export async function createPurchaseReq(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const userId = req.user!.userId;
|
||||
const { workLogId } = req.params;
|
||||
const { item, qty, unit, reason, status } = req.body;
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO dsn_purchase_req (work_log_id, item, qty, unit, reason, status, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
|
||||
[workLogId, item, qty, unit, reason, status || "요청", userId, companyCode]
|
||||
);
|
||||
res.json({ success: true, data: result[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("구매요청 생성 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCoopReq(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const userId = req.user!.userId;
|
||||
const { workLogId } = req.params;
|
||||
const { to_user, to_dept, title, description, due_date } = req.body;
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO dsn_coop_req (work_log_id, to_user, to_dept, title, description, status, due_date, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *`,
|
||||
[workLogId, to_user, to_dept, title, description, "요청", due_date, userId, companyCode]
|
||||
);
|
||||
res.json({ success: true, data: result[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("협업요청 생성 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function addCoopResponse(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const userId = req.user!.userId;
|
||||
const { coopReqId } = req.params;
|
||||
const { response_date, user_name, content } = req.body;
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO dsn_coop_response (coop_req_id, response_date, user_name, content, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
|
||||
[coopReqId, response_date, user_name, content, userId, companyCode]
|
||||
);
|
||||
res.json({ success: true, data: result[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("협업응답 추가 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,352 @@
|
|||
/**
|
||||
* 자재현황 컨트롤러
|
||||
* - 생산계획(작업지시) 조회
|
||||
* - 선택된 작업지시의 BOM 기반 자재소요량 + 재고 현황 조회
|
||||
* - 창고 목록 조회
|
||||
*/
|
||||
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { pool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// ─── 생산계획(작업지시) 조회 ───
|
||||
|
||||
export async function getWorkOrders(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { dateFrom, dateTo, itemCode, itemName } = req.query;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (companyCode === "*") {
|
||||
logger.info("최고 관리자 전체 작업지시 조회");
|
||||
} else {
|
||||
conditions.push(`p.company_code = $${paramIndex}`);
|
||||
params.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (dateFrom) {
|
||||
conditions.push(`p.plan_date >= $${paramIndex}::date`);
|
||||
params.push(dateFrom);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (dateTo) {
|
||||
conditions.push(`p.plan_date <= $${paramIndex}::date`);
|
||||
params.push(dateTo);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (itemCode) {
|
||||
conditions.push(`p.item_code ILIKE $${paramIndex}`);
|
||||
params.push(`%${itemCode}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (itemName) {
|
||||
conditions.push(`p.item_name ILIKE $${paramIndex}`);
|
||||
params.push(`%${itemName}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
p.id,
|
||||
p.plan_no,
|
||||
p.item_code,
|
||||
p.item_name,
|
||||
p.plan_qty,
|
||||
p.completed_qty,
|
||||
p.plan_date,
|
||||
p.start_date,
|
||||
p.end_date,
|
||||
p.status,
|
||||
p.work_order_no,
|
||||
p.company_code
|
||||
FROM production_plan_mng p
|
||||
${whereClause}
|
||||
ORDER BY p.plan_date DESC, p.created_date DESC
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
logger.info("작업지시 조회 완료", {
|
||||
companyCode,
|
||||
rowCount: result.rowCount,
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("작업지시 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 선택된 작업지시의 자재소요 + 재고 현황 조회 ───
|
||||
|
||||
export async function getMaterialStatus(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { planIds, warehouseCode } = req.body;
|
||||
|
||||
if (!planIds || !Array.isArray(planIds) || planIds.length === 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, message: "작업지시를 선택해주세요." });
|
||||
}
|
||||
|
||||
// 1) 선택된 작업지시의 품목코드 + 수량 조회
|
||||
const planPlaceholders = planIds
|
||||
.map((_, i) => `$${i + 1}`)
|
||||
.join(",");
|
||||
let paramIndex = planIds.length + 1;
|
||||
|
||||
const companyCondition =
|
||||
companyCode === "*" ? "" : `AND p.company_code = $${paramIndex}`;
|
||||
const planParams: any[] = [...planIds];
|
||||
if (companyCode !== "*") {
|
||||
planParams.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const planQuery = `
|
||||
SELECT p.item_code, p.item_name, p.plan_qty
|
||||
FROM production_plan_mng p
|
||||
WHERE p.id IN (${planPlaceholders})
|
||||
${companyCondition}
|
||||
`;
|
||||
|
||||
const planResult = await pool.query(planQuery, planParams);
|
||||
|
||||
if (planResult.rowCount === 0) {
|
||||
return res.json({ success: true, data: [] });
|
||||
}
|
||||
|
||||
// 2) 해당 품목들의 BOM에서 필요 자재 목록 조회
|
||||
const itemCodes = planResult.rows.map((r: any) => r.item_code);
|
||||
const planQtyMap: Record<string, number> = {};
|
||||
for (const row of planResult.rows) {
|
||||
const code = row.item_code;
|
||||
planQtyMap[code] = (planQtyMap[code] || 0) + Number(row.plan_qty || 0);
|
||||
}
|
||||
|
||||
const itemPlaceholders = itemCodes.map((_: any, i: number) => `$${i + 1}`).join(",");
|
||||
|
||||
// BOM 조인: bom -> bom_detail -> item_info (자재 정보)
|
||||
const bomCompanyCondition =
|
||||
companyCode === "*" ? "" : `AND b.company_code = $${itemCodes.length + 1}`;
|
||||
const bomParams: any[] = [...itemCodes];
|
||||
if (companyCode !== "*") {
|
||||
bomParams.push(companyCode);
|
||||
}
|
||||
|
||||
const bomQuery = `
|
||||
SELECT
|
||||
b.item_code AS parent_item_code,
|
||||
b.base_qty AS bom_base_qty,
|
||||
bd.child_item_id,
|
||||
bd.quantity AS bom_qty,
|
||||
bd.unit AS bom_unit,
|
||||
bd.loss_rate,
|
||||
ii.item_name AS material_name,
|
||||
ii.item_number AS material_code,
|
||||
ii.unit AS material_unit
|
||||
FROM bom b
|
||||
JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code
|
||||
LEFT JOIN item_info ii ON bd.child_item_id = ii.id AND b.company_code = ii.company_code
|
||||
WHERE b.item_code IN (${itemPlaceholders})
|
||||
${bomCompanyCondition}
|
||||
ORDER BY b.item_code, bd.seq_no
|
||||
`;
|
||||
|
||||
const bomResult = await pool.query(bomQuery, bomParams);
|
||||
|
||||
// 3) 자재별 필요수량 계산
|
||||
interface MaterialNeed {
|
||||
childItemId: string;
|
||||
materialCode: string;
|
||||
materialName: string;
|
||||
unit: string;
|
||||
requiredQty: number;
|
||||
}
|
||||
|
||||
const materialMap: Record<string, MaterialNeed> = {};
|
||||
|
||||
for (const bomRow of bomResult.rows) {
|
||||
const parentQty = planQtyMap[bomRow.parent_item_code] || 0;
|
||||
const baseQty = Number(bomRow.bom_base_qty) || 1;
|
||||
const bomQty = Number(bomRow.bom_qty) || 0;
|
||||
const lossRate = Number(bomRow.loss_rate) || 0;
|
||||
|
||||
// 필요수량 = (생산수량 / BOM기준수량) * BOM자재수량 * (1 + 로스율/100)
|
||||
const requiredQty =
|
||||
(parentQty / baseQty) * bomQty * (1 + lossRate / 100);
|
||||
|
||||
const key = bomRow.child_item_id;
|
||||
if (materialMap[key]) {
|
||||
materialMap[key].requiredQty += requiredQty;
|
||||
} else {
|
||||
materialMap[key] = {
|
||||
childItemId: bomRow.child_item_id,
|
||||
materialCode:
|
||||
bomRow.material_code || bomRow.child_item_id,
|
||||
materialName: bomRow.material_name || "알 수 없음",
|
||||
unit: bomRow.bom_unit || bomRow.material_unit || "EA",
|
||||
requiredQty,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const materialIds = Object.keys(materialMap);
|
||||
|
||||
if (materialIds.length === 0) {
|
||||
return res.json({ success: true, data: [] });
|
||||
}
|
||||
|
||||
// 4) 재고 조회 (창고/위치별)
|
||||
const stockPlaceholders = materialIds
|
||||
.map((_, i) => `$${i + 1}`)
|
||||
.join(",");
|
||||
const stockParams: any[] = [...materialIds];
|
||||
let stockParamIdx = materialIds.length + 1;
|
||||
|
||||
const stockConditions: string[] = [
|
||||
`s.item_code IN (${stockPlaceholders})`,
|
||||
];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
stockConditions.push(`s.company_code = $${stockParamIdx}`);
|
||||
stockParams.push(companyCode);
|
||||
stockParamIdx++;
|
||||
}
|
||||
|
||||
if (warehouseCode) {
|
||||
stockConditions.push(`s.warehouse_code = $${stockParamIdx}`);
|
||||
stockParams.push(warehouseCode);
|
||||
stockParamIdx++;
|
||||
}
|
||||
|
||||
const stockQuery = `
|
||||
SELECT
|
||||
s.item_code,
|
||||
s.warehouse_code,
|
||||
s.location_code,
|
||||
COALESCE(CAST(s.current_qty AS NUMERIC), 0) AS current_qty
|
||||
FROM inventory_stock s
|
||||
WHERE ${stockConditions.join(" AND ")}
|
||||
AND COALESCE(CAST(s.current_qty AS NUMERIC), 0) > 0
|
||||
ORDER BY s.item_code, s.warehouse_code, s.location_code
|
||||
`;
|
||||
|
||||
const stockResult = await pool.query(stockQuery, stockParams);
|
||||
|
||||
// 5) 결과 조합
|
||||
// item_code 기준 재고 맵핑 (inventory_stock.item_code는 item_info.item_number 또는 item_info.id일 수 있음)
|
||||
const stockByItem: Record<
|
||||
string,
|
||||
{ location: string; warehouse: string; qty: number }[]
|
||||
> = {};
|
||||
|
||||
for (const stockRow of stockResult.rows) {
|
||||
const code = stockRow.item_code;
|
||||
if (!stockByItem[code]) {
|
||||
stockByItem[code] = [];
|
||||
}
|
||||
stockByItem[code].push({
|
||||
location: stockRow.location_code || "",
|
||||
warehouse: stockRow.warehouse_code || "",
|
||||
qty: Number(stockRow.current_qty),
|
||||
});
|
||||
}
|
||||
|
||||
const resultData = materialIds.map((id) => {
|
||||
const material = materialMap[id];
|
||||
// inventory_stock의 item_code가 item_number 또는 child_item_id일 수 있음
|
||||
const locations =
|
||||
stockByItem[material.materialCode] ||
|
||||
stockByItem[id] ||
|
||||
[];
|
||||
|
||||
const totalCurrentQty = locations.reduce(
|
||||
(sum, loc) => sum + loc.qty,
|
||||
0
|
||||
);
|
||||
|
||||
return {
|
||||
code: material.materialCode,
|
||||
name: material.materialName,
|
||||
required: Math.round(material.requiredQty * 100) / 100,
|
||||
current: totalCurrentQty,
|
||||
unit: material.unit,
|
||||
locations,
|
||||
};
|
||||
});
|
||||
|
||||
logger.info("자재현황 조회 완료", {
|
||||
companyCode,
|
||||
planCount: planIds.length,
|
||||
materialCount: resultData.length,
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: resultData });
|
||||
} catch (error: any) {
|
||||
logger.error("자재현황 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 창고 목록 조회 ───
|
||||
|
||||
export async function getWarehouses(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
query = `
|
||||
SELECT DISTINCT warehouse_code, warehouse_name, warehouse_type
|
||||
FROM warehouse_info
|
||||
ORDER BY warehouse_code
|
||||
`;
|
||||
params = [];
|
||||
} else {
|
||||
query = `
|
||||
SELECT DISTINCT warehouse_code, warehouse_name, warehouse_type
|
||||
FROM warehouse_info
|
||||
WHERE company_code = $1
|
||||
ORDER BY warehouse_code
|
||||
`;
|
||||
params = [companyCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
logger.info("창고 목록 조회 완료", {
|
||||
companyCode,
|
||||
rowCount: result.rowCount,
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("창고 목록 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,463 @@
|
|||
/**
|
||||
* 공정정보관리 컨트롤러
|
||||
* - 공정 마스터 CRUD
|
||||
* - 공정별 설비 관리
|
||||
* - 품목별 라우팅 관리
|
||||
*/
|
||||
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { pool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// 공정 마스터 CRUD
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
export async function getProcessList(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { processCode, processName, processType, useYn } = req.query;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (companyCode !== "*") {
|
||||
conditions.push(`company_code = $${idx++}`);
|
||||
params.push(companyCode);
|
||||
}
|
||||
if (processCode) {
|
||||
conditions.push(`process_code ILIKE $${idx++}`);
|
||||
params.push(`%${processCode}%`);
|
||||
}
|
||||
if (processName) {
|
||||
conditions.push(`process_name ILIKE $${idx++}`);
|
||||
params.push(`%${processName}%`);
|
||||
}
|
||||
if (processType) {
|
||||
conditions.push(`process_type = $${idx++}`);
|
||||
params.push(processType);
|
||||
}
|
||||
if (useYn) {
|
||||
conditions.push(`use_yn = $${idx++}`);
|
||||
params.push(useYn);
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM process_mng ${where} ORDER BY process_code`,
|
||||
params
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("공정 목록 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function createProcess(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const writer = req.user!.userId;
|
||||
const { process_name, process_type, standard_time, worker_count, use_yn } = req.body;
|
||||
|
||||
// 공정코드 자동 채번: PROC-001, PROC-002, ...
|
||||
const seqRes = await pool.query(
|
||||
`SELECT process_code FROM process_mng WHERE company_code = $1 AND process_code LIKE 'PROC-%' ORDER BY process_code DESC LIMIT 1`,
|
||||
[companyCode]
|
||||
);
|
||||
let nextNum = 1;
|
||||
if (seqRes.rowCount! > 0) {
|
||||
const lastCode = seqRes.rows[0].process_code;
|
||||
const numPart = parseInt(lastCode.replace("PROC-", ""), 10);
|
||||
if (!isNaN(numPart)) nextNum = numPart + 1;
|
||||
}
|
||||
const processCode = `PROC-${String(nextNum).padStart(3, "0")}`;
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO process_mng (id, company_code, process_code, process_name, process_type, standard_time, worker_count, use_yn, writer)
|
||||
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
|
||||
[companyCode, processCode, process_name, process_type, standard_time || "0", worker_count || "0", use_yn || "Y", writer]
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("공정 등록 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateProcess(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { id } = req.params;
|
||||
const { process_name, process_type, standard_time, worker_count, use_yn } = req.body;
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE process_mng SET process_name=$1, process_type=$2, standard_time=$3, worker_count=$4, use_yn=$5, updated_date=NOW()
|
||||
WHERE id=$6 AND company_code=$7 RETURNING *`,
|
||||
[process_name, process_type, standard_time, worker_count, use_yn, id, companyCode]
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({ success: false, message: "공정을 찾을 수 없습니다." });
|
||||
}
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("공정 수정 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteProcesses(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ids } = req.body;
|
||||
|
||||
if (!ids || !Array.isArray(ids) || ids.length === 0) {
|
||||
return res.status(400).json({ success: false, message: "삭제할 공정을 선택해주세요." });
|
||||
}
|
||||
|
||||
const placeholders = ids.map((_: any, i: number) => `$${i + 1}`).join(",");
|
||||
// 설비 매핑도 삭제
|
||||
await pool.query(
|
||||
`DELETE FROM process_equipment WHERE process_code IN (SELECT process_code FROM process_mng WHERE id IN (${placeholders}) AND company_code = $${ids.length + 1})`,
|
||||
[...ids, companyCode]
|
||||
);
|
||||
const result = await pool.query(
|
||||
`DELETE FROM process_mng WHERE id IN (${placeholders}) AND company_code = $${ids.length + 1} RETURNING id`,
|
||||
[...ids, companyCode]
|
||||
);
|
||||
|
||||
return res.json({ success: true, deletedCount: result.rowCount });
|
||||
} catch (error: any) {
|
||||
logger.error("공정 삭제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// 공정별 설비 관리
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
export async function getProcessEquipments(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { processCode } = req.params;
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT pe.*, ei.equipment_name
|
||||
FROM process_equipment pe
|
||||
LEFT JOIN equipment_info ei ON pe.equipment_code = ei.equipment_code AND pe.company_code = ei.company_code
|
||||
WHERE pe.process_code = $1 AND pe.company_code = $2
|
||||
ORDER BY pe.equipment_code`,
|
||||
[processCode, companyCode]
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("공정 설비 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function addProcessEquipment(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const writer = req.user!.userId;
|
||||
const { process_code, equipment_code } = req.body;
|
||||
|
||||
const dupCheck = await pool.query(
|
||||
`SELECT id FROM process_equipment WHERE process_code=$1 AND equipment_code=$2 AND company_code=$3`,
|
||||
[process_code, equipment_code, companyCode]
|
||||
);
|
||||
if (dupCheck.rowCount! > 0) {
|
||||
return res.status(400).json({ success: false, message: "이미 등록된 설비입니다." });
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO process_equipment (id, company_code, process_code, equipment_code, writer)
|
||||
VALUES (gen_random_uuid()::text, $1, $2, $3, $4) RETURNING *`,
|
||||
[companyCode, process_code, equipment_code, writer]
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("공정 설비 등록 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeProcessEquipment(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { id } = req.params;
|
||||
|
||||
await pool.query(
|
||||
`DELETE FROM process_equipment WHERE id=$1 AND company_code=$2`,
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
return res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
logger.error("공정 설비 제거 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEquipmentList(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const condition = companyCode === "*" ? "" : `WHERE company_code = $1`;
|
||||
const params = companyCode === "*" ? [] : [companyCode];
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT id, equipment_code, equipment_name FROM equipment_info ${condition} ORDER BY equipment_code`,
|
||||
params
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("설비 목록 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// 품목별 라우팅 관리
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
export async function getItemsForRouting(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { search } = req.query;
|
||||
|
||||
const conditions: string[] = ["i.company_code = rv.company_code"];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (companyCode !== "*") {
|
||||
conditions.push(`i.company_code = $${idx++}`);
|
||||
params.push(companyCode);
|
||||
}
|
||||
if (search) {
|
||||
conditions.push(`(i.item_number ILIKE $${idx} OR i.item_name ILIKE $${idx})`);
|
||||
params.push(`%${search}%`);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const result = await pool.query(
|
||||
`SELECT DISTINCT i.id, i.item_number, i.item_name, i.size, i.unit, i.type
|
||||
FROM item_info i
|
||||
INNER JOIN item_routing_version rv ON rv.item_code = i.item_number AND rv.company_code = i.company_code
|
||||
${where}
|
||||
ORDER BY i.item_number LIMIT 200`,
|
||||
params
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("라우팅 등록 품목 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchAllItems(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { search } = req.query;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (companyCode !== "*") {
|
||||
conditions.push(`company_code = $${idx++}`);
|
||||
params.push(companyCode);
|
||||
}
|
||||
if (search) {
|
||||
conditions.push(`(item_number ILIKE $${idx} OR item_name ILIKE $${idx})`);
|
||||
params.push(`%${search}%`);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const result = await pool.query(
|
||||
`SELECT id, item_number, item_name, size, unit, type FROM item_info ${where} ORDER BY item_number LIMIT 200`,
|
||||
params
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("전체 품목 검색 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRoutingVersions(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { itemCode } = req.params;
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM item_routing_version WHERE item_code=$1 AND company_code=$2 ORDER BY created_date`,
|
||||
[itemCode, companyCode]
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("라우팅 버전 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function createRoutingVersion(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const writer = req.user!.userId;
|
||||
const { item_code, version_name, description, is_default } = req.body;
|
||||
|
||||
if (is_default) {
|
||||
await pool.query(
|
||||
`UPDATE item_routing_version SET is_default=false WHERE item_code=$1 AND company_code=$2`,
|
||||
[item_code, companyCode]
|
||||
);
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO item_routing_version (id, company_code, item_code, version_name, description, is_default, writer)
|
||||
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6) RETURNING *`,
|
||||
[companyCode, item_code, version_name, description || "", is_default || false, writer]
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("라우팅 버전 생성 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteRoutingVersion(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { id } = req.params;
|
||||
|
||||
await pool.query(
|
||||
`DELETE FROM item_routing_detail WHERE routing_version_id=$1 AND company_code=$2`,
|
||||
[id, companyCode]
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM item_routing_version WHERE id=$1 AND company_code=$2`,
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
return res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
logger.error("라우팅 버전 삭제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRoutingDetails(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { versionId } = req.params;
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT rd.*, pm.process_name
|
||||
FROM item_routing_detail rd
|
||||
LEFT JOIN process_mng pm ON rd.process_code = pm.process_code AND rd.company_code = pm.company_code
|
||||
WHERE rd.routing_version_id=$1 AND rd.company_code=$2
|
||||
ORDER BY CAST(rd.seq_no AS INTEGER)`,
|
||||
[versionId, companyCode]
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("라우팅 상세 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveRoutingDetails(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const writer = req.user!.userId;
|
||||
const { versionId } = req.params;
|
||||
const { details } = req.body;
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 기존 상세 삭제 후 재입력
|
||||
await client.query(
|
||||
`DELETE FROM item_routing_detail WHERE routing_version_id=$1 AND company_code=$2`,
|
||||
[versionId, companyCode]
|
||||
);
|
||||
|
||||
for (const d of details) {
|
||||
await client.query(
|
||||
`INSERT INTO item_routing_detail (id, company_code, routing_version_id, seq_no, process_code, is_required, is_fixed_order, work_type, standard_time, outsource_supplier, writer)
|
||||
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
[companyCode, versionId, d.seq_no, d.process_code, d.is_required || "Y", d.is_fixed_order || "Y", d.work_type || "내부", d.standard_time || "0", d.outsource_supplier || "", writer]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
return res.json({ success: true });
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK");
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error("라우팅 상세 저장 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// BOM 구성 자재 조회 (품목코드 기반)
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
export async function getBomMaterials(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { itemCode } = req.params;
|
||||
|
||||
if (!itemCode) {
|
||||
return res.status(400).json({ success: false, message: "itemCode는 필수입니다" });
|
||||
}
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
bd.id,
|
||||
bd.child_item_id,
|
||||
bd.quantity,
|
||||
bd.unit as detail_unit,
|
||||
bd.process_type,
|
||||
i.item_name as child_item_name,
|
||||
i.item_number as child_item_code,
|
||||
i.type as child_item_type,
|
||||
i.unit as item_unit
|
||||
FROM bom b
|
||||
JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code
|
||||
LEFT JOIN item_info i ON bd.child_item_id = i.id AND bd.company_code = i.company_code
|
||||
WHERE b.item_code = $1 AND b.company_code = $2
|
||||
ORDER BY bd.seq_no ASC, bd.created_date ASC
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [itemCode, companyCode]);
|
||||
|
||||
logger.info("BOM 자재 조회 성공", { companyCode, itemCode, count: result.rowCount });
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("BOM 자재 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,487 @@
|
|||
/**
|
||||
* 입고관리 컨트롤러
|
||||
*
|
||||
* 입고유형별 소스 테이블:
|
||||
* - 구매입고 → purchase_order_mng (발주)
|
||||
* - 반품입고 → shipment_instruction + shipment_instruction_detail (출하)
|
||||
* - 기타입고 → item_info (품목)
|
||||
*/
|
||||
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// 입고 목록 조회
|
||||
export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const {
|
||||
inbound_type,
|
||||
inbound_status,
|
||||
search_keyword,
|
||||
date_from,
|
||||
date_to,
|
||||
} = req.query;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIdx = 1;
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 전체 조회
|
||||
} else {
|
||||
conditions.push(`im.company_code = $${paramIdx}`);
|
||||
params.push(companyCode);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
if (inbound_type && inbound_type !== "all") {
|
||||
conditions.push(`im.inbound_type = $${paramIdx}`);
|
||||
params.push(inbound_type);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
if (inbound_status && inbound_status !== "all") {
|
||||
conditions.push(`im.inbound_status = $${paramIdx}`);
|
||||
params.push(inbound_status);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
if (search_keyword) {
|
||||
conditions.push(
|
||||
`(im.inbound_number ILIKE $${paramIdx} OR im.item_name ILIKE $${paramIdx} OR im.item_number ILIKE $${paramIdx} OR im.supplier_name ILIKE $${paramIdx} OR im.reference_number ILIKE $${paramIdx})`
|
||||
);
|
||||
params.push(`%${search_keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
if (date_from) {
|
||||
conditions.push(`im.inbound_date >= $${paramIdx}::date`);
|
||||
params.push(date_from);
|
||||
paramIdx++;
|
||||
}
|
||||
if (date_to) {
|
||||
conditions.push(`im.inbound_date <= $${paramIdx}::date`);
|
||||
params.push(date_to);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
im.*,
|
||||
wh.warehouse_name
|
||||
FROM inbound_mng im
|
||||
LEFT JOIN warehouse_info wh
|
||||
ON im.warehouse_code = wh.warehouse_code
|
||||
AND im.company_code = wh.company_code
|
||||
${whereClause}
|
||||
ORDER BY im.created_date DESC
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
logger.info("입고 목록 조회", {
|
||||
companyCode,
|
||||
rowCount: result.rowCount,
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("입고 목록 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 입고 등록 (다건)
|
||||
export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { items, inbound_number, inbound_date, warehouse_code, location_code, inspector, manager, memo } = req.body;
|
||||
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
return res.status(400).json({ success: false, message: "입고 품목이 없습니다." });
|
||||
}
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
const insertedRows: any[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const result = await client.query(
|
||||
`INSERT INTO inbound_mng (
|
||||
company_code, inbound_number, inbound_type, inbound_date,
|
||||
reference_number, supplier_code, supplier_name,
|
||||
item_number, item_name, spec, material, unit,
|
||||
inbound_qty, unit_price, total_amount,
|
||||
lot_number, warehouse_code, location_code,
|
||||
inbound_status, inspection_status,
|
||||
inspector, manager, memo,
|
||||
source_table, source_id,
|
||||
created_date, created_by, writer, status
|
||||
) VALUES (
|
||||
$1, $2, $3, $4::date,
|
||||
$5, $6, $7,
|
||||
$8, $9, $10, $11, $12,
|
||||
$13, $14, $15,
|
||||
$16, $17, $18,
|
||||
$19, $20,
|
||||
$21, $22, $23,
|
||||
$24, $25,
|
||||
NOW(), $26, $26, '입고'
|
||||
) RETURNING *`,
|
||||
[
|
||||
companyCode,
|
||||
inbound_number || item.inbound_number,
|
||||
item.inbound_type,
|
||||
inbound_date || item.inbound_date,
|
||||
item.reference_number || null,
|
||||
item.supplier_code || null,
|
||||
item.supplier_name || null,
|
||||
item.item_number || null,
|
||||
item.item_name || null,
|
||||
item.spec || null,
|
||||
item.material || null,
|
||||
item.unit || "EA",
|
||||
item.inbound_qty || 0,
|
||||
item.unit_price || 0,
|
||||
item.total_amount || 0,
|
||||
item.lot_number || null,
|
||||
warehouse_code || item.warehouse_code || null,
|
||||
location_code || item.location_code || null,
|
||||
item.inbound_status || "대기",
|
||||
item.inspection_status || "대기",
|
||||
inspector || item.inspector || null,
|
||||
manager || item.manager || null,
|
||||
memo || item.memo || null,
|
||||
item.source_table || null,
|
||||
item.source_id || null,
|
||||
userId,
|
||||
]
|
||||
);
|
||||
|
||||
insertedRows.push(result.rows[0]);
|
||||
|
||||
// 구매입고인 경우 발주의 received_qty 업데이트
|
||||
if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_order_mng") {
|
||||
await client.query(
|
||||
`UPDATE purchase_order_mng
|
||||
SET received_qty = CAST(
|
||||
COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1 AS text
|
||||
),
|
||||
remain_qty = CAST(
|
||||
GREATEST(COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
|
||||
- COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) - $1, 0) AS text
|
||||
),
|
||||
status = CASE
|
||||
WHEN COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1
|
||||
>= COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
|
||||
THEN '입고완료'
|
||||
ELSE '부분입고'
|
||||
END,
|
||||
updated_date = NOW()
|
||||
WHERE id = $2 AND company_code = $3`,
|
||||
[item.inbound_qty || 0, item.source_id, companyCode]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("입고 등록 완료", {
|
||||
companyCode,
|
||||
userId,
|
||||
count: insertedRows.length,
|
||||
inbound_number,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: insertedRows,
|
||||
message: `${insertedRows.length}건 입고 등록 완료`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("입고 등록 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// 입고 수정
|
||||
export async function update(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { id } = req.params;
|
||||
const {
|
||||
inbound_date, inbound_qty, unit_price, total_amount,
|
||||
lot_number, warehouse_code, location_code,
|
||||
inbound_status, inspection_status,
|
||||
inspector, manager: mgr, memo,
|
||||
} = req.body;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`UPDATE inbound_mng SET
|
||||
inbound_date = COALESCE($1::date, inbound_date),
|
||||
inbound_qty = COALESCE($2, inbound_qty),
|
||||
unit_price = COALESCE($3, unit_price),
|
||||
total_amount = COALESCE($4, total_amount),
|
||||
lot_number = COALESCE($5, lot_number),
|
||||
warehouse_code = COALESCE($6, warehouse_code),
|
||||
location_code = COALESCE($7, location_code),
|
||||
inbound_status = COALESCE($8, inbound_status),
|
||||
inspection_status = COALESCE($9, inspection_status),
|
||||
inspector = COALESCE($10, inspector),
|
||||
manager = COALESCE($11, manager),
|
||||
memo = COALESCE($12, memo),
|
||||
updated_date = NOW(),
|
||||
updated_by = $13
|
||||
WHERE id = $14 AND company_code = $15
|
||||
RETURNING *`,
|
||||
[
|
||||
inbound_date, inbound_qty, unit_price, total_amount,
|
||||
lot_number, warehouse_code, location_code,
|
||||
inbound_status, inspection_status,
|
||||
inspector, mgr, memo,
|
||||
userId, id, companyCode,
|
||||
]
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({ success: false, message: "입고 데이터를 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
logger.info("입고 수정", { companyCode, userId, id });
|
||||
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("입고 수정 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 입고 삭제
|
||||
export async function deleteReceiving(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { id } = req.params;
|
||||
const pool = getPool();
|
||||
|
||||
const result = await pool.query(
|
||||
`DELETE FROM inbound_mng WHERE id = $1 AND company_code = $2 RETURNING id`,
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
logger.info("입고 삭제", { companyCode, id });
|
||||
|
||||
return res.json({ success: true, message: "삭제 완료" });
|
||||
} catch (error: any) {
|
||||
logger.error("입고 삭제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 구매입고용: 발주 데이터 조회 (미입고분)
|
||||
export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword } = req.query;
|
||||
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIdx = 2;
|
||||
|
||||
// 잔량이 있는 것만 조회
|
||||
conditions.push(
|
||||
`COALESCE(CAST(NULLIF(remain_qty, '') AS numeric), COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) - COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0)) > 0`
|
||||
);
|
||||
conditions.push(`status NOT IN ('입고완료', '취소')`);
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(
|
||||
`(purchase_no ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx} OR item_code ILIKE $${paramIdx} OR supplier_name ILIKE $${paramIdx})`
|
||||
);
|
||||
params.push(`%${keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
id, purchase_no, order_date, supplier_code, supplier_name,
|
||||
item_code, item_name, spec, material,
|
||||
COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) AS order_qty,
|
||||
COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) AS received_qty,
|
||||
COALESCE(CAST(NULLIF(remain_qty, '') AS numeric),
|
||||
COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
|
||||
- COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0)
|
||||
) AS remain_qty,
|
||||
COALESCE(CAST(NULLIF(unit_price, '') AS numeric), 0) AS unit_price,
|
||||
status, due_date
|
||||
FROM purchase_order_mng
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY order_date DESC, purchase_no`,
|
||||
params
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("발주 데이터 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 반품입고용: 출하 데이터 조회
|
||||
export async function getShipments(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword } = req.query;
|
||||
|
||||
const conditions: string[] = ["si.company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIdx = 2;
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(
|
||||
`(si.instruction_no ILIKE $${paramIdx} OR sid.item_name ILIKE $${paramIdx} OR sid.item_code ILIKE $${paramIdx})`
|
||||
);
|
||||
params.push(`%${keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
sid.id AS detail_id,
|
||||
si.id AS instruction_id,
|
||||
si.instruction_no,
|
||||
si.instruction_date,
|
||||
si.partner_id,
|
||||
si.status AS instruction_status,
|
||||
sid.item_code,
|
||||
sid.item_name,
|
||||
sid.spec,
|
||||
sid.material,
|
||||
COALESCE(sid.ship_qty, 0) AS ship_qty,
|
||||
COALESCE(sid.order_qty, 0) AS order_qty,
|
||||
sid.source_type
|
||||
FROM shipment_instruction si
|
||||
JOIN shipment_instruction_detail sid
|
||||
ON si.id = sid.instruction_id
|
||||
AND si.company_code = sid.company_code
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY si.instruction_date DESC, si.instruction_no`,
|
||||
params
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("출하 데이터 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 기타입고용: 품목 데이터 조회
|
||||
export async function getItems(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword } = req.query;
|
||||
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIdx = 2;
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(
|
||||
`(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`
|
||||
);
|
||||
params.push(`%${keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
id, item_number, item_name, size AS spec, material, unit,
|
||||
COALESCE(CAST(NULLIF(standard_price, '') AS numeric), 0) AS standard_price
|
||||
FROM item_info
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY item_name`,
|
||||
params
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("품목 데이터 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 입고번호 자동생성
|
||||
export async function generateNumber(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const pool = getPool();
|
||||
|
||||
const today = new Date();
|
||||
const yyyy = today.getFullYear();
|
||||
const prefix = `RCV-${yyyy}-`;
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT inbound_number FROM inbound_mng
|
||||
WHERE company_code = $1 AND inbound_number LIKE $2
|
||||
ORDER BY inbound_number DESC LIMIT 1`,
|
||||
[companyCode, `${prefix}%`]
|
||||
);
|
||||
|
||||
let seq = 1;
|
||||
if (result.rows.length > 0) {
|
||||
const lastNo = result.rows[0].inbound_number;
|
||||
const lastSeq = parseInt(lastNo.replace(prefix, ""), 10);
|
||||
if (!isNaN(lastSeq)) seq = lastSeq + 1;
|
||||
}
|
||||
|
||||
const newNumber = `${prefix}${String(seq).padStart(4, "0")}`;
|
||||
|
||||
return res.json({ success: true, data: newNumber });
|
||||
} catch (error: any) {
|
||||
logger.error("입고번호 생성 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 창고 목록 조회
|
||||
export async function getWarehouses(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const pool = getPool();
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT warehouse_code, warehouse_name, warehouse_type
|
||||
FROM warehouse_info
|
||||
WHERE company_code = $1 AND status != '삭제'
|
||||
ORDER BY warehouse_name`,
|
||||
[companyCode]
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("창고 목록 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
import { Response } from "express";
|
||||
import { query } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
/**
|
||||
* 영업 리포트 컨트롤러
|
||||
* - 수주 데이터를 기반으로 집계/분석용 원본 데이터를 반환
|
||||
* - 프론트엔드에서 그룹핑/집계/필터링 처리
|
||||
*/
|
||||
export async function getSalesReportData(
|
||||
req: any,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
res.status(401).json({ success: false, message: "인증 정보가 없습니다" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { startDate, endDate } = req.query;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIdx = 1;
|
||||
|
||||
// 멀티테넌시: 최고관리자는 전체, 일반 회사는 자기 데이터만
|
||||
if (companyCode !== "*") {
|
||||
conditions.push(`som.company_code = $${paramIdx}`);
|
||||
params.push(companyCode);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
// 날짜 필터 (due_date 또는 order_date 기준)
|
||||
if (startDate) {
|
||||
conditions.push(
|
||||
`COALESCE(sod.due_date, som.order_date::text, som.created_date::date::text) >= $${paramIdx}`
|
||||
);
|
||||
params.push(startDate);
|
||||
paramIdx++;
|
||||
}
|
||||
if (endDate) {
|
||||
conditions.push(
|
||||
`COALESCE(sod.due_date, som.order_date::text, som.created_date::date::text) <= $${paramIdx}`
|
||||
);
|
||||
params.push(endDate);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
som.order_no,
|
||||
COALESCE(sod.due_date, som.order_date::text, som.created_date::date::text) as date,
|
||||
som.order_date,
|
||||
som.partner_id,
|
||||
COALESCE(cm.customer_name, som.partner_id, '미지정') as customer,
|
||||
sod.part_code,
|
||||
COALESCE(ii.item_name, sod.part_name, sod.part_code, '미지정') as item,
|
||||
CAST(COALESCE(NULLIF(sod.qty, ''), '0') AS numeric) as "orderQty",
|
||||
CAST(COALESCE(NULLIF(sod.ship_qty, ''), '0') AS numeric) as "shipQty",
|
||||
CAST(COALESCE(NULLIF(sod.unit_price, ''), '0') AS numeric) as "unitPrice",
|
||||
CAST(COALESCE(NULLIF(sod.amount, ''), '0') AS numeric) as "orderAmt",
|
||||
1 as "orderCount",
|
||||
som.status,
|
||||
som.company_code
|
||||
FROM sales_order_mng som
|
||||
JOIN sales_order_detail sod
|
||||
ON som.order_no = sod.order_no
|
||||
AND som.company_code = sod.company_code
|
||||
LEFT JOIN customer_mng cm
|
||||
ON som.partner_id = cm.customer_code
|
||||
AND som.company_code = cm.company_code
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (item_number, company_code)
|
||||
item_number, item_name, company_code
|
||||
FROM item_info
|
||||
ORDER BY item_number, company_code, created_date DESC
|
||||
) ii
|
||||
ON sod.part_code = ii.item_number
|
||||
AND sod.company_code = ii.company_code
|
||||
${whereClause}
|
||||
ORDER BY date DESC NULLS LAST
|
||||
`;
|
||||
|
||||
// query()는 rows 배열을 직접 반환
|
||||
const dataRows = await query(dataQuery, params);
|
||||
|
||||
// 필터 옵션 조회 (거래처, 품목, 상태)
|
||||
const filterParams: any[] = [];
|
||||
let filterWhere = "";
|
||||
|
||||
if (companyCode !== "*") {
|
||||
filterWhere = `WHERE company_code = $1`;
|
||||
filterParams.push(companyCode);
|
||||
}
|
||||
|
||||
const statusWhere = filterWhere
|
||||
? `${filterWhere} AND status IS NOT NULL`
|
||||
: `WHERE status IS NOT NULL`;
|
||||
|
||||
const [customersRows, statusRows] = await Promise.all([
|
||||
query(
|
||||
`SELECT DISTINCT customer_code as value, customer_name as label
|
||||
FROM customer_mng ${filterWhere}
|
||||
ORDER BY customer_name`,
|
||||
filterParams
|
||||
),
|
||||
query(
|
||||
`SELECT DISTINCT status as value, status as label
|
||||
FROM sales_order_mng ${statusWhere}
|
||||
ORDER BY status`,
|
||||
filterParams
|
||||
),
|
||||
]);
|
||||
|
||||
// 품목은 데이터에서 추출 (실제 수주에 사용된 품목만)
|
||||
const itemSet = new Map<string, string>();
|
||||
dataRows.forEach((row: any) => {
|
||||
if (row.part_code && !itemSet.has(row.part_code)) {
|
||||
itemSet.set(row.part_code, row.item);
|
||||
}
|
||||
});
|
||||
const items = Array.from(itemSet.entries()).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}));
|
||||
|
||||
logger.info("영업 리포트 데이터 조회", {
|
||||
companyCode,
|
||||
rowCount: dataRows.length,
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
rows: dataRows,
|
||||
filterOptions: {
|
||||
customers: customersRows,
|
||||
items,
|
||||
statuses: statusRows,
|
||||
},
|
||||
totalCount: dataRows.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("영업 리포트 데이터 조회 실패", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "영업 리포트 데이터 조회에 실패했습니다",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,482 @@
|
|||
/**
|
||||
* 출하지시 컨트롤러 (shipment_instruction + shipment_instruction_detail)
|
||||
*/
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import { numberingRuleService } from "../services/numberingRuleService";
|
||||
|
||||
// ─── 출하지시 목록 조회 ───
|
||||
export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { dateFrom, dateTo, status, customer, keyword } = req.query;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (companyCode !== "*") {
|
||||
conditions.push(`si.company_code = $${idx}`);
|
||||
params.push(companyCode);
|
||||
idx++;
|
||||
}
|
||||
if (dateFrom) {
|
||||
conditions.push(`si.instruction_date >= $${idx}::date`);
|
||||
params.push(dateFrom);
|
||||
idx++;
|
||||
}
|
||||
if (dateTo) {
|
||||
conditions.push(`si.instruction_date <= $${idx}::date`);
|
||||
params.push(dateTo);
|
||||
idx++;
|
||||
}
|
||||
if (status) {
|
||||
conditions.push(`si.status = $${idx}`);
|
||||
params.push(status);
|
||||
idx++;
|
||||
}
|
||||
if (customer) {
|
||||
conditions.push(`(c.customer_name ILIKE $${idx} OR si.partner_id ILIKE $${idx})`);
|
||||
params.push(`%${customer}%`);
|
||||
idx++;
|
||||
}
|
||||
if (keyword) {
|
||||
conditions.push(`(si.instruction_no ILIKE $${idx} OR si.memo ILIKE $${idx})`);
|
||||
params.push(`%${keyword}%`);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
si.*,
|
||||
COALESCE(c.customer_name, si.partner_id, '') AS customer_name,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', sid.id,
|
||||
'item_code', sid.item_code,
|
||||
'item_name', COALESCE(i.item_name, sid.item_name, sid.item_code),
|
||||
'spec', sid.spec,
|
||||
'material', sid.material,
|
||||
'order_qty', sid.order_qty,
|
||||
'plan_qty', sid.plan_qty,
|
||||
'ship_qty', sid.ship_qty,
|
||||
'source_type', sid.source_type,
|
||||
'shipment_plan_id', sid.shipment_plan_id,
|
||||
'sales_order_id', sid.sales_order_id,
|
||||
'detail_id', sid.detail_id
|
||||
)
|
||||
) FILTER (WHERE sid.id IS NOT NULL),
|
||||
'[]'
|
||||
) AS items
|
||||
FROM shipment_instruction si
|
||||
LEFT JOIN customer_mng c
|
||||
ON si.partner_id = c.customer_code AND si.company_code = c.company_code
|
||||
LEFT JOIN shipment_instruction_detail sid
|
||||
ON si.id = sid.instruction_id AND si.company_code = sid.company_code
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT item_name FROM item_info
|
||||
WHERE item_number = sid.item_code AND company_code = si.company_code
|
||||
LIMIT 1
|
||||
) i ON true
|
||||
${where}
|
||||
GROUP BY si.id, c.customer_name
|
||||
ORDER BY si.created_date DESC
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
logger.info("출하지시 목록 조회", { companyCode, count: result.rowCount });
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("출하지시 목록 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 다음 출하지시번호 미리보기 ───
|
||||
export async function previewNextNo(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
let instructionNo: string;
|
||||
|
||||
try {
|
||||
const rule = await numberingRuleService.getNumberingRuleByColumn(
|
||||
companyCode, "shipment_instruction", "instruction_no"
|
||||
);
|
||||
if (rule) {
|
||||
instructionNo = await numberingRuleService.previewCode(
|
||||
rule.ruleId, companyCode, {}
|
||||
);
|
||||
} else {
|
||||
throw new Error("채번 규칙 없음");
|
||||
}
|
||||
} catch {
|
||||
const pool = getPool();
|
||||
const today = new Date().toISOString().split("T")[0].replace(/-/g, "");
|
||||
const seqRes = await pool.query(
|
||||
`SELECT COUNT(*) + 1 AS seq FROM shipment_instruction WHERE company_code = $1 AND instruction_no LIKE $2`,
|
||||
[companyCode, `SI-${today}-%`]
|
||||
);
|
||||
const seq = String(seqRes.rows[0].seq).padStart(3, "0");
|
||||
instructionNo = `SI-${today}-${seq}`;
|
||||
}
|
||||
|
||||
return res.json({ success: true, instructionNo });
|
||||
} catch (error: any) {
|
||||
logger.error("출하지시번호 미리보기 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 출하지시 저장 (신규/수정) ───
|
||||
export async function save(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const {
|
||||
id: editId,
|
||||
instructionDate,
|
||||
partnerId,
|
||||
status: orderStatus,
|
||||
memo,
|
||||
carrierName,
|
||||
vehicleNo,
|
||||
driverName,
|
||||
driverContact,
|
||||
arrivalTime,
|
||||
deliveryAddress,
|
||||
items,
|
||||
} = req.body;
|
||||
|
||||
if (!instructionDate) {
|
||||
return res.status(400).json({ success: false, message: "출하지시일은 필수입니다" });
|
||||
}
|
||||
if (!items || items.length === 0) {
|
||||
return res.status(400).json({ success: false, message: "품목을 선택해주세요" });
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
let instructionId: number;
|
||||
let instructionNo: string;
|
||||
|
||||
if (editId) {
|
||||
// 수정
|
||||
const check = await client.query(
|
||||
`SELECT id, instruction_no FROM shipment_instruction WHERE id = $1 AND company_code = $2`,
|
||||
[editId, companyCode]
|
||||
);
|
||||
if (check.rowCount === 0) {
|
||||
throw new Error("출하지시를 찾을 수 없습니다");
|
||||
}
|
||||
instructionId = editId;
|
||||
instructionNo = check.rows[0].instruction_no;
|
||||
|
||||
await client.query(
|
||||
`UPDATE shipment_instruction SET
|
||||
instruction_date = $1::date, partner_id = $2, status = $3, memo = $4,
|
||||
carrier_name = $5, vehicle_no = $6, driver_name = $7, driver_contact = $8,
|
||||
arrival_time = $9, delivery_address = $10,
|
||||
updated_date = NOW(), updated_by = $11
|
||||
WHERE id = $12 AND company_code = $13`,
|
||||
[
|
||||
instructionDate, partnerId, orderStatus || "READY", memo,
|
||||
carrierName, vehicleNo, driverName, driverContact,
|
||||
arrivalTime || null, deliveryAddress,
|
||||
userId, editId, companyCode,
|
||||
]
|
||||
);
|
||||
|
||||
// 기존 디테일 삭제 후 재삽입
|
||||
await client.query(
|
||||
`DELETE FROM shipment_instruction_detail WHERE instruction_id = $1 AND company_code = $2`,
|
||||
[editId, companyCode]
|
||||
);
|
||||
} else {
|
||||
// 신규 - 채번 규칙이 있으면 사용, 없으면 자체 생성
|
||||
try {
|
||||
const rule = await numberingRuleService.getNumberingRuleByColumn(
|
||||
companyCode, "shipment_instruction", "instruction_no"
|
||||
);
|
||||
if (rule) {
|
||||
instructionNo = await numberingRuleService.allocateCode(
|
||||
rule.ruleId, companyCode, { instruction_date: instructionDate }
|
||||
);
|
||||
logger.info("채번 규칙으로 출하지시번호 생성", { ruleId: rule.ruleId, instructionNo });
|
||||
} else {
|
||||
throw new Error("채번 규칙 없음 - 폴백");
|
||||
}
|
||||
} catch {
|
||||
const today = new Date().toISOString().split("T")[0].replace(/-/g, "");
|
||||
const seqRes = await client.query(
|
||||
`SELECT COUNT(*) + 1 AS seq FROM shipment_instruction WHERE company_code = $1 AND instruction_no LIKE $2`,
|
||||
[companyCode, `SI-${today}-%`]
|
||||
);
|
||||
const seq = String(seqRes.rows[0].seq).padStart(3, "0");
|
||||
instructionNo = `SI-${today}-${seq}`;
|
||||
logger.info("폴백으로 출하지시번호 생성", { instructionNo });
|
||||
}
|
||||
|
||||
const insertRes = await client.query(
|
||||
`INSERT INTO shipment_instruction
|
||||
(company_code, instruction_no, instruction_date, partner_id, status, memo,
|
||||
carrier_name, vehicle_no, driver_name, driver_contact, arrival_time, delivery_address,
|
||||
created_date, created_by)
|
||||
VALUES ($1, $2, $3::date, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW(), $13)
|
||||
RETURNING id`,
|
||||
[
|
||||
companyCode, instructionNo, instructionDate, partnerId,
|
||||
orderStatus || "READY", memo,
|
||||
carrierName, vehicleNo, driverName, driverContact,
|
||||
arrivalTime || null, deliveryAddress, userId,
|
||||
]
|
||||
);
|
||||
instructionId = insertRes.rows[0].id;
|
||||
}
|
||||
|
||||
// 디테일 삽입
|
||||
for (const item of items) {
|
||||
await client.query(
|
||||
`INSERT INTO shipment_instruction_detail
|
||||
(company_code, instruction_id, shipment_plan_id, sales_order_id, detail_id,
|
||||
item_code, item_name, spec, material, order_qty, plan_qty, ship_qty,
|
||||
source_type, created_date, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), $14)`,
|
||||
[
|
||||
companyCode, instructionId,
|
||||
item.shipmentPlanId || null, item.salesOrderId || null, item.detailId || null,
|
||||
item.itemCode, item.itemName, item.spec, item.material,
|
||||
item.orderQty || 0, item.planQty || 0, item.shipQty || 0,
|
||||
item.sourceType || "shipmentPlan", userId,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("출하지시 저장 완료", { companyCode, instructionId, instructionNo, itemCount: items.length });
|
||||
return res.json({ success: true, data: { id: instructionId, instructionNo } });
|
||||
} catch (txErr) {
|
||||
await client.query("ROLLBACK");
|
||||
throw txErr;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error("출하지시 저장 실패", { error: error.message, stack: error.stack });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 출하지시 삭제 ───
|
||||
export async function remove(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ids } = req.body;
|
||||
|
||||
if (!ids || !Array.isArray(ids) || ids.length === 0) {
|
||||
return res.status(400).json({ success: false, message: "삭제할 ID가 필요합니다" });
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
// CASCADE로 디테일도 자동 삭제
|
||||
const result = await pool.query(
|
||||
`DELETE FROM shipment_instruction WHERE id = ANY($1::int[]) AND company_code = $2 RETURNING id`,
|
||||
[ids, companyCode]
|
||||
);
|
||||
|
||||
logger.info("출하지시 삭제", { companyCode, deletedCount: result.rowCount });
|
||||
return res.json({ success: true, deletedCount: result.rowCount });
|
||||
} catch (error: any) {
|
||||
logger.error("출하지시 삭제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 출하계획 목록 (모달 왼쪽 패널용) ───
|
||||
export async function getShipmentPlanSource(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword, customer, page: pageStr, pageSize: pageSizeStr } = req.query;
|
||||
const page = Math.max(1, parseInt(pageStr as string) || 1);
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(pageSizeStr as string) || 20));
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const conditions = ["sp.company_code = $1", "sp.status = 'READY'"];
|
||||
const params: any[] = [companyCode];
|
||||
let idx = 2;
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(`(COALESCE(d.part_code, m.part_code, '') ILIKE $${idx} OR COALESCE(i.item_name, d.part_name, m.part_name, '') ILIKE $${idx})`);
|
||||
params.push(`%${keyword}%`);
|
||||
idx++;
|
||||
}
|
||||
if (customer) {
|
||||
conditions.push(`(c.customer_name ILIKE $${idx} OR COALESCE(m.partner_id, d.delivery_partner_code, '') ILIKE $${idx})`);
|
||||
params.push(`%${customer}%`);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(" AND ");
|
||||
const fromClause = `
|
||||
FROM shipment_plan sp
|
||||
LEFT JOIN sales_order_detail d ON sp.detail_id = d.id AND sp.company_code = d.company_code
|
||||
LEFT JOIN sales_order_mng m ON sp.sales_order_id = m.id AND sp.company_code = m.company_code
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT item_name FROM item_info
|
||||
WHERE item_number = COALESCE(d.part_code, m.part_code) AND company_code = sp.company_code
|
||||
LIMIT 1
|
||||
) i ON true
|
||||
LEFT JOIN customer_mng c
|
||||
ON COALESCE(m.partner_id, d.delivery_partner_code) = c.customer_code AND sp.company_code = c.company_code
|
||||
WHERE ${whereClause}
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const countResult = await pool.query(`SELECT COUNT(*) AS total ${fromClause}`, params);
|
||||
const totalCount = parseInt(countResult.rows[0].total);
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
sp.id, sp.plan_qty, sp.plan_date, sp.status, sp.shipment_plan_no,
|
||||
COALESCE(m.order_no, d.order_no, '') AS order_no,
|
||||
COALESCE(d.part_code, m.part_code, '') AS item_code,
|
||||
COALESCE(i.item_name, d.part_name, m.part_name, COALESCE(d.part_code, m.part_code, '')) AS item_name,
|
||||
COALESCE(d.spec, m.spec, '') AS spec,
|
||||
COALESCE(m.material, '') AS material,
|
||||
COALESCE(c.customer_name, m.partner_id, d.delivery_partner_code, '') AS customer_name,
|
||||
COALESCE(m.partner_id, d.delivery_partner_code, '') AS partner_code,
|
||||
sp.detail_id, sp.sales_order_id
|
||||
${fromClause}
|
||||
ORDER BY sp.created_date DESC
|
||||
LIMIT $${idx} OFFSET $${idx + 1}
|
||||
`;
|
||||
params.push(pageSize, offset);
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
return res.json({ success: true, data: result.rows, totalCount, page, pageSize });
|
||||
} catch (error: any) {
|
||||
logger.error("출하계획 소스 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 수주 목록 (모달 왼쪽 패널용) ───
|
||||
export async function getSalesOrderSource(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword, customer, page: pageStr, pageSize: pageSizeStr } = req.query;
|
||||
const page = Math.max(1, parseInt(pageStr as string) || 1);
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(pageSizeStr as string) || 20));
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const conditions = ["d.company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let idx = 2;
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(`(d.part_code ILIKE $${idx} OR COALESCE(i.item_name, d.part_name, d.part_code) ILIKE $${idx} OR d.order_no ILIKE $${idx})`);
|
||||
params.push(`%${keyword}%`);
|
||||
idx++;
|
||||
}
|
||||
if (customer) {
|
||||
conditions.push(`(c.customer_name ILIKE $${idx} OR COALESCE(d.delivery_partner_code, m.partner_id, '') ILIKE $${idx})`);
|
||||
params.push(`%${customer}%`);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(" AND ");
|
||||
const fromClause = `
|
||||
FROM sales_order_detail d
|
||||
LEFT JOIN sales_order_mng m ON d.order_no = m.order_no AND d.company_code = m.company_code
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT item_name FROM item_info
|
||||
WHERE item_number = d.part_code AND company_code = d.company_code
|
||||
LIMIT 1
|
||||
) i ON true
|
||||
LEFT JOIN customer_mng c
|
||||
ON COALESCE(d.delivery_partner_code, m.partner_id) = c.customer_code AND d.company_code = c.company_code
|
||||
WHERE ${whereClause}
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const countResult = await pool.query(`SELECT COUNT(*) AS total ${fromClause}`, params);
|
||||
const totalCount = parseInt(countResult.rows[0].total);
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
d.id, d.order_no, d.part_code AS item_code,
|
||||
COALESCE(i.item_name, d.part_name, d.part_code) AS item_name,
|
||||
COALESCE(d.spec, '') AS spec, COALESCE(m.material, '') AS material,
|
||||
COALESCE(NULLIF(d.qty,'')::numeric, 0) AS qty,
|
||||
COALESCE(NULLIF(d.balance_qty,'')::numeric, 0) AS balance_qty,
|
||||
COALESCE(c.customer_name, COALESCE(d.delivery_partner_code, m.partner_id, '')) AS customer_name,
|
||||
COALESCE(d.delivery_partner_code, m.partner_id, '') AS partner_code,
|
||||
m.id AS master_id
|
||||
${fromClause}
|
||||
ORDER BY d.created_date DESC
|
||||
LIMIT $${idx} OFFSET $${idx + 1}
|
||||
`;
|
||||
params.push(pageSize, offset);
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
return res.json({ success: true, data: result.rows, totalCount, page, pageSize });
|
||||
} catch (error: any) {
|
||||
logger.error("수주 소스 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 품목 목록 (모달 왼쪽 패널용) ───
|
||||
export async function getItemSource(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword, page: pageStr, pageSize: pageSizeStr } = req.query;
|
||||
const page = Math.max(1, parseInt(pageStr as string) || 1);
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(pageSizeStr as string) || 20));
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const conditions = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let idx = 2;
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(`(item_number ILIKE $${idx} OR item_name ILIKE $${idx})`);
|
||||
params.push(`%${keyword}%`);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(" AND ");
|
||||
|
||||
const pool = getPool();
|
||||
const countResult = await pool.query(`SELECT COUNT(*) AS total FROM item_info WHERE ${whereClause}`, params);
|
||||
const totalCount = parseInt(countResult.rows[0].total);
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
item_number AS item_code, item_name,
|
||||
COALESCE(size, '') AS spec, COALESCE(material, '') AS material
|
||||
FROM item_info
|
||||
WHERE ${whereClause}
|
||||
ORDER BY item_name
|
||||
LIMIT $${idx} OFFSET $${idx + 1}
|
||||
`;
|
||||
params.push(pageSize, offset);
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
return res.json({ success: true, data: result.rows, totalCount, page, pageSize });
|
||||
} catch (error: any) {
|
||||
logger.error("품목 소스 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
|
@ -144,6 +144,218 @@ async function getNormalizedOrders(
|
|||
}
|
||||
}
|
||||
|
||||
// ─── 출하계획 목록 조회 (관리 화면용) ───
|
||||
|
||||
export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { dateFrom, dateTo, status, customer, keyword } = req.query;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 멀티테넌시
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 전체 조회
|
||||
} else {
|
||||
conditions.push(`sp.company_code = $${paramIndex}`);
|
||||
params.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (dateFrom) {
|
||||
conditions.push(`sp.plan_date >= $${paramIndex}::date`);
|
||||
params.push(dateFrom);
|
||||
paramIndex++;
|
||||
}
|
||||
if (dateTo) {
|
||||
conditions.push(`sp.plan_date <= $${paramIndex}::date`);
|
||||
params.push(dateTo);
|
||||
paramIndex++;
|
||||
}
|
||||
if (status) {
|
||||
conditions.push(`sp.status = $${paramIndex}`);
|
||||
params.push(status);
|
||||
paramIndex++;
|
||||
}
|
||||
if (customer) {
|
||||
conditions.push(`(c.customer_name ILIKE $${paramIndex} OR COALESCE(m.partner_id, d.delivery_partner_code, '') ILIKE $${paramIndex})`);
|
||||
params.push(`%${customer}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
if (keyword) {
|
||||
conditions.push(`(
|
||||
COALESCE(m.order_no, d.order_no, '') ILIKE $${paramIndex}
|
||||
OR COALESCE(d.part_code, m.part_code, '') ILIKE $${paramIndex}
|
||||
OR COALESCE(i.item_name, d.part_name, m.part_name, '') ILIKE $${paramIndex}
|
||||
OR sp.shipment_plan_no ILIKE $${paramIndex}
|
||||
)`);
|
||||
params.push(`%${keyword}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
sp.id,
|
||||
sp.plan_date,
|
||||
sp.plan_qty,
|
||||
sp.status,
|
||||
sp.memo,
|
||||
sp.shipment_plan_no,
|
||||
sp.created_date,
|
||||
sp.created_by,
|
||||
sp.detail_id,
|
||||
sp.sales_order_id,
|
||||
sp.remain_qty,
|
||||
COALESCE(m.order_no, d.order_no, '') AS order_no,
|
||||
COALESCE(d.part_code, m.part_code, '') AS part_code,
|
||||
COALESCE(i.item_name, d.part_name, m.part_name, COALESCE(d.part_code, m.part_code, '')) AS part_name,
|
||||
COALESCE(d.spec, m.spec, '') AS spec,
|
||||
COALESCE(m.material, '') AS material,
|
||||
COALESCE(c.customer_name, m.partner_id, d.delivery_partner_code, '') AS customer_name,
|
||||
COALESCE(m.partner_id, d.delivery_partner_code, '') AS partner_code,
|
||||
COALESCE(d.due_date, m.due_date::text, '') AS due_date,
|
||||
COALESCE(NULLIF(d.qty,'')::numeric, m.order_qty, 0) AS order_qty,
|
||||
COALESCE(NULLIF(d.ship_qty,'')::numeric, m.ship_qty, 0) AS shipped_qty
|
||||
FROM shipment_plan sp
|
||||
LEFT JOIN sales_order_detail d
|
||||
ON sp.detail_id = d.id AND sp.company_code = d.company_code
|
||||
LEFT JOIN sales_order_mng m
|
||||
ON sp.sales_order_id = m.id AND sp.company_code = m.company_code
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT item_name FROM item_info
|
||||
WHERE item_number = COALESCE(d.part_code, m.part_code)
|
||||
AND company_code = sp.company_code
|
||||
LIMIT 1
|
||||
) i ON true
|
||||
LEFT JOIN customer_mng c
|
||||
ON COALESCE(m.partner_id, d.delivery_partner_code) = c.customer_code
|
||||
AND sp.company_code = c.company_code
|
||||
${whereClause}
|
||||
ORDER BY sp.created_date DESC
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
logger.info("출하계획 목록 조회", {
|
||||
companyCode,
|
||||
rowCount: result.rowCount,
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("출하계획 목록 조회 실패", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 출하계획 단건 수정 ───
|
||||
|
||||
export async function updatePlan(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { id } = req.params;
|
||||
const { planQty, planDate, memo } = req.body;
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
const check = await pool.query(
|
||||
`SELECT id, status FROM shipment_plan WHERE id = $1 AND company_code = $2`,
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (check.rowCount === 0) {
|
||||
return res.status(404).json({ success: false, message: "출하계획을 찾을 수 없습니다" });
|
||||
}
|
||||
|
||||
const setClauses: string[] = [];
|
||||
const updateParams: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (planQty !== undefined) {
|
||||
setClauses.push(`plan_qty = $${idx}`);
|
||||
updateParams.push(planQty);
|
||||
idx++;
|
||||
}
|
||||
if (planDate !== undefined) {
|
||||
setClauses.push(`plan_date = $${idx}::date`);
|
||||
updateParams.push(planDate);
|
||||
idx++;
|
||||
}
|
||||
if (memo !== undefined) {
|
||||
setClauses.push(`memo = $${idx}`);
|
||||
updateParams.push(memo);
|
||||
idx++;
|
||||
}
|
||||
|
||||
setClauses.push(`updated_date = NOW()`);
|
||||
setClauses.push(`updated_by = $${idx}`);
|
||||
updateParams.push(userId);
|
||||
idx++;
|
||||
|
||||
updateParams.push(id);
|
||||
updateParams.push(companyCode);
|
||||
|
||||
const updateQuery = `
|
||||
UPDATE shipment_plan
|
||||
SET ${setClauses.join(", ")}
|
||||
WHERE id = $${idx - 1} AND company_code = $${idx}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
// 파라미터 인덱스 수정
|
||||
const finalParams: any[] = [];
|
||||
let pIdx = 1;
|
||||
const setClausesFinal: string[] = [];
|
||||
|
||||
if (planQty !== undefined) {
|
||||
setClausesFinal.push(`plan_qty = $${pIdx}`);
|
||||
finalParams.push(planQty);
|
||||
pIdx++;
|
||||
}
|
||||
if (planDate !== undefined) {
|
||||
setClausesFinal.push(`plan_date = $${pIdx}::date`);
|
||||
finalParams.push(planDate);
|
||||
pIdx++;
|
||||
}
|
||||
if (memo !== undefined) {
|
||||
setClausesFinal.push(`memo = $${pIdx}`);
|
||||
finalParams.push(memo);
|
||||
pIdx++;
|
||||
}
|
||||
setClausesFinal.push(`updated_date = NOW()`);
|
||||
setClausesFinal.push(`updated_by = $${pIdx}`);
|
||||
finalParams.push(userId);
|
||||
pIdx++;
|
||||
|
||||
finalParams.push(id);
|
||||
finalParams.push(companyCode);
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE shipment_plan
|
||||
SET ${setClausesFinal.join(", ")}
|
||||
WHERE id = $${pIdx} AND company_code = $${pIdx + 1}
|
||||
RETURNING *`,
|
||||
finalParams
|
||||
);
|
||||
|
||||
logger.info("출하계획 수정", { companyCode, planId: id, userId });
|
||||
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("출하계획 수정 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 품목별 집계 + 기존 출하계획 조회 ───
|
||||
|
||||
export async function getAggregate(req: AuthenticatedRequest, res: Response) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,650 @@
|
|||
/**
|
||||
* 작업지시 컨트롤러 (work_instruction + work_instruction_detail)
|
||||
*/
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import { numberingRuleService } from "../services/numberingRuleService";
|
||||
|
||||
// ─── 작업지시 목록 조회 (detail 기준 행 반환) ───
|
||||
export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { dateFrom, dateTo, status, progressStatus, keyword } = req.query;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (companyCode !== "*") {
|
||||
conditions.push(`wi.company_code = $${idx}`);
|
||||
params.push(companyCode);
|
||||
idx++;
|
||||
}
|
||||
if (dateFrom) {
|
||||
conditions.push(`wi.start_date >= $${idx}`);
|
||||
params.push(dateFrom);
|
||||
idx++;
|
||||
}
|
||||
if (dateTo) {
|
||||
conditions.push(`wi.end_date <= $${idx}`);
|
||||
params.push(dateTo);
|
||||
idx++;
|
||||
}
|
||||
if (status && status !== "all") {
|
||||
conditions.push(`wi.status = $${idx}`);
|
||||
params.push(status);
|
||||
idx++;
|
||||
}
|
||||
if (progressStatus && progressStatus !== "all") {
|
||||
conditions.push(`wi.progress_status = $${idx}`);
|
||||
params.push(progressStatus);
|
||||
idx++;
|
||||
}
|
||||
if (keyword) {
|
||||
conditions.push(`(wi.work_instruction_no ILIKE $${idx} OR wi.worker ILIKE $${idx} OR COALESCE(itm.item_name,'') ILIKE $${idx} OR COALESCE(d.item_number,'') ILIKE $${idx})`);
|
||||
params.push(`%${keyword}%`);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
wi.id AS wi_id,
|
||||
wi.work_instruction_no,
|
||||
wi.status,
|
||||
wi.progress_status,
|
||||
wi.qty AS total_qty,
|
||||
wi.completed_qty,
|
||||
wi.start_date,
|
||||
wi.end_date,
|
||||
wi.equipment_id,
|
||||
wi.work_team,
|
||||
wi.worker,
|
||||
wi.remark AS wi_remark,
|
||||
wi.created_date,
|
||||
d.id AS detail_id,
|
||||
d.item_number,
|
||||
d.qty AS detail_qty,
|
||||
d.remark AS detail_remark,
|
||||
d.part_code,
|
||||
d.source_table,
|
||||
d.source_id,
|
||||
COALESCE(itm.item_name, '') AS item_name,
|
||||
COALESCE(itm.size, '') AS item_spec,
|
||||
COALESCE(e.equipment_name, '') AS equipment_name,
|
||||
COALESCE(e.equipment_code, '') AS equipment_code,
|
||||
wi.routing AS routing_version_id,
|
||||
COALESCE(rv.version_name, '') AS routing_name,
|
||||
ROW_NUMBER() OVER (PARTITION BY wi.work_instruction_no ORDER BY d.created_date) AS detail_seq,
|
||||
COUNT(*) OVER (PARTITION BY wi.work_instruction_no) AS detail_count
|
||||
FROM work_instruction wi
|
||||
INNER JOIN work_instruction_detail d
|
||||
ON d.work_instruction_no = wi.work_instruction_no AND d.company_code = wi.company_code
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT item_name, size FROM item_info
|
||||
WHERE item_number = d.item_number AND company_code = wi.company_code LIMIT 1
|
||||
) itm ON true
|
||||
LEFT JOIN equipment_mng e ON wi.equipment_id = e.id AND wi.company_code = e.company_code
|
||||
LEFT JOIN item_routing_version rv ON wi.routing = rv.id AND rv.company_code = wi.company_code
|
||||
${whereClause}
|
||||
ORDER BY wi.created_date DESC, d.created_date ASC
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(query, params);
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("작업지시 목록 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 다음 작업지시번호 미리보기 ───
|
||||
export async function previewNextNo(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
let wiNo: string;
|
||||
try {
|
||||
const rule = await numberingRuleService.getNumberingRuleByColumn(companyCode, "work_instruction", "work_instruction_no");
|
||||
if (rule) {
|
||||
wiNo = await numberingRuleService.previewCode(rule.ruleId, companyCode, {});
|
||||
} else { throw new Error("채번 규칙 없음"); }
|
||||
} catch {
|
||||
const pool = getPool();
|
||||
const today = new Date().toISOString().split("T")[0].replace(/-/g, "");
|
||||
const seqRes = await pool.query(
|
||||
`SELECT COUNT(*) + 1 AS seq FROM work_instruction WHERE company_code = $1 AND work_instruction_no LIKE $2`,
|
||||
[companyCode, `WI-${today}-%`]
|
||||
);
|
||||
wiNo = `WI-${today}-${String(seqRes.rows[0].seq).padStart(3, "0")}`;
|
||||
}
|
||||
return res.json({ success: true, instructionNo: wiNo });
|
||||
} catch (error: any) {
|
||||
logger.error("작업지시번호 미리보기 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 작업지시 저장 (신규/수정) ───
|
||||
export async function save(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { id: editId, status: wiStatus, progressStatus, reason, startDate, endDate, equipmentId, workTeam, worker, remark, items, routing: routingVersionId } = req.body;
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
return res.status(400).json({ success: false, message: "품목을 선택해주세요" });
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
let wiId: string;
|
||||
let wiNo: string;
|
||||
|
||||
if (editId) {
|
||||
const check = await client.query(`SELECT id, work_instruction_no FROM work_instruction WHERE id = $1 AND company_code = $2`, [editId, companyCode]);
|
||||
if (check.rowCount === 0) throw new Error("작업지시를 찾을 수 없습니다");
|
||||
wiId = editId;
|
||||
wiNo = check.rows[0].work_instruction_no;
|
||||
await client.query(
|
||||
`UPDATE work_instruction SET status=$1, progress_status=$2, reason=$3, start_date=$4, end_date=$5, equipment_id=$6, work_team=$7, worker=$8, remark=$9, routing=$10, updated_date=NOW(), writer=$11 WHERE id=$12 AND company_code=$13`,
|
||||
[wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", routingVersionId||null, userId, editId, companyCode]
|
||||
);
|
||||
await client.query(`DELETE FROM work_instruction_detail WHERE work_instruction_no=$1 AND company_code=$2`, [wiNo, companyCode]);
|
||||
} else {
|
||||
try {
|
||||
const rule = await numberingRuleService.getNumberingRuleByColumn(companyCode, "work_instruction", "work_instruction_no");
|
||||
if (rule) { wiNo = await numberingRuleService.allocateCode(rule.ruleId, companyCode, {}); }
|
||||
else { throw new Error("채번 규칙 없음 - 폴백"); }
|
||||
} catch {
|
||||
const today = new Date().toISOString().split("T")[0].replace(/-/g, "");
|
||||
const seqRes = await client.query(`SELECT COUNT(*)+1 AS seq FROM work_instruction WHERE company_code=$1 AND work_instruction_no LIKE $2`, [companyCode, `WI-${today}-%`]);
|
||||
wiNo = `WI-${today}-${String(seqRes.rows[0].seq).padStart(3, "0")}`;
|
||||
}
|
||||
const insertRes = await client.query(
|
||||
`INSERT INTO work_instruction (id,company_code,work_instruction_no,status,progress_status,reason,start_date,end_date,equipment_id,work_team,worker,remark,routing,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,NOW(),$13) RETURNING id`,
|
||||
[companyCode, wiNo, wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", routingVersionId||null, userId]
|
||||
);
|
||||
wiId = insertRes.rows[0].id;
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
await client.query(
|
||||
`INSERT INTO work_instruction_detail (id,company_code,work_instruction_no,item_number,qty,remark,source_table,source_id,part_code,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,NOW(),$9)`,
|
||||
[companyCode, wiNo, item.itemNumber||item.itemCode||"", item.qty||"0", item.remark||"", item.sourceTable||"", item.sourceId||"", item.partCode||item.itemNumber||item.itemCode||"", userId]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
return res.json({ success: true, data: { id: wiId, workInstructionNo: wiNo } });
|
||||
} catch (txErr) { await client.query("ROLLBACK"); throw txErr; }
|
||||
finally { client.release(); }
|
||||
} catch (error: any) {
|
||||
logger.error("작업지시 저장 실패", { error: error.message, stack: error.stack });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 작업지시 삭제 ───
|
||||
export async function remove(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ids } = req.body;
|
||||
if (!ids || ids.length === 0) return res.status(400).json({ success: false, message: "삭제할 항목을 선택해주세요" });
|
||||
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const wiNos = await client.query(`SELECT work_instruction_no FROM work_instruction WHERE id=ANY($1) AND company_code=$2`, [ids, companyCode]);
|
||||
for (const row of wiNos.rows) {
|
||||
await client.query(`DELETE FROM work_instruction_detail WHERE work_instruction_no=$1 AND company_code=$2`, [row.work_instruction_no, companyCode]);
|
||||
}
|
||||
const result = await client.query(`DELETE FROM work_instruction WHERE id=ANY($1) AND company_code=$2`, [ids, companyCode]);
|
||||
await client.query("COMMIT");
|
||||
return res.json({ success: true, deletedCount: result.rowCount });
|
||||
} catch (txErr) { await client.query("ROLLBACK"); throw txErr; }
|
||||
finally { client.release(); }
|
||||
} catch (error: any) {
|
||||
logger.error("작업지시 삭제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 품목 소스 (페이징) ───
|
||||
export async function getItemSource(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword, page: ps, pageSize: pss } = req.query;
|
||||
const page = Math.max(1, parseInt(ps as string) || 1);
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(pss as string) || 20));
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const conds = ["company_code = $1"]; const params: any[] = [companyCode]; let idx = 2;
|
||||
if (keyword) { conds.push(`(item_number ILIKE $${idx} OR item_name ILIKE $${idx})`); params.push(`%${keyword}%`); idx++; }
|
||||
const w = conds.join(" AND ");
|
||||
const pool = getPool();
|
||||
const cnt = await pool.query(`SELECT COUNT(*) AS total FROM item_info WHERE ${w}`, params);
|
||||
params.push(pageSize, offset);
|
||||
const rows = await pool.query(`SELECT id, item_number AS item_code, item_name, COALESCE(size,'') AS spec FROM item_info WHERE ${w} ORDER BY item_name LIMIT $${idx} OFFSET $${idx+1}`, params);
|
||||
return res.json({ success: true, data: rows.rows, totalCount: parseInt(cnt.rows[0].total), page, pageSize });
|
||||
} catch (error: any) { return res.status(500).json({ success: false, message: error.message }); }
|
||||
}
|
||||
|
||||
// ─── 수주 소스 (페이징) ───
|
||||
export async function getSalesOrderSource(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword, page: ps, pageSize: pss } = req.query;
|
||||
const page = Math.max(1, parseInt(ps as string) || 1);
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(pss as string) || 20));
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const conds = ["d.company_code = $1"]; const params: any[] = [companyCode]; let idx = 2;
|
||||
if (keyword) { conds.push(`(d.part_code ILIKE $${idx} OR COALESCE(i.item_name, d.part_name, d.part_code) ILIKE $${idx} OR d.order_no ILIKE $${idx})`); params.push(`%${keyword}%`); idx++; }
|
||||
const fromClause = `FROM sales_order_detail d LEFT JOIN LATERAL (SELECT item_name FROM item_info WHERE item_number = d.part_code AND company_code = d.company_code LIMIT 1) i ON true WHERE ${conds.join(" AND ")}`;
|
||||
const pool = getPool();
|
||||
const cnt = await pool.query(`SELECT COUNT(*) AS total ${fromClause}`, params);
|
||||
params.push(pageSize, offset);
|
||||
const rows = await pool.query(`SELECT d.id, d.order_no, d.part_code AS item_code, COALESCE(i.item_name, d.part_name, d.part_code) AS item_name, COALESCE(d.spec,'') AS spec, COALESCE(NULLIF(d.qty,'')::numeric,0) AS qty, d.due_date ${fromClause} ORDER BY d.created_date DESC LIMIT $${idx} OFFSET $${idx+1}`, params);
|
||||
return res.json({ success: true, data: rows.rows, totalCount: parseInt(cnt.rows[0].total), page, pageSize });
|
||||
} catch (error: any) { return res.status(500).json({ success: false, message: error.message }); }
|
||||
}
|
||||
|
||||
// ─── 생산계획 소스 (페이징) ───
|
||||
export async function getProductionPlanSource(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword, page: ps, pageSize: pss } = req.query;
|
||||
const page = Math.max(1, parseInt(ps as string) || 1);
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(pss as string) || 20));
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const conds = ["p.company_code = $1"]; const params: any[] = [companyCode]; let idx = 2;
|
||||
if (keyword) { conds.push(`(p.plan_no ILIKE $${idx} OR p.item_code ILIKE $${idx} OR COALESCE(p.item_name,'') ILIKE $${idx})`); params.push(`%${keyword}%`); idx++; }
|
||||
const w = conds.join(" AND ");
|
||||
const pool = getPool();
|
||||
const cnt = await pool.query(`SELECT COUNT(*) AS total FROM production_plan_mng p WHERE ${w}`, params);
|
||||
params.push(pageSize, offset);
|
||||
const rows = await pool.query(`SELECT p.id, p.plan_no, p.item_code, COALESCE(p.item_name,'') AS item_name, COALESCE(p.plan_qty,0) AS plan_qty, p.start_date, p.end_date, p.status, COALESCE(p.equipment_name,'') AS equipment_name FROM production_plan_mng p WHERE ${w} ORDER BY p.created_date DESC LIMIT $${idx} OFFSET $${idx+1}`, params);
|
||||
return res.json({ success: true, data: rows.rows, totalCount: parseInt(cnt.rows[0].total), page, pageSize });
|
||||
} catch (error: any) { return res.status(500).json({ success: false, message: error.message }); }
|
||||
}
|
||||
|
||||
// ─── 사원 목록 (작업자 Select용) ───
|
||||
export async function getEmployeeList(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const pool = getPool();
|
||||
let query: string;
|
||||
let params: any[];
|
||||
if (companyCode !== "*") {
|
||||
query = `SELECT user_id, user_name, dept_name FROM user_info WHERE company_code = $1 AND company_code != '*' ORDER BY user_name`;
|
||||
params = [companyCode];
|
||||
} else {
|
||||
query = `SELECT user_id, user_name, dept_name, company_code FROM user_info WHERE company_code != '*' ORDER BY user_name`;
|
||||
params = [];
|
||||
}
|
||||
const result = await pool.query(query, params);
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("사원 목록 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 설비 목록 (Select용) ───
|
||||
export async function getEquipmentList(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const pool = getPool();
|
||||
const cond = companyCode !== "*" ? "WHERE company_code = $1" : "";
|
||||
const params = companyCode !== "*" ? [companyCode] : [];
|
||||
const result = await pool.query(`SELECT id, equipment_code, equipment_name FROM equipment_mng ${cond} ORDER BY equipment_name`, params);
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) { return res.status(500).json({ success: false, message: error.message }); }
|
||||
}
|
||||
|
||||
// ─── 품목의 라우팅 버전 + 공정 조회 ───
|
||||
export async function getRoutingVersions(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { itemCode } = req.params;
|
||||
const pool = getPool();
|
||||
|
||||
const versionsResult = await pool.query(
|
||||
`SELECT id, version_name, description, created_date, COALESCE(is_default, false) AS is_default
|
||||
FROM item_routing_version
|
||||
WHERE item_code = $1 AND company_code = $2
|
||||
ORDER BY is_default DESC, created_date DESC`,
|
||||
[itemCode, companyCode]
|
||||
);
|
||||
|
||||
const routings = [];
|
||||
for (const version of versionsResult.rows) {
|
||||
const detailsResult = await pool.query(
|
||||
`SELECT rd.id AS routing_detail_id, rd.seq_no, rd.process_code,
|
||||
rd.is_required, rd.work_type,
|
||||
COALESCE(p.process_name, rd.process_code) AS process_name
|
||||
FROM item_routing_detail rd
|
||||
LEFT JOIN process_mng p ON p.process_code = rd.process_code AND p.company_code = rd.company_code
|
||||
WHERE rd.routing_version_id = $1 AND rd.company_code = $2
|
||||
ORDER BY rd.seq_no::integer`,
|
||||
[version.id, companyCode]
|
||||
);
|
||||
routings.push({ ...version, processes: detailsResult.rows });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: routings });
|
||||
} catch (error: any) {
|
||||
logger.error("라우팅 버전 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 작업지시 라우팅 변경 ───
|
||||
export async function updateRouting(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { wiNo } = req.params;
|
||||
const { routingVersionId } = req.body;
|
||||
const pool = getPool();
|
||||
|
||||
await pool.query(
|
||||
`UPDATE work_instruction SET routing = $1, updated_date = NOW() WHERE work_instruction_no = $2 AND company_code = $3`,
|
||||
[routingVersionId || null, wiNo, companyCode]
|
||||
);
|
||||
|
||||
return res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
logger.error("라우팅 변경 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 작업지시 전용 공정작업기준 조회 ───
|
||||
export async function getWorkStandard(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { wiNo } = req.params;
|
||||
const { routingVersionId } = req.query;
|
||||
const pool = getPool();
|
||||
|
||||
if (!routingVersionId) {
|
||||
return res.status(400).json({ success: false, message: "routingVersionId 필요" });
|
||||
}
|
||||
|
||||
// 라우팅 디테일(공정) 목록 조회
|
||||
const processesResult = await pool.query(
|
||||
`SELECT rd.id AS routing_detail_id, rd.seq_no, rd.process_code,
|
||||
COALESCE(p.process_name, rd.process_code) AS process_name
|
||||
FROM item_routing_detail rd
|
||||
LEFT JOIN process_mng p ON p.process_code = rd.process_code AND p.company_code = rd.company_code
|
||||
WHERE rd.routing_version_id = $1 AND rd.company_code = $2
|
||||
ORDER BY rd.seq_no::integer`,
|
||||
[routingVersionId, companyCode]
|
||||
);
|
||||
|
||||
// 커스텀 작업기준이 있는지 확인
|
||||
const customCheck = await pool.query(
|
||||
`SELECT COUNT(*) AS cnt FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`,
|
||||
[wiNo, companyCode]
|
||||
);
|
||||
const hasCustom = parseInt(customCheck.rows[0].cnt) > 0;
|
||||
|
||||
const processes = [];
|
||||
for (const proc of processesResult.rows) {
|
||||
let workItems;
|
||||
|
||||
if (hasCustom) {
|
||||
// 커스텀 버전에서 조회
|
||||
const wiResult = await pool.query(
|
||||
`SELECT wi.id, wi.routing_detail_id, wi.work_phase, wi.title, wi.is_required, wi.sort_order, wi.description,
|
||||
(SELECT COUNT(*) FROM wi_process_work_item_detail d WHERE d.wi_work_item_id = wi.id AND d.company_code = wi.company_code)::integer AS detail_count
|
||||
FROM wi_process_work_item wi
|
||||
WHERE wi.work_instruction_no = $1 AND wi.routing_detail_id = $2 AND wi.company_code = $3
|
||||
ORDER BY wi.work_phase, wi.sort_order`,
|
||||
[wiNo, proc.routing_detail_id, companyCode]
|
||||
);
|
||||
workItems = wiResult.rows;
|
||||
|
||||
// 각 work_item의 상세도 로드
|
||||
for (const wi of workItems) {
|
||||
const detailsResult = await pool.query(
|
||||
`SELECT id, wi_work_item_id AS work_item_id, detail_type, content, is_required, sort_order, remark,
|
||||
inspection_code, inspection_method, unit, lower_limit, upper_limit,
|
||||
duration_minutes, input_type, lookup_target, display_fields
|
||||
FROM wi_process_work_item_detail
|
||||
WHERE wi_work_item_id = $1 AND company_code = $2
|
||||
ORDER BY sort_order`,
|
||||
[wi.id, companyCode]
|
||||
);
|
||||
wi.details = detailsResult.rows;
|
||||
}
|
||||
} else {
|
||||
// 원본에서 조회
|
||||
const origResult = await pool.query(
|
||||
`SELECT wi.id, wi.routing_detail_id, wi.work_phase, wi.title, wi.is_required, wi.sort_order, wi.description,
|
||||
(SELECT COUNT(*) FROM process_work_item_detail d WHERE d.work_item_id = wi.id AND d.company_code = wi.company_code)::integer AS detail_count
|
||||
FROM process_work_item wi
|
||||
WHERE wi.routing_detail_id = $1 AND wi.company_code = $2
|
||||
ORDER BY wi.work_phase, wi.sort_order`,
|
||||
[proc.routing_detail_id, companyCode]
|
||||
);
|
||||
workItems = origResult.rows;
|
||||
|
||||
for (const wi of workItems) {
|
||||
const detailsResult = await pool.query(
|
||||
`SELECT id, work_item_id, detail_type, content, is_required, sort_order, remark,
|
||||
inspection_code, inspection_method, unit, lower_limit, upper_limit,
|
||||
duration_minutes, input_type, lookup_target, display_fields
|
||||
FROM process_work_item_detail
|
||||
WHERE work_item_id = $1 AND company_code = $2
|
||||
ORDER BY sort_order`,
|
||||
[wi.id, companyCode]
|
||||
);
|
||||
wi.details = detailsResult.rows;
|
||||
}
|
||||
}
|
||||
|
||||
processes.push({
|
||||
...proc,
|
||||
workItems,
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: { processes, isCustom: hasCustom } });
|
||||
} catch (error: any) {
|
||||
logger.error("작업지시 공정작업기준 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 원본 공정작업기준 -> 작업지시 전용 복사 ───
|
||||
export async function copyWorkStandard(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { wiNo } = req.params;
|
||||
const { routingVersionId } = req.body;
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 기존 커스텀 데이터 삭제
|
||||
const existingItems = await client.query(
|
||||
`SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`,
|
||||
[wiNo, companyCode]
|
||||
);
|
||||
for (const row of existingItems.rows) {
|
||||
await client.query(
|
||||
`DELETE FROM wi_process_work_item_detail WHERE wi_work_item_id = $1 AND company_code = $2`,
|
||||
[row.id, companyCode]
|
||||
);
|
||||
}
|
||||
await client.query(
|
||||
`DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`,
|
||||
[wiNo, companyCode]
|
||||
);
|
||||
|
||||
// 라우팅 디테일 목록 조회
|
||||
const routingDetails = await client.query(
|
||||
`SELECT id FROM item_routing_detail WHERE routing_version_id = $1 AND company_code = $2`,
|
||||
[routingVersionId, companyCode]
|
||||
);
|
||||
|
||||
// 각 공정(routing_detail)별 원본 작업항목 복사
|
||||
for (const rd of routingDetails.rows) {
|
||||
const origItems = await client.query(
|
||||
`SELECT * FROM process_work_item WHERE routing_detail_id = $1 AND company_code = $2`,
|
||||
[rd.id, companyCode]
|
||||
);
|
||||
|
||||
for (const origItem of origItems.rows) {
|
||||
const newItemResult = await client.query(
|
||||
`INSERT INTO wi_process_work_item (company_code, work_instruction_no, routing_detail_id, work_phase, title, is_required, sort_order, description, source_work_item_id, writer)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`,
|
||||
[companyCode, wiNo, rd.id, origItem.work_phase, origItem.title, origItem.is_required, origItem.sort_order, origItem.description, origItem.id, userId]
|
||||
);
|
||||
const newItemId = newItemResult.rows[0].id;
|
||||
|
||||
// 상세 복사
|
||||
const origDetails = await client.query(
|
||||
`SELECT * FROM process_work_item_detail WHERE work_item_id = $1 AND company_code = $2`,
|
||||
[origItem.id, companyCode]
|
||||
);
|
||||
|
||||
for (const origDetail of origDetails.rows) {
|
||||
await client.query(
|
||||
`INSERT INTO wi_process_work_item_detail (company_code, wi_work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, writer)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
|
||||
[companyCode, newItemId, origDetail.detail_type, origDetail.content, origDetail.is_required, origDetail.sort_order, origDetail.remark, origDetail.inspection_code, origDetail.inspection_method, origDetail.unit, origDetail.lower_limit, origDetail.upper_limit, origDetail.duration_minutes, origDetail.input_type, origDetail.lookup_target, origDetail.display_fields, userId]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
logger.info("공정작업기준 복사 완료", { companyCode, wiNo, routingVersionId });
|
||||
return res.json({ success: true });
|
||||
} catch (txErr) {
|
||||
await client.query("ROLLBACK");
|
||||
throw txErr;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error("공정작업기준 복사 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 작업지시 전용 공정작업기준 저장 (일괄) ───
|
||||
export async function saveWorkStandard(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { wiNo } = req.params;
|
||||
const { routingDetailId, workItems } = req.body;
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 해당 공정의 기존 커스텀 데이터 삭제
|
||||
const existing = await client.query(
|
||||
`SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND routing_detail_id = $2 AND company_code = $3`,
|
||||
[wiNo, routingDetailId, companyCode]
|
||||
);
|
||||
for (const row of existing.rows) {
|
||||
await client.query(
|
||||
`DELETE FROM wi_process_work_item_detail WHERE wi_work_item_id = $1 AND company_code = $2`,
|
||||
[row.id, companyCode]
|
||||
);
|
||||
}
|
||||
await client.query(
|
||||
`DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND routing_detail_id = $2 AND company_code = $3`,
|
||||
[wiNo, routingDetailId, companyCode]
|
||||
);
|
||||
|
||||
// 새 데이터 삽입
|
||||
for (const wi of workItems) {
|
||||
const wiResult = await client.query(
|
||||
`INSERT INTO wi_process_work_item (company_code, work_instruction_no, routing_detail_id, work_phase, title, is_required, sort_order, description, source_work_item_id, writer)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`,
|
||||
[companyCode, wiNo, routingDetailId, wi.work_phase, wi.title, wi.is_required, wi.sort_order, wi.description || null, wi.source_work_item_id || null, userId]
|
||||
);
|
||||
const newId = wiResult.rows[0].id;
|
||||
|
||||
if (wi.details && Array.isArray(wi.details)) {
|
||||
for (const d of wi.details) {
|
||||
await client.query(
|
||||
`INSERT INTO wi_process_work_item_detail (company_code, wi_work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, writer)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
|
||||
[companyCode, newId, d.detail_type, d.content, d.is_required, d.sort_order, d.remark || null, d.inspection_code || null, d.inspection_method || null, d.unit || null, d.lower_limit || null, d.upper_limit || null, d.duration_minutes || null, d.input_type || null, d.lookup_target || null, d.display_fields || null, userId]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
logger.info("작업지시 공정작업기준 저장 완료", { companyCode, wiNo, routingDetailId });
|
||||
return res.json({ success: true });
|
||||
} catch (txErr) {
|
||||
await client.query("ROLLBACK");
|
||||
throw txErr;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error("작업지시 공정작업기준 저장 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 작업지시 전용 커스텀 데이터 삭제 (원본으로 초기화) ───
|
||||
export async function resetWorkStandard(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { wiNo } = req.params;
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const items = await client.query(
|
||||
`SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`,
|
||||
[wiNo, companyCode]
|
||||
);
|
||||
for (const row of items.rows) {
|
||||
await client.query(
|
||||
`DELETE FROM wi_process_work_item_detail WHERE wi_work_item_id = $1 AND company_code = $2`,
|
||||
[row.id, companyCode]
|
||||
);
|
||||
}
|
||||
await client.query(
|
||||
`DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`,
|
||||
[wiNo, companyCode]
|
||||
);
|
||||
await client.query("COMMIT");
|
||||
logger.info("작업지시 공정작업기준 초기화", { companyCode, wiNo });
|
||||
return res.json({ success: true });
|
||||
} catch (txErr) {
|
||||
await client.query("ROLLBACK");
|
||||
throw txErr;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error("작업지시 공정작업기준 초기화 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import {
|
||||
getProductionReportData,
|
||||
getInventoryReportData,
|
||||
getPurchaseReportData,
|
||||
getQualityReportData,
|
||||
getEquipmentReportData,
|
||||
getMoldReportData,
|
||||
} from "../controllers/analyticsReportController";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticateToken);
|
||||
|
||||
router.get("/production/data", getProductionReportData);
|
||||
router.get("/inventory/data", getInventoryReportData);
|
||||
router.get("/purchase/data", getPurchaseReportData);
|
||||
router.get("/quality/data", getQualityReportData);
|
||||
router.get("/equipment/data", getEquipmentReportData);
|
||||
router.get("/mold/data", getMoldReportData);
|
||||
|
||||
export default router;
|
||||
|
|
@ -7,6 +7,19 @@ import { authenticateToken } from "../middleware/authMiddleware";
|
|||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/batch-management/stats
|
||||
* 배치 대시보드 통계 (전체/활성 배치 수, 오늘·어제 실행/실패 수)
|
||||
* 반드시 /batch-configs 보다 위에 등록 (/:id로 잡히지 않도록)
|
||||
*/
|
||||
router.get("/stats", authenticateToken, BatchManagementController.getBatchStats);
|
||||
|
||||
/**
|
||||
* GET /api/batch-management/node-flows
|
||||
* 배치 설정에서 노드 플로우 선택용 목록 조회
|
||||
*/
|
||||
router.get("/node-flows", authenticateToken, BatchManagementController.getNodeFlows);
|
||||
|
||||
/**
|
||||
* GET /api/batch-management/connections
|
||||
* 사용 가능한 커넥션 목록 조회
|
||||
|
|
@ -55,6 +68,18 @@ router.get("/batch-configs", authenticateToken, BatchManagementController.getBat
|
|||
*/
|
||||
router.get("/batch-configs/:id", authenticateToken, BatchManagementController.getBatchConfigById);
|
||||
|
||||
/**
|
||||
* GET /api/batch-management/batch-configs/:id/sparkline
|
||||
* 해당 배치 최근 24시간 1시간 단위 실행 집계
|
||||
*/
|
||||
router.get("/batch-configs/:id/sparkline", authenticateToken, BatchManagementController.getBatchSparkline);
|
||||
|
||||
/**
|
||||
* GET /api/batch-management/batch-configs/:id/recent-logs
|
||||
* 해당 배치 최근 실행 로그 (최대 20건)
|
||||
*/
|
||||
router.get("/batch-configs/:id/recent-logs", authenticateToken, BatchManagementController.getBatchRecentLogs);
|
||||
|
||||
/**
|
||||
* PUT /api/batch-management/batch-configs/:id
|
||||
* 배치 설정 업데이트
|
||||
|
|
|
|||
|
|
@ -13,7 +13,54 @@ import { auditLogService, getClientIp } from "../../services/auditLogService";
|
|||
const router = Router();
|
||||
|
||||
/**
|
||||
* 플로우 목록 조회
|
||||
* flow_data에서 요약 정보 추출
|
||||
*/
|
||||
function extractFlowSummary(flowData: any) {
|
||||
try {
|
||||
const parsed = typeof flowData === "string" ? JSON.parse(flowData) : flowData;
|
||||
const nodes = parsed?.nodes || [];
|
||||
const edges = parsed?.edges || [];
|
||||
|
||||
const nodeTypes: Record<string, number> = {};
|
||||
nodes.forEach((n: any) => {
|
||||
const t = n.type || "unknown";
|
||||
nodeTypes[t] = (nodeTypes[t] || 0) + 1;
|
||||
});
|
||||
|
||||
// 미니 토폴로지용 간소화된 좌표 (0~1 정규화)
|
||||
let topology = null;
|
||||
if (nodes.length > 0) {
|
||||
const xs = nodes.map((n: any) => n.position?.x || 0);
|
||||
const ys = nodes.map((n: any) => n.position?.y || 0);
|
||||
const minX = Math.min(...xs), maxX = Math.max(...xs);
|
||||
const minY = Math.min(...ys), maxY = Math.max(...ys);
|
||||
const rangeX = maxX - minX || 1;
|
||||
const rangeY = maxY - minY || 1;
|
||||
|
||||
topology = {
|
||||
nodes: nodes.map((n: any) => ({
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
x: (((n.position?.x || 0) - minX) / rangeX),
|
||||
y: (((n.position?.y || 0) - minY) / rangeY),
|
||||
})),
|
||||
edges: edges.map((e: any) => [e.source, e.target]),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
nodeCount: nodes.length,
|
||||
edgeCount: edges.length,
|
||||
nodeTypes,
|
||||
topology,
|
||||
};
|
||||
} catch {
|
||||
return { nodeCount: 0, edgeCount: 0, nodeTypes: {}, topology: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 플로우 목록 조회 (summary 포함)
|
||||
*/
|
||||
router.get("/", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
|
|
@ -24,6 +71,7 @@ router.get("/", async (req: AuthenticatedRequest, res: Response) => {
|
|||
flow_id as "flowId",
|
||||
flow_name as "flowName",
|
||||
flow_description as "flowDescription",
|
||||
flow_data as "flowData",
|
||||
company_code as "companyCode",
|
||||
created_at as "createdAt",
|
||||
updated_at as "updatedAt"
|
||||
|
|
@ -32,7 +80,6 @@ router.get("/", async (req: AuthenticatedRequest, res: Response) => {
|
|||
|
||||
const params: any[] = [];
|
||||
|
||||
// 슈퍼 관리자가 아니면 회사별 필터링
|
||||
if (userCompanyCode && userCompanyCode !== "*") {
|
||||
sqlQuery += ` WHERE company_code = $1`;
|
||||
params.push(userCompanyCode);
|
||||
|
|
@ -42,9 +89,15 @@ router.get("/", async (req: AuthenticatedRequest, res: Response) => {
|
|||
|
||||
const flows = await query(sqlQuery, params);
|
||||
|
||||
const flowsWithSummary = flows.map((flow: any) => {
|
||||
const summary = extractFlowSummary(flow.flowData);
|
||||
const { flowData, ...rest } = flow;
|
||||
return { ...rest, summary };
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: flows,
|
||||
data: flowsWithSummary,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("플로우 목록 조회 실패:", error);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
import express from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import {
|
||||
getDesignRequestList, getDesignRequestDetail, createDesignRequest, updateDesignRequest, deleteDesignRequest, addRequestHistory,
|
||||
getProjectList, getProjectDetail, createProject, updateProject, deleteProject,
|
||||
getTasksByProject, createTask, updateTask, deleteTask,
|
||||
getWorkLogsByTask, createWorkLog, deleteWorkLog,
|
||||
createSubItem, updateSubItem, deleteSubItem,
|
||||
createIssue, updateIssue,
|
||||
getEcnList, createEcn, updateEcn, deleteEcn,
|
||||
getMyWork,
|
||||
createPurchaseReq, createCoopReq, addCoopResponse,
|
||||
} from "../controllers/designController";
|
||||
|
||||
const router = express.Router();
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 설계의뢰/설변요청 (DR/ECR)
|
||||
router.get("/requests", getDesignRequestList);
|
||||
router.get("/requests/:id", getDesignRequestDetail);
|
||||
router.post("/requests", createDesignRequest);
|
||||
router.put("/requests/:id", updateDesignRequest);
|
||||
router.delete("/requests/:id", deleteDesignRequest);
|
||||
router.post("/requests/:id/history", addRequestHistory);
|
||||
|
||||
// 설계 프로젝트
|
||||
router.get("/projects", getProjectList);
|
||||
router.get("/projects/:id", getProjectDetail);
|
||||
router.post("/projects", createProject);
|
||||
router.put("/projects/:id", updateProject);
|
||||
router.delete("/projects/:id", deleteProject);
|
||||
|
||||
// 프로젝트 태스크
|
||||
router.get("/projects/:projectId/tasks", getTasksByProject);
|
||||
router.post("/projects/:projectId/tasks", createTask);
|
||||
router.put("/tasks/:taskId", updateTask);
|
||||
router.delete("/tasks/:taskId", deleteTask);
|
||||
|
||||
// 작업일지
|
||||
router.get("/tasks/:taskId/work-logs", getWorkLogsByTask);
|
||||
router.post("/tasks/:taskId/work-logs", createWorkLog);
|
||||
router.delete("/work-logs/:workLogId", deleteWorkLog);
|
||||
|
||||
// 태스크 하위항목
|
||||
router.post("/tasks/:taskId/sub-items", createSubItem);
|
||||
router.put("/sub-items/:subItemId", updateSubItem);
|
||||
router.delete("/sub-items/:subItemId", deleteSubItem);
|
||||
|
||||
// 태스크 이슈
|
||||
router.post("/tasks/:taskId/issues", createIssue);
|
||||
router.put("/issues/:issueId", updateIssue);
|
||||
|
||||
// ECN (설변통보)
|
||||
router.get("/ecn", getEcnList);
|
||||
router.post("/ecn", createEcn);
|
||||
router.put("/ecn/:id", updateEcn);
|
||||
router.delete("/ecn/:id", deleteEcn);
|
||||
|
||||
// 나의 업무
|
||||
router.get("/my-work", getMyWork);
|
||||
|
||||
// 구매요청 / 협업요청
|
||||
router.post("/work-logs/:workLogId/purchase-reqs", createPurchaseReq);
|
||||
router.post("/work-logs/:workLogId/coop-reqs", createCoopReq);
|
||||
router.post("/coop-reqs/:coopReqId/responses", addCoopResponse);
|
||||
|
||||
export default router;
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* 자재현황 라우트
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import * as materialStatusController from "../controllers/materialStatusController";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 생산계획(작업지시) 목록 조회
|
||||
router.get("/work-orders", materialStatusController.getWorkOrders);
|
||||
|
||||
// 자재소요 + 재고 현황 조회 (POST: planIds 배열 전달)
|
||||
router.post("/materials", materialStatusController.getMaterialStatus);
|
||||
|
||||
// 창고 목록 조회
|
||||
router.get("/warehouses", materialStatusController.getWarehouses);
|
||||
|
||||
export default router;
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* 공정정보관리 라우트
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import * as ctrl from "../controllers/processInfoController";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 공정 마스터 CRUD
|
||||
router.get("/processes", ctrl.getProcessList);
|
||||
router.post("/processes", ctrl.createProcess);
|
||||
router.put("/processes/:id", ctrl.updateProcess);
|
||||
router.post("/processes/delete", ctrl.deleteProcesses);
|
||||
|
||||
// 공정별 설비 관리
|
||||
router.get("/processes/:processCode/equipments", ctrl.getProcessEquipments);
|
||||
router.post("/process-equipments", ctrl.addProcessEquipment);
|
||||
router.delete("/process-equipments/:id", ctrl.removeProcessEquipment);
|
||||
|
||||
// 설비 목록 (드롭다운용)
|
||||
router.get("/equipments", ctrl.getEquipmentList);
|
||||
|
||||
// 품목 목록 (라우팅 등록된 품목만)
|
||||
router.get("/items", ctrl.getItemsForRouting);
|
||||
|
||||
// 전체 품목 검색 (등록 모달용)
|
||||
router.get("/items/search-all", ctrl.searchAllItems);
|
||||
|
||||
// 라우팅 버전
|
||||
router.get("/routing-versions/:itemCode", ctrl.getRoutingVersions);
|
||||
router.post("/routing-versions", ctrl.createRoutingVersion);
|
||||
router.delete("/routing-versions/:id", ctrl.deleteRoutingVersion);
|
||||
|
||||
// 라우팅 상세
|
||||
router.get("/routing-details/:versionId", ctrl.getRoutingDetails);
|
||||
router.put("/routing-details/:versionId", ctrl.saveRoutingDetails);
|
||||
|
||||
// BOM 구성 자재 조회
|
||||
router.get("/bom-materials/:itemCode", ctrl.getBomMaterials);
|
||||
|
||||
export default router;
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* 입고관리 라우트
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import * as receivingController from "../controllers/receivingController";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 입고 목록 조회
|
||||
router.get("/list", receivingController.getList);
|
||||
|
||||
// 입고번호 자동생성
|
||||
router.get("/generate-number", receivingController.generateNumber);
|
||||
|
||||
// 창고 목록 조회
|
||||
router.get("/warehouses", receivingController.getWarehouses);
|
||||
|
||||
// 소스 데이터: 발주 (구매입고)
|
||||
router.get("/source/purchase-orders", receivingController.getPurchaseOrders);
|
||||
|
||||
// 소스 데이터: 출하 (반품입고)
|
||||
router.get("/source/shipments", receivingController.getShipments);
|
||||
|
||||
// 소스 데이터: 품목 (기타입고)
|
||||
router.get("/source/items", receivingController.getItems);
|
||||
|
||||
// 입고 등록
|
||||
router.post("/", receivingController.create);
|
||||
|
||||
// 입고 수정
|
||||
router.put("/:id", receivingController.update);
|
||||
|
||||
// 입고 삭제
|
||||
router.delete("/:id", receivingController.deleteReceiving);
|
||||
|
||||
export default router;
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { getSalesReportData } from "../controllers/salesReportController";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 영업 리포트 원본 데이터 조회
|
||||
router.get("/data", getSalesReportData);
|
||||
|
||||
export default router;
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* 출하지시 라우트
|
||||
*/
|
||||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import * as shippingOrderController from "../controllers/shippingOrderController";
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticateToken);
|
||||
|
||||
router.get("/list", shippingOrderController.getList);
|
||||
router.get("/preview-no", shippingOrderController.previewNextNo);
|
||||
router.post("/save", shippingOrderController.save);
|
||||
router.post("/delete", shippingOrderController.remove);
|
||||
|
||||
// 모달 왼쪽 패널 데이터 소스
|
||||
router.get("/source/shipment-plan", shippingOrderController.getShipmentPlanSource);
|
||||
router.get("/source/sales-order", shippingOrderController.getSalesOrderSource);
|
||||
router.get("/source/item", shippingOrderController.getItemSource);
|
||||
|
||||
export default router;
|
||||
|
|
@ -10,10 +10,16 @@ const router = Router();
|
|||
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 출하계획 목록 조회 (관리 화면용)
|
||||
router.get("/list", shippingPlanController.getList);
|
||||
|
||||
// 품목별 집계 + 기존 출하계획 조회
|
||||
router.get("/aggregate", shippingPlanController.getAggregate);
|
||||
|
||||
// 출하계획 일괄 저장
|
||||
router.post("/batch", shippingPlanController.batchSave);
|
||||
|
||||
// 출하계획 단건 수정
|
||||
router.put("/:id", shippingPlanController.updatePlan);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import * as ctrl from "../controllers/workInstructionController";
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticateToken);
|
||||
|
||||
router.get("/list", ctrl.getList);
|
||||
router.get("/preview-no", ctrl.previewNextNo);
|
||||
router.post("/save", ctrl.save);
|
||||
router.post("/delete", ctrl.remove);
|
||||
router.get("/source/item", ctrl.getItemSource);
|
||||
router.get("/source/sales-order", ctrl.getSalesOrderSource);
|
||||
router.get("/source/production-plan", ctrl.getProductionPlanSource);
|
||||
router.get("/equipment", ctrl.getEquipmentList);
|
||||
router.get("/employees", ctrl.getEmployeeList);
|
||||
|
||||
// 라우팅 & 공정작업기준
|
||||
router.get("/:wiNo/routing-versions/:itemCode", ctrl.getRoutingVersions);
|
||||
router.put("/:wiNo/routing", ctrl.updateRouting);
|
||||
router.get("/:wiNo/work-standard", ctrl.getWorkStandard);
|
||||
router.post("/:wiNo/work-standard/copy", ctrl.copyWorkStandard);
|
||||
router.put("/:wiNo/work-standard/save", ctrl.saveWorkStandard);
|
||||
router.delete("/:wiNo/work-standard/reset", ctrl.resetWorkStandard);
|
||||
|
||||
export default router;
|
||||
|
|
@ -122,20 +122,22 @@ export class BatchSchedulerService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 배치 설정 실행
|
||||
* 배치 설정 실행 - execution_type에 따라 매핑 또는 노드 플로우 실행
|
||||
*/
|
||||
static async executeBatchConfig(config: any) {
|
||||
const startTime = new Date();
|
||||
let executionLog: any = null;
|
||||
|
||||
try {
|
||||
logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id})`);
|
||||
logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id}, type: ${config.execution_type || "mapping"})`);
|
||||
|
||||
// 매핑 정보가 없으면 상세 조회로 다시 가져오기
|
||||
if (!config.batch_mappings || config.batch_mappings.length === 0) {
|
||||
const fullConfig = await BatchService.getBatchConfigById(config.id);
|
||||
if (fullConfig.success && fullConfig.data) {
|
||||
config = fullConfig.data;
|
||||
// 상세 조회 (매핑 또는 노드플로우 정보가 없을 수 있음)
|
||||
if (!config.execution_type || config.execution_type === "mapping") {
|
||||
if (!config.batch_mappings || config.batch_mappings.length === 0) {
|
||||
const fullConfig = await BatchService.getBatchConfigById(config.id);
|
||||
if (fullConfig.success && fullConfig.data) {
|
||||
config = fullConfig.data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -165,12 +167,17 @@ export class BatchSchedulerService {
|
|||
|
||||
executionLog = executionLogResponse.data;
|
||||
|
||||
// 실제 배치 실행 로직 (수동 실행과 동일한 로직 사용)
|
||||
const result = await this.executeBatchMappings(config);
|
||||
let result: { totalRecords: number; successRecords: number; failedRecords: number };
|
||||
|
||||
if (config.execution_type === "node_flow") {
|
||||
result = await this.executeNodeFlow(config);
|
||||
} else {
|
||||
result = await this.executeBatchMappings(config);
|
||||
}
|
||||
|
||||
// 실행 로그 업데이트 (성공)
|
||||
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
||||
execution_status: "SUCCESS",
|
||||
execution_status: result.failedRecords > 0 ? "PARTIAL" : "SUCCESS",
|
||||
end_time: new Date(),
|
||||
duration_ms: Date.now() - startTime.getTime(),
|
||||
total_records: result.totalRecords,
|
||||
|
|
@ -182,12 +189,10 @@ export class BatchSchedulerService {
|
|||
`배치 실행 완료: ${config.batch_name} (처리된 레코드: ${result.totalRecords})`
|
||||
);
|
||||
|
||||
// 성공 결과 반환
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`배치 실행 중 오류 발생: ${config.batch_name}`, error);
|
||||
|
||||
// 실행 로그 업데이트 (실패)
|
||||
if (executionLog) {
|
||||
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
||||
execution_status: "FAILED",
|
||||
|
|
@ -198,7 +203,6 @@ export class BatchSchedulerService {
|
|||
});
|
||||
}
|
||||
|
||||
// 실패 결과 반환
|
||||
return {
|
||||
totalRecords: 0,
|
||||
successRecords: 0,
|
||||
|
|
@ -207,6 +211,43 @@ export class BatchSchedulerService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 노드 플로우 실행 - NodeFlowExecutionService에 위임
|
||||
*/
|
||||
private static async executeNodeFlow(config: any) {
|
||||
if (!config.node_flow_id) {
|
||||
throw new Error("노드 플로우 ID가 설정되지 않았습니다.");
|
||||
}
|
||||
|
||||
const { NodeFlowExecutionService } = await import(
|
||||
"./nodeFlowExecutionService"
|
||||
);
|
||||
|
||||
const contextData: Record<string, any> = {
|
||||
companyCode: config.company_code,
|
||||
batchConfigId: config.id,
|
||||
batchName: config.batch_name,
|
||||
executionSource: "batch_scheduler",
|
||||
...(config.node_flow_context || {}),
|
||||
};
|
||||
|
||||
logger.info(
|
||||
`노드 플로우 실행: flowId=${config.node_flow_id}, batch=${config.batch_name}`
|
||||
);
|
||||
|
||||
const flowResult = await NodeFlowExecutionService.executeFlow(
|
||||
config.node_flow_id,
|
||||
contextData
|
||||
);
|
||||
|
||||
// 노드 플로우 실행 결과를 배치 로그 형식으로 변환
|
||||
return {
|
||||
totalRecords: flowResult.summary.total,
|
||||
successRecords: flowResult.summary.success,
|
||||
failedRecords: flowResult.summary.failed,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 매핑 실행 (수동 실행과 동일한 로직)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -72,9 +72,12 @@ export class BatchService {
|
|||
const total = parseInt(countResult[0].count);
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
// 목록 조회
|
||||
// 목록 조회 (최근 실행 정보 포함)
|
||||
const configs = await query<any>(
|
||||
`SELECT bc.*
|
||||
`SELECT bc.*,
|
||||
(SELECT bel.execution_status FROM batch_execution_logs bel WHERE bel.batch_config_id = bc.id ORDER BY bel.start_time DESC LIMIT 1) as last_status,
|
||||
(SELECT bel.start_time FROM batch_execution_logs bel WHERE bel.batch_config_id = bc.id ORDER BY bel.start_time DESC LIMIT 1) as last_executed_at,
|
||||
(SELECT bel.total_records FROM batch_execution_logs bel WHERE bel.batch_config_id = bc.id ORDER BY bel.start_time DESC LIMIT 1) as last_total_records
|
||||
FROM batch_configs bc
|
||||
${whereClause}
|
||||
ORDER BY bc.created_date DESC
|
||||
|
|
@ -82,9 +85,6 @@ export class BatchService {
|
|||
[...values, limit, offset]
|
||||
);
|
||||
|
||||
// 매핑 정보 조회 (N+1 문제 해결을 위해 별도 쿼리 대신 여기서는 생략하고 상세 조회에서 처리)
|
||||
// 하지만 목록에서도 간단한 정보는 필요할 수 있음
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: configs as BatchConfig[],
|
||||
|
|
@ -176,8 +176,8 @@ export class BatchService {
|
|||
// 배치 설정 생성
|
||||
const batchConfigResult = await client.query(
|
||||
`INSERT INTO batch_configs
|
||||
(batch_name, description, cron_schedule, is_active, company_code, save_mode, conflict_key, auth_service_name, data_array_path, created_by, created_date, updated_date)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
|
||||
(batch_name, description, cron_schedule, is_active, company_code, save_mode, conflict_key, auth_service_name, data_array_path, execution_type, node_flow_id, node_flow_context, created_by, created_date, updated_date)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), NOW())
|
||||
RETURNING *`,
|
||||
[
|
||||
data.batchName,
|
||||
|
|
@ -189,6 +189,9 @@ export class BatchService {
|
|||
data.conflictKey || null,
|
||||
data.authServiceName || null,
|
||||
data.dataArrayPath || null,
|
||||
data.executionType || "mapping",
|
||||
data.nodeFlowId || null,
|
||||
data.nodeFlowContext ? JSON.stringify(data.nodeFlowContext) : null,
|
||||
userId,
|
||||
]
|
||||
);
|
||||
|
|
@ -332,6 +335,22 @@ export class BatchService {
|
|||
updateFields.push(`data_array_path = $${paramIndex++}`);
|
||||
updateValues.push(data.dataArrayPath || null);
|
||||
}
|
||||
if (data.executionType !== undefined) {
|
||||
updateFields.push(`execution_type = $${paramIndex++}`);
|
||||
updateValues.push(data.executionType);
|
||||
}
|
||||
if (data.nodeFlowId !== undefined) {
|
||||
updateFields.push(`node_flow_id = $${paramIndex++}`);
|
||||
updateValues.push(data.nodeFlowId || null);
|
||||
}
|
||||
if (data.nodeFlowContext !== undefined) {
|
||||
updateFields.push(`node_flow_context = $${paramIndex++}`);
|
||||
updateValues.push(
|
||||
data.nodeFlowContext
|
||||
? JSON.stringify(data.nodeFlowContext)
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
// 배치 설정 업데이트
|
||||
const batchConfigResult = await client.query(
|
||||
|
|
|
|||
|
|
@ -79,6 +79,9 @@ export interface BatchMapping {
|
|||
created_date?: Date;
|
||||
}
|
||||
|
||||
// 배치 실행 타입: 기존 매핑 방식 또는 노드 플로우 실행
|
||||
export type BatchExecutionType = "mapping" | "node_flow";
|
||||
|
||||
// 배치 설정 타입
|
||||
export interface BatchConfig {
|
||||
id?: number;
|
||||
|
|
@ -87,15 +90,21 @@ export interface BatchConfig {
|
|||
cron_schedule: string;
|
||||
is_active: "Y" | "N";
|
||||
company_code?: string;
|
||||
save_mode?: "INSERT" | "UPSERT"; // 저장 모드 (기본: INSERT)
|
||||
conflict_key?: string; // UPSERT 시 충돌 기준 컬럼명
|
||||
auth_service_name?: string; // REST API 인증에 사용할 토큰 서비스명
|
||||
data_array_path?: string; // REST API 응답에서 데이터 배열 경로 (예: response, data.items)
|
||||
save_mode?: "INSERT" | "UPSERT";
|
||||
conflict_key?: string;
|
||||
auth_service_name?: string;
|
||||
data_array_path?: string;
|
||||
execution_type?: BatchExecutionType;
|
||||
node_flow_id?: number;
|
||||
node_flow_context?: Record<string, any>;
|
||||
created_by?: string;
|
||||
created_date?: Date;
|
||||
updated_by?: string;
|
||||
updated_date?: Date;
|
||||
batch_mappings?: BatchMapping[];
|
||||
last_status?: string;
|
||||
last_executed_at?: string;
|
||||
last_total_records?: number;
|
||||
}
|
||||
|
||||
export interface BatchConnectionInfo {
|
||||
|
|
@ -149,7 +158,10 @@ export interface CreateBatchConfigRequest {
|
|||
saveMode?: "INSERT" | "UPSERT";
|
||||
conflictKey?: string;
|
||||
authServiceName?: string;
|
||||
dataArrayPath?: string; // REST API 응답에서 데이터 배열 경로
|
||||
dataArrayPath?: string;
|
||||
executionType?: BatchExecutionType;
|
||||
nodeFlowId?: number;
|
||||
nodeFlowContext?: Record<string, any>;
|
||||
mappings: BatchMappingRequest[];
|
||||
}
|
||||
|
||||
|
|
@ -161,7 +173,10 @@ export interface UpdateBatchConfigRequest {
|
|||
saveMode?: "INSERT" | "UPSERT";
|
||||
conflictKey?: string;
|
||||
authServiceName?: string;
|
||||
dataArrayPath?: string; // REST API 응답에서 데이터 배열 경로
|
||||
dataArrayPath?: string;
|
||||
executionType?: BatchExecutionType;
|
||||
nodeFlowId?: number;
|
||||
nodeFlowContext?: Record<string, any>;
|
||||
mappings?: BatchMappingRequest[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,909 @@
|
|||
# 배치 스케줄러 + 노드 플로우 연동 계획서
|
||||
|
||||
## 1. 배경 및 목적
|
||||
|
||||
### 현재 상태
|
||||
|
||||
현재 시스템에는 두 개의 독립적인 실행 엔진이 있다:
|
||||
|
||||
| 시스템 | 역할 | 트리거 방식 |
|
||||
|--------|------|-------------|
|
||||
| **배치 스케줄러** | Cron 기반 자동 실행 (데이터 복사만 가능) | 시간 기반 (node-cron) |
|
||||
| **노드 플로우 엔진** | 조건/변환/INSERT/UPDATE/DELETE 등 복합 로직 | 버튼 클릭 (수동) |
|
||||
|
||||
### 문제
|
||||
|
||||
- 배치는 **INSERT/UPSERT만** 가능하고, 조건 기반 UPDATE/DELETE를 못 함
|
||||
- 노드 플로우는 강력하지만 **수동 실행만** 가능 (버튼 클릭 필수)
|
||||
- "퇴사일이 지나면 자동으로 퇴사 처리" 같은 **시간 기반 비즈니스 로직**을 구현할 수 없음
|
||||
|
||||
### 목표
|
||||
|
||||
배치 스케줄러가 노드 플로우를 자동 실행할 수 있도록 연동하여,
|
||||
시간 기반 비즈니스 로직 자동화를 지원한다.
|
||||
|
||||
```
|
||||
[배치 스케줄러] ──Cron 트리거──> [노드 플로우 실행 엔진]
|
||||
│ │
|
||||
│ ├── 테이블 소스 조회
|
||||
│ ├── 조건 분기
|
||||
│ ├── UPDATE / DELETE / INSERT
|
||||
│ ├── 이메일 발송
|
||||
│ └── 로깅
|
||||
│
|
||||
└── 실행 로그 기록 (batch_execution_logs)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 사용 시나리오
|
||||
|
||||
### 시나리오 A: 자동 퇴사 처리
|
||||
|
||||
```
|
||||
매일 자정 실행:
|
||||
1. user_info에서 퇴사일 <= NOW() AND 상태 != '퇴사' 인 사람 조회
|
||||
2. 해당 사용자의 상태를 '퇴사'로 UPDATE
|
||||
3. 관리자에게 이메일 알림 발송
|
||||
```
|
||||
|
||||
### 시나리오 B: 월말 재고 마감
|
||||
|
||||
```
|
||||
매월 1일 00:00 실행:
|
||||
1. 전월 재고 데이터를 재고마감 테이블로 INSERT
|
||||
2. 이월 수량 계산 후 UPDATE
|
||||
```
|
||||
|
||||
### 시나리오 C: 미납 알림
|
||||
|
||||
```
|
||||
매일 09:00 실행:
|
||||
1. 납기일이 지난 미납 주문 조회
|
||||
2. 담당자에게 이메일 발송
|
||||
3. 알림 로그 INSERT
|
||||
```
|
||||
|
||||
### 시나리오 D: 외부 API 연동 자동화
|
||||
|
||||
```
|
||||
매시간 실행:
|
||||
1. 외부 REST API에서 데이터 조회
|
||||
2. 조건 필터링 (변경된 데이터만)
|
||||
3. 내부 테이블에 UPSERT
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 구현 범위
|
||||
|
||||
### 3.1 DB 변경 (batch_configs 테이블 확장)
|
||||
|
||||
```sql
|
||||
-- batch_configs 테이블에 컬럼 추가
|
||||
ALTER TABLE batch_configs
|
||||
ADD COLUMN execution_type VARCHAR(20) DEFAULT 'mapping',
|
||||
ADD COLUMN node_flow_id INTEGER DEFAULT NULL,
|
||||
ADD COLUMN node_flow_context JSONB DEFAULT NULL;
|
||||
|
||||
-- execution_type: 'mapping' (기존 데이터 복사) | 'node_flow' (노드 플로우 실행)
|
||||
-- node_flow_id: node_flows 테이블의 flow_id (FK)
|
||||
-- node_flow_context: 플로우 실행 시 전달할 컨텍스트 데이터 (선택)
|
||||
|
||||
COMMENT ON COLUMN batch_configs.execution_type IS '실행 타입: mapping(기존 데이터 복사), node_flow(노드 플로우 실행)';
|
||||
COMMENT ON COLUMN batch_configs.node_flow_id IS '연결된 노드 플로우 ID (execution_type이 node_flow일 때 사용)';
|
||||
COMMENT ON COLUMN batch_configs.node_flow_context IS '플로우 실행 시 전달할 컨텍스트 데이터 (JSON)';
|
||||
```
|
||||
|
||||
기존 데이터에 영향 없음 (`DEFAULT 'mapping'`으로 하위 호환성 보장)
|
||||
|
||||
### 3.2 백엔드 변경
|
||||
|
||||
#### BatchSchedulerService 수정 (핵심)
|
||||
|
||||
`executeBatchConfig()` 메서드에서 `execution_type` 분기:
|
||||
|
||||
```
|
||||
executeBatchConfig(config)
|
||||
├── config.execution_type === 'mapping'
|
||||
│ └── 기존 executeBatchMappings() (변경 없음)
|
||||
│
|
||||
└── config.execution_type === 'node_flow'
|
||||
└── NodeFlowExecutionService.executeFlow()
|
||||
├── 노드 플로우 조회
|
||||
├── 위상 정렬
|
||||
├── 레벨별 실행
|
||||
└── 결과 반환
|
||||
```
|
||||
|
||||
수정 파일:
|
||||
- `backend-node/src/services/batchSchedulerService.ts`
|
||||
- `executeBatchConfig()` 에 node_flow 분기 추가
|
||||
- 노드 플로우 실행 결과를 배치 로그 형식으로 변환
|
||||
|
||||
#### 배치 설정 API 수정
|
||||
|
||||
수정 파일:
|
||||
- `backend-node/src/types/batchTypes.ts`
|
||||
- `BatchConfig` 인터페이스에 `execution_type`, `node_flow_id`, `node_flow_context` 추가
|
||||
- `CreateBatchConfigRequest`, `UpdateBatchConfigRequest` 에도 추가
|
||||
- `backend-node/src/services/batchService.ts`
|
||||
- `createBatchConfig()` - 새 필드 INSERT
|
||||
- `updateBatchConfig()` - 새 필드 UPDATE
|
||||
- `backend-node/src/controllers/batchManagementController.ts`
|
||||
- 생성/수정 시 새 필드 처리
|
||||
|
||||
#### 노드 플로우 목록 API (배치용)
|
||||
|
||||
추가 파일/수정:
|
||||
- `backend-node/src/routes/batchManagementRoutes.ts`
|
||||
- `GET /api/batch-management/node-flows` 추가 (배치 설정 UI에서 플로우 선택용)
|
||||
|
||||
### 3.3 프론트엔드 변경
|
||||
|
||||
#### 배치 생성/편집 UI 수정
|
||||
|
||||
수정 파일:
|
||||
- `frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx`
|
||||
- `frontend/app/(main)/admin/automaticMng/batchmngList/edit/[id]/page.tsx`
|
||||
|
||||
변경 내용:
|
||||
- "실행 타입" 선택 추가 (기존 매핑 / 노드 플로우)
|
||||
- 노드 플로우 선택 시: 플로우 드롭다운 표시 (기존 매핑 설정 숨김)
|
||||
- 노드 플로우 선택 시: 컨텍스트 데이터 입력 (선택사항, JSON)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 배치 설정 │
|
||||
├─────────────────────────────────────────┤
|
||||
│ 배치명: [자동 퇴사 처리 ] │
|
||||
│ 설명: [퇴사일 경과 사용자 자동 처리] │
|
||||
│ Cron: [0 0 * * * ] │
|
||||
│ │
|
||||
│ 실행 타입: ○ 데이터 매핑 ● 노드 플로우 │
|
||||
│ │
|
||||
│ ┌─ 노드 플로우 선택 ─────────────────┐ │
|
||||
│ │ [▾ 자동 퇴사 처리 플로우 ] │ │
|
||||
│ │ │ │
|
||||
│ │ 플로우 설명: user_info에서 퇴사일..│ │
|
||||
│ │ 노드 수: 4개 │ │
|
||||
│ └────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [취소] [저장] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 배치 목록 UI - Ops 대시보드 리디자인
|
||||
|
||||
현재 배치 목록은 단순 테이블인데, Vercel/Railway 스타일의 **운영 대시보드**로 전면 리디자인한다.
|
||||
노드 플로우 연동과 함께 적용하면 새로운 실행 타입도 자연스럽게 표현 가능.
|
||||
|
||||
디자인 컨셉: **"편집기"가 아닌 "운영 대시보드"**
|
||||
- 데이터 타입 관리 = 컬럼 편집기 → 3패널(리스트/그리드/설정)이 적합
|
||||
- 배치 관리 = 운영 모니터링 → 테이블 + 인라인 상태 표시가 적합
|
||||
- 역할이 다르면 레이아웃도 달라야 함
|
||||
|
||||
---
|
||||
|
||||
##### 전체 레이아웃
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ [헤더] 배치 관리 [새로고침] [새 배치] │
|
||||
│ └ 데이터 동기화 배치 작업을 모니터링하고 관리합니다 │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ [통계 카드 4열 그리드] │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ 전체 배치 │ │ 활성 배치 │ │ 오늘 실행 │ │ 오늘 실패 │ │
|
||||
│ │ 8 │ │ 6 │ │ 142 │ │ 3 │ │
|
||||
│ │ +2 이번달│ │ 2 비활성 │ │+12% 전일 │ │+1 전일 │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ [툴바] │
|
||||
│ 🔍 검색... [전체|활성|비활성] [전체|DB-DB|API-DB|플로우] 총 8건 │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ [테이블 헤더] │
|
||||
│ ● 배치 타입 스케줄 최근24h 마지막실행 │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ ● 품목 마스터 동기화 DB→DB */30**** ▌▌▌▐▌▌▌ 14:30 ▶✎🗑 │
|
||||
│ ┌────────────────────────────────────────────────────────┐ │
|
||||
│ │ [확장 상세 패널 - 클릭 시 토글] │ │
|
||||
│ │ 내러티브 + 파이프라인 + 매핑 + 설정 + 타임라인 │ │
|
||||
│ └────────────────────────────────────────────────────────┘ │
|
||||
│ ● 거래처 ERP 연동 API→DB 0*/2*** ▌▌▌▌▌▌▌ 14:00 ▶✎🗑 │
|
||||
│ ◉ 재고 현황 수집 API→DB 06,18** ▌▌▐▌▌▌░ 실행중 ▶✎🗑 │
|
||||
│ ○ BOM 백업 DB→DB 0 3**0 ░░░░░░░ 비활성 ▶✎🗑 │
|
||||
│ ... │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
##### 1. 페이지 헤더
|
||||
|
||||
```
|
||||
구조: flex, align-items: flex-end, justify-content: space-between
|
||||
하단 보더: 1px solid border
|
||||
하단 마진: 24px
|
||||
|
||||
좌측:
|
||||
- 제목: "배치 관리" (text-xl font-extrabold tracking-tight)
|
||||
- 부제: "데이터 동기화 배치 작업을 모니터링하고 관리합니다" (text-xs text-muted-foreground)
|
||||
|
||||
우측 버튼 그룹 (gap-2):
|
||||
- [새로고침] 버튼: variant="outline", RefreshCw 아이콘
|
||||
- [새 배치] 버튼: variant="default" (primary), Plus 아이콘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
##### 2. 통계 카드 영역
|
||||
|
||||
```
|
||||
레이아웃: grid grid-cols-4 gap-3
|
||||
각 카드: rounded-xl border bg-card p-4
|
||||
|
||||
카드 구조:
|
||||
┌──────────────────────────┐
|
||||
│ [라벨] [아이콘] │ ← stat-top: flex justify-between
|
||||
│ │
|
||||
│ 숫자값 (28px 모노 볼드) │ ← stat-val: font-mono text-3xl font-extrabold
|
||||
│ │
|
||||
│ [변화량 배지] 기간 텍스트 │ ← stat-footer: flex items-center gap-1.5
|
||||
└──────────────────────────┘
|
||||
|
||||
4개 카드 상세:
|
||||
┌─────────────┬────────────┬───────────────────────────────┐
|
||||
│ 카드 │ 아이콘 색상 │ 값 색상 │
|
||||
├─────────────┼────────────┼───────────────────────────────┤
|
||||
│ 전체 배치 │ indigo bg │ foreground (기본) │
|
||||
│ 활성 배치 │ green bg │ green (--success) │
|
||||
│ 오늘 실행 │ cyan bg │ cyan (--info 계열) │
|
||||
│ 오늘 실패 │ red bg │ red (--destructive) │
|
||||
└─────────────┴────────────┴───────────────────────────────┘
|
||||
|
||||
변화량 배지:
|
||||
- 증가: green 배경 + green 텍스트, "+N" 또는 "+N%"
|
||||
- 감소/악화: red 배경 + red 텍스트
|
||||
- 크기: text-[10px] font-bold px-1.5 py-0.5 rounded
|
||||
|
||||
아이콘 박스: 28x28px rounded-lg, 배경색 투명도 10%
|
||||
아이콘: lucide-react (LayoutGrid, CheckCircle, Activity, XCircle)
|
||||
```
|
||||
|
||||
**데이터 소스:**
|
||||
```
|
||||
GET /api/batch-management/stats
|
||||
→ {
|
||||
totalBatches: number, // batch_configs COUNT(*)
|
||||
activeBatches: number, // batch_configs WHERE is_active='Y'
|
||||
todayExecutions: number, // batch_execution_logs WHERE DATE(start_time)=TODAY
|
||||
todayFailures: number, // batch_execution_logs WHERE DATE(start_time)=TODAY AND status='FAILED'
|
||||
// 선택사항: 전일 대비 변화량
|
||||
prevDayExecutions?: number,
|
||||
prevDayFailures?: number
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
##### 3. 툴바
|
||||
|
||||
```
|
||||
레이아웃: flex items-center gap-2.5
|
||||
|
||||
요소 1 - 검색:
|
||||
- 위치: 좌측, flex-1 max-w-[320px]
|
||||
- 구조: relative div + input + Search 아이콘(absolute left)
|
||||
- input: h-9, rounded-lg, border, bg-card, text-xs
|
||||
- placeholder: "배치 이름으로 검색..."
|
||||
- focus: ring-2 ring-primary
|
||||
|
||||
요소 2 - 상태 필터 (pill-group):
|
||||
- 컨테이너: flex gap-0.5, bg-card, border, rounded-lg, p-0.5
|
||||
- 각 pill: text-[11px] font-semibold px-3 py-1.5 rounded-md
|
||||
- 활성 pill: bg-primary/10 text-primary
|
||||
- 비활성 pill: text-muted-foreground, hover시 밝아짐
|
||||
- 항목: [전체] [활성] [비활성]
|
||||
|
||||
요소 3 - 타입 필터 (pill-group):
|
||||
- 동일 스타일
|
||||
- 항목: [전체] [DB-DB] [API-DB] [노드 플로우] ← 노드 플로우는 신규
|
||||
|
||||
요소 4 - 건수 표시:
|
||||
- 위치: ml-auto (우측 정렬)
|
||||
- 텍스트: "총 N건" (text-[11px] text-muted-foreground, N은 font-bold)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
##### 4. 배치 테이블
|
||||
|
||||
```
|
||||
컨테이너: border rounded-xl overflow-hidden bg-card
|
||||
|
||||
테이블 헤더:
|
||||
- 배경: bg-muted/50
|
||||
- 높이: 40px
|
||||
- 글자: text-[10px] font-bold text-muted-foreground uppercase tracking-wider
|
||||
- 그리드 컬럼: 44px 1fr 100px 130px 160px 100px 120px
|
||||
- 컬럼: [LED] [배치] [타입] [스케줄] [최근 24h] [마지막 실행] [액션]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
##### 5. 배치 테이블 행 (핵심)
|
||||
|
||||
```
|
||||
그리드: 44px 1fr 100px 130px 160px 100px 120px
|
||||
높이: min-height 60px
|
||||
하단 보더: 1px solid border
|
||||
hover: bg-card/80 (약간 밝아짐)
|
||||
선택됨: bg-primary/10 + 좌측 3px primary 박스 섀도우 (inset)
|
||||
클릭 시: 상세 패널 토글
|
||||
|
||||
[셀 1] LED 상태 표시:
|
||||
┌──────────────────────────────────────┐
|
||||
│ 원형 8x8px, 센터 정렬 │
|
||||
│ │
|
||||
│ 활성(on): green + box-shadow glow │
|
||||
│ 실행중(run): amber + 1.5s blink 애니 │
|
||||
│ 비활성(off): muted-foreground (회색) │
|
||||
│ 에러(err): red + box-shadow glow │
|
||||
└──────────────────────────────────────┘
|
||||
|
||||
[셀 2] 배치 정보:
|
||||
┌──────────────────────────────────────┐
|
||||
│ 배치명: text-[13px] font-bold │
|
||||
│ 설명: text-[10px] text-muted-fg │
|
||||
│ overflow ellipsis (1줄) │
|
||||
│ │
|
||||
│ 비활성 배치: 배치명도 muted 색상 │
|
||||
└──────────────────────────────────────┘
|
||||
|
||||
[셀 3] 타입 배지:
|
||||
┌──────────────────────────────────────┐
|
||||
│ inline-flex, text-[10px] font-bold │
|
||||
│ px-2 py-0.5 rounded-[5px] │
|
||||
│ │
|
||||
│ DB → DB: cyan 배경/텍스트 │
|
||||
│ API → DB: violet 배경/텍스트 │
|
||||
│ 노드 플로우: indigo 배경/텍스트 (신규) │
|
||||
└──────────────────────────────────────┘
|
||||
|
||||
[셀 4] Cron 스케줄:
|
||||
┌──────────────────────────────────────┐
|
||||
│ Cron 표현식: font-mono text-[11px] │
|
||||
│ font-medium │
|
||||
│ 한글 설명: text-[9px] text-muted │
|
||||
│ "매 30분", "매일 01:00" │
|
||||
│ │
|
||||
│ 비활성: muted 색상 │
|
||||
└──────────────────────────────────────┘
|
||||
|
||||
Cron → 한글 변환 예시:
|
||||
- */30 * * * * → "매 30분"
|
||||
- 0 */2 * * * → "매 2시간"
|
||||
- 0 6,18 * * * → "06:00, 18:00"
|
||||
- 0 1 * * * → "매일 01:00"
|
||||
- 0 3 * * 0 → "매주 일 03:00"
|
||||
- 0 0 1 * * → "매월 1일 00:00"
|
||||
|
||||
[셀 5] 스파크라인 (최근 24h):
|
||||
┌──────────────────────────────────────┐
|
||||
│ flex, items-end, gap-[1px], h-6 │
|
||||
│ │
|
||||
│ 24개 바 (시간당 1개): │
|
||||
│ - 성공(ok): green, opacity 60% │
|
||||
│ - 실패(fail): red, opacity 80% │
|
||||
│ - 미실행(none): muted, opacity 15% │
|
||||
│ │
|
||||
│ 각 바: flex-1, min-w-[3px] │
|
||||
│ rounded-t-[1px] │
|
||||
│ 높이: 실행시간 비례 또는 고정 │
|
||||
│ hover: opacity 100% │
|
||||
└──────────────────────────────────────┘
|
||||
|
||||
데이터: 최근 24시간을 1시간 단위로 슬라이싱
|
||||
각 슬롯별 가장 최근 실행의 status 사용
|
||||
높이: 성공=80~95%, 실패=20~40%, 미실행=5%
|
||||
|
||||
[셀 6] 마지막 실행:
|
||||
┌──────────────────────────────────────┐
|
||||
│ 시간: font-mono text-[10px] │
|
||||
│ "14:30:00" │
|
||||
│ 경과: text-[9px] muted │
|
||||
│ "12분 전" │
|
||||
│ │
|
||||
│ 실행 중: amber 색상 "실행 중..." │
|
||||
│ 비활성: muted "-" + "비활성" │
|
||||
└──────────────────────────────────────┘
|
||||
|
||||
[셀 7] 액션 버튼:
|
||||
┌──────────────────────────────────────┐
|
||||
│ flex gap-1, justify-end │
|
||||
│ │
|
||||
│ 3개 아이콘 버튼 (28x28 rounded-md): │
|
||||
│ │
|
||||
│ [▶] 수동 실행 │
|
||||
│ hover: green 테두리+배경+아이콘 │
|
||||
│ 아이콘: Play (lucide) │
|
||||
│ │
|
||||
│ [✎] 편집 │
|
||||
│ hover: 기본 밝아짐 │
|
||||
│ 아이콘: Pencil (lucide) │
|
||||
│ │
|
||||
│ [🗑] 삭제 │
|
||||
│ hover: red 테두리+배경+아이콘 │
|
||||
│ 아이콘: Trash2 (lucide) │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
##### 6. 행 확장 상세 패널 (클릭 시 토글)
|
||||
|
||||
행을 클릭하면 아래로 펼쳐지는 상세 패널. 매핑 타입과 노드 플로우 타입에 따라 내용이 달라진다.
|
||||
|
||||
```
|
||||
컨테이너:
|
||||
- border (상단 border 없음, 행과 이어짐)
|
||||
- rounded-b-xl
|
||||
- bg-muted/30 (행보다 약간 어두운 배경)
|
||||
- padding: 20px 24px
|
||||
|
||||
내부 구조:
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ [내러티브 박스] │
|
||||
│ "ERP_SOURCE DB의 item_master 테이블에서 현재 DB의 │
|
||||
│ item_info 테이블로 12개 컬럼을 매 30분마다 동기화하고 │
|
||||
│ 있어요. 오늘 48회 실행, 마지막 실행은 12분 전이에요." │
|
||||
├────────────────────────────────────────────────────────────┤
|
||||
│ [파이프라인 플로우 다이어그램] │
|
||||
│ │
|
||||
│ ┌─────────────┐ 12 컬럼 UPSERT ┌─────────────┐ │
|
||||
│ │ 🗄 DB아이콘 │ ─────────────────→ │ 🗄 DB아이콘 │ │
|
||||
│ │ ERP_SOURCE │ WHERE USE_YN='Y' │ 현재 DB │ │
|
||||
│ │ item_master │ │ item_info │ │
|
||||
│ └─────────────┘ └─────────────┘ │
|
||||
├──────────────────────┬─────────────────────────────────────┤
|
||||
│ [좌측: 매핑 + 설정] │ [우측: 실행 이력 타임라인] │
|
||||
│ │ │
|
||||
│ --- 컬럼 매핑 (12) --- │ --- 실행 이력 (최근 5건) --- │
|
||||
│ ITEM_CD → item_code PK│ ● 14:30:00 [성공] 1,842건 3.2s │
|
||||
│ ITEM_NM → item_name │ │ │
|
||||
│ ITEM_SPEC → spec... │ ● 14:00:00 [성공] 1,840건 3.1s │
|
||||
│ UNIT_CD → unit_code │ │ │
|
||||
│ STD_PRICE → std_price │ ✕ 13:30:00 [실패] Timeout │
|
||||
│ + 7개 더 보기 │ │ │
|
||||
│ │ ● 13:00:00 [성공] 1,838건 2.9s │
|
||||
│ --- 설정 --- │ │ │
|
||||
│ 배치 크기: 500 │ ● 12:30:00 [성공] 1,835건 3.5s │
|
||||
│ 타임아웃: 30s │ │
|
||||
│ 실패 시: 3회 재시도 │ │
|
||||
│ 매칭 키: item_code │ │
|
||||
│ 모드: [UPSERT] │ │
|
||||
└──────────────────────┴─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**6-1. 내러티브 박스 (Toss 스타일 자연어 설명)**
|
||||
|
||||
```
|
||||
스타일:
|
||||
- rounded-lg
|
||||
- 배경: linear-gradient(135deg, primary/6%, info/4%)
|
||||
- 보더: 1px solid primary/8%
|
||||
- padding: 12px 14px
|
||||
- margin-bottom: 16px
|
||||
|
||||
텍스트: text-[11px] text-muted-foreground leading-relaxed
|
||||
강조 텍스트:
|
||||
- 굵은 텍스트(b): foreground font-semibold
|
||||
- 하이라이트(hl): primary font-bold
|
||||
|
||||
매핑 타입 예시:
|
||||
"ERP_SOURCE 데이터베이스의 item_master 테이블에서 현재 DB의
|
||||
item_info 테이블로 12개 컬럼을 매 30분마다 동기화하고 있어요.
|
||||
오늘 48회 실행, 마지막 실행은 12분 전이에요."
|
||||
|
||||
노드 플로우 타입 예시:
|
||||
"자동 퇴사 처리 노드 플로우를 매일 00:00에 실행하고 있어요.
|
||||
user_info 테이블에서 퇴사일이 지난 사용자를 조회하여
|
||||
상태를 '퇴사'로 변경합니다. 4개 노드로 구성되어 있어요."
|
||||
```
|
||||
|
||||
**6-2. 파이프라인 플로우 다이어그램**
|
||||
|
||||
```
|
||||
컨테이너:
|
||||
- flex items-center
|
||||
- rounded-lg border bg-card p-4
|
||||
- margin-bottom: 16px
|
||||
|
||||
구조: [소스 노드] ──[커넥터]──> [타겟 노드]
|
||||
|
||||
소스 노드 (pipe-node src):
|
||||
- 배경: cyan/6%, 보더: cyan/12%
|
||||
- 아이콘: 32x32 rounded-lg, cyan/12% 배경
|
||||
- DB 타입: Database 아이콘 (lucide)
|
||||
- API 타입: Cloud 아이콘 (lucide) + violet 색상
|
||||
- 이름: text-xs font-bold cyan 색상
|
||||
- 부제: font-mono text-[10px] muted (테이블명/URL)
|
||||
|
||||
커넥터 (pipe-connector):
|
||||
- flex-1, flex-col items-center
|
||||
- 상단 라벨: text-[9px] font-bold muted ("12 컬럼 UPSERT")
|
||||
- 라인: width 100%, h-[2px], gradient(cyan → green)
|
||||
- 라인 끝: 삼각형 화살표 (CSS ::after)
|
||||
- 하단 라벨: text-[9px] font-bold muted ("WHERE USE_YN='Y'")
|
||||
|
||||
타겟 노드 (pipe-node tgt):
|
||||
- 배경: green/6%, 보더: green/12%
|
||||
- 아이콘: green/12% 배경
|
||||
- 이름: text-xs font-bold green 색상
|
||||
- 부제: 테이블명
|
||||
|
||||
노드 플로우 타입일 때:
|
||||
- 소스/타겟 대신 노드 플로우 요약 카드로 대체
|
||||
- 아이콘: Workflow 아이콘 (lucide) + indigo 색상
|
||||
- 이름: 플로우명
|
||||
- 부제: "N개 노드 | 조건 분기 포함"
|
||||
- 노드 목록: 간략 리스트 (Source → Condition → Update → Email)
|
||||
```
|
||||
|
||||
**6-3. 하단 2열 그리드**
|
||||
|
||||
```
|
||||
레이아웃: grid grid-cols-2 gap-5
|
||||
|
||||
[좌측 컬럼] 매핑 + 설정:
|
||||
|
||||
섹션 1 - 컬럼 매핑:
|
||||
헤더: flex items-center gap-1.5
|
||||
- Link 아이콘 (lucide, 13px, muted)
|
||||
- "컬럼 매핑" (text-[11px] font-bold muted)
|
||||
- 건수 배지 (ml-auto, text-[9px] font-bold, primary/10% bg, primary 색)
|
||||
|
||||
매핑 행 (map-row):
|
||||
- flex items-center gap-1.5
|
||||
- rounded-md border bg-card px-2.5 py-1.5
|
||||
- margin-bottom: 2px
|
||||
|
||||
구조: [소스 컬럼] → [타겟 컬럼] [태그]
|
||||
소스: font-mono font-semibold text-[11px] cyan
|
||||
화살표: "→" muted
|
||||
타겟: font-mono font-semibold text-[11px] green
|
||||
태그: text-[8px] font-bold px-1.5 py-0.5 rounded-sm
|
||||
PK = green 배경 + dark 텍스트
|
||||
|
||||
5개까지 표시 후 "+ N개 더 보기" 접기/펼치기
|
||||
|
||||
노드 플로우 타입일 때:
|
||||
매핑 대신 "노드 구성" 섹션으로 대체
|
||||
각 행: [노드 아이콘] [노드 타입] [노드 설명]
|
||||
예: 🔍 테이블 소스 | user_info 조회
|
||||
🔀 조건 분기 | 퇴사일 <= NOW()
|
||||
✏️ UPDATE | status → '퇴사'
|
||||
📧 이메일 | 관리자 알림
|
||||
|
||||
섹션 2 - 설정 (cprop 리스트):
|
||||
헤더: Settings 아이콘 + "설정"
|
||||
|
||||
각 행 (cprop):
|
||||
- flex justify-between py-1.5
|
||||
- 하단 보더: 1px solid white/3%
|
||||
- 키: text-[11px] muted
|
||||
- 값: text-[11px] font-semibold, mono체는 font-mono text-[10px]
|
||||
- 특수 값: UPSERT 배지 (green/10% bg, green 색, text-[10px] font-bold)
|
||||
|
||||
매핑 타입 설정:
|
||||
- 배치 크기: 500
|
||||
- 타임아웃: 30s
|
||||
- 실패 시 재시도: 3회 (green)
|
||||
- 매칭 키: item_code (mono)
|
||||
- 모드: [UPSERT] (배지)
|
||||
|
||||
노드 플로우 타입 설정:
|
||||
- 플로우 ID: 42
|
||||
- 노드 수: 4개
|
||||
- 실행 타임아웃: 60s
|
||||
- 컨텍스트: { ... } (mono, 접기 가능)
|
||||
|
||||
|
||||
[우측 컬럼] 실행 이력 타임라인:
|
||||
|
||||
헤더: Clock 아이콘 + "실행 이력" + "최근 5건" 배지 (green)
|
||||
|
||||
타임라인 (timeline):
|
||||
flex-col, gap-0
|
||||
|
||||
각 항목 (tl-item):
|
||||
- flex items-start gap-3
|
||||
- padding: 10px 0
|
||||
- 하단 보더: 1px solid white/3%
|
||||
|
||||
좌측 - 점+선 (tl-dot-wrap):
|
||||
- flex-col items-center, width 16px
|
||||
- 점 (tl-dot): 8x8 rounded-full
|
||||
성공(ok): green
|
||||
실패(fail): red
|
||||
실행중(run): amber + blink 애니메이션
|
||||
- 선 (tl-line): width 1px, bg border, min-h 12px
|
||||
마지막 항목에는 선 없음
|
||||
|
||||
우측 - 내용 (tl-body):
|
||||
- 시간: font-mono text-[10px] font-semibold
|
||||
- 상태 배지: text-[9px] font-bold px-1.5 py-0.5 rounded
|
||||
성공: green/10% bg + green 색
|
||||
실패: red/10% bg + red 색
|
||||
- 메시지: text-[10px] muted, margin-top 2px
|
||||
성공: "1,842건 처리 / 3.2s 소요"
|
||||
실패: "Connection timeout: ERP_SOURCE 응답 없음"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
##### 7. 반응형 대응
|
||||
|
||||
```
|
||||
1024px 이하 (태블릿):
|
||||
- 통계 카드: grid-cols-2
|
||||
- 테이블 그리드: 36px 1fr 80px 110px 120px 80px (액션 숨김)
|
||||
- 상세 패널 2열 그리드 → 1열
|
||||
|
||||
640px 이하 (모바일):
|
||||
- 컨테이너 padding: 16px
|
||||
- 통계 카드: grid-cols-2 gap-2
|
||||
- 테이블 헤더: 숨김
|
||||
- 테이블 행: grid-cols-1, 카드형태 (padding 16px, gap 8px)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
##### 8. 필요한 백엔드 API
|
||||
|
||||
```
|
||||
1. GET /api/batch-management/stats
|
||||
→ {
|
||||
totalBatches: number,
|
||||
activeBatches: number,
|
||||
todayExecutions: number,
|
||||
todayFailures: number,
|
||||
prevDayExecutions?: number,
|
||||
prevDayFailures?: number
|
||||
}
|
||||
쿼리: batch_configs COUNT + batch_execution_logs 오늘/어제 집계
|
||||
|
||||
2. GET /api/batch-management/batch-configs/:id/sparkline
|
||||
→ [{ hour: 0~23, status: 'success'|'failed'|'none', count: number }]
|
||||
쿼리: batch_execution_logs WHERE batch_config_id=$1
|
||||
AND start_time >= NOW() - INTERVAL '24 hours'
|
||||
GROUP BY EXTRACT(HOUR FROM start_time)
|
||||
|
||||
3. GET /api/batch-management/batch-configs/:id/recent-logs?limit=5
|
||||
→ [{ start_time, end_time, execution_status, total_records,
|
||||
success_records, failed_records, error_message, duration_ms }]
|
||||
쿼리: batch_execution_logs WHERE batch_config_id=$1
|
||||
ORDER BY start_time DESC LIMIT $2
|
||||
|
||||
4. GET /api/batch-management/batch-configs (기존 수정)
|
||||
→ 각 배치에 sparkline 요약 + last_execution 포함하여 반환
|
||||
목록 페이지에서 개별 sparkline API를 N번 호출하지 않도록
|
||||
한번에 가져오기 (LEFT JOIN + 서브쿼리)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 변경 파일 목록
|
||||
|
||||
### DB
|
||||
|
||||
| 파일 | 변경 | 설명 |
|
||||
|------|------|------|
|
||||
| `db/migrations/XXXX_batch_node_flow_integration.sql` | 신규 | ALTER TABLE batch_configs |
|
||||
|
||||
### 백엔드
|
||||
|
||||
| 파일 | 변경 | 설명 |
|
||||
|------|------|------|
|
||||
| `backend-node/src/services/batchSchedulerService.ts` | 수정 | executeBatchConfig에 node_flow 분기 |
|
||||
| `backend-node/src/types/batchTypes.ts` | 수정 | BatchConfig 타입에 새 필드 추가 |
|
||||
| `backend-node/src/services/batchService.ts` | 수정 | create/update에 새 필드 처리 |
|
||||
| `backend-node/src/controllers/batchManagementController.ts` | 수정 | 새 필드 API + stats/sparkline/recent-logs API |
|
||||
| `backend-node/src/routes/batchManagementRoutes.ts` | 수정 | node-flows/stats/sparkline 엔드포인트 추가 |
|
||||
|
||||
### 프론트엔드
|
||||
|
||||
| 파일 | 변경 | 설명 |
|
||||
|------|------|------|
|
||||
| `frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx` | **리디자인** | Ops 대시보드 스타일로 전면 재작성 |
|
||||
| `frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx` | 수정 | 실행 타입 선택 + 플로우 선택 |
|
||||
| `frontend/app/(main)/admin/automaticMng/batchmngList/edit/[id]/page.tsx` | 수정 | 실행 타입 선택 + 플로우 선택 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 핵심 구현 상세
|
||||
|
||||
### 5.1 BatchSchedulerService 변경 (가장 중요)
|
||||
|
||||
```typescript
|
||||
// batchSchedulerService.ts - executeBatchConfig 메서드 수정
|
||||
|
||||
static async executeBatchConfig(config: any) {
|
||||
const startTime = new Date();
|
||||
let executionLog: any = null;
|
||||
|
||||
try {
|
||||
// ... 실행 로그 생성 (기존 코드 유지) ...
|
||||
|
||||
let result;
|
||||
|
||||
// 실행 타입에 따라 분기
|
||||
if (config.execution_type === 'node_flow' && config.node_flow_id) {
|
||||
// 노드 플로우 실행
|
||||
result = await this.executeNodeFlow(config);
|
||||
} else {
|
||||
// 기존 매핑 실행 (하위 호환)
|
||||
result = await this.executeBatchMappings(config);
|
||||
}
|
||||
|
||||
// ... 실행 로그 업데이트 (기존 코드 유지) ...
|
||||
return result;
|
||||
} catch (error) {
|
||||
// ... 에러 처리 (기존 코드 유지) ...
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 노드 플로우 실행 (신규)
|
||||
*/
|
||||
private static async executeNodeFlow(config: any) {
|
||||
const { NodeFlowExecutionService } = await import('./nodeFlowExecutionService');
|
||||
|
||||
const context = {
|
||||
sourceData: [],
|
||||
dataSourceType: 'none',
|
||||
nodeResults: new Map(),
|
||||
executionOrder: [],
|
||||
buttonContext: {
|
||||
buttonId: `batch_${config.id}`,
|
||||
companyCode: config.company_code,
|
||||
userId: config.created_by || 'batch_system',
|
||||
formData: config.node_flow_context || {},
|
||||
},
|
||||
};
|
||||
|
||||
const flowResult = await NodeFlowExecutionService.executeFlow(
|
||||
config.node_flow_id,
|
||||
context
|
||||
);
|
||||
|
||||
// 노드 플로우 결과를 배치 로그 형식으로 변환
|
||||
return {
|
||||
totalRecords: flowResult.totalNodes || 0,
|
||||
successRecords: flowResult.successNodes || 0,
|
||||
failedRecords: flowResult.failedNodes || 0,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 실행 결과 매핑
|
||||
|
||||
노드 플로우 결과 → 배치 로그 변환:
|
||||
|
||||
| 노드 플로우 결과 | 배치 로그 필드 | 설명 |
|
||||
|------------------|---------------|------|
|
||||
| 전체 노드 수 | total_records | 실행 대상 노드 수 |
|
||||
| 성공 노드 수 | success_records | 성공적으로 실행된 노드 |
|
||||
| 실패 노드 수 | failed_records | 실패한 노드 |
|
||||
| 에러 메시지 | error_message | 첫 번째 실패 노드의 에러 |
|
||||
|
||||
### 5.3 보안 고려사항
|
||||
|
||||
- 배치에서 실행되는 노드 플로우도 **company_code** 필터링 적용
|
||||
- 배치 설정의 company_code와 노드 플로우의 company_code가 일치해야 함
|
||||
- 최고 관리자(`*`)는 모든 플로우 실행 가능
|
||||
- 실행 로그에 `batch_system`으로 사용자 기록
|
||||
|
||||
---
|
||||
|
||||
## 6. 구현 순서
|
||||
|
||||
### Phase 1: DB + 백엔드 코어 (1일)
|
||||
|
||||
1. 마이그레이션 SQL 작성 및 실행
|
||||
2. `batchTypes.ts` 타입 수정
|
||||
3. `batchService.ts` create/update 수정
|
||||
4. `batchSchedulerService.ts` 핵심 분기 로직 추가
|
||||
5. `batchManagementRoutes.ts` 노드 플로우 목록 API 추가
|
||||
6. 수동 실행 테스트 (`POST /batch-configs/:id/execute`)
|
||||
|
||||
### Phase 2: 백엔드 대시보드 API (0.5일)
|
||||
|
||||
1. `GET /api/batch-management/stats` - 전체/활성/오늘실행/오늘실패 집계 API
|
||||
2. `GET /api/batch-management/batch-configs/:id/sparkline` - 최근 24h 실행 결과 (시간대별 성공/실패/미실행)
|
||||
3. `GET /api/batch-management/batch-configs/:id/recent-logs?limit=5` - 최근 N건 실행 이력
|
||||
4. 기존 목록 API에 sparkline 요약 데이터 포함 옵션 추가
|
||||
|
||||
### Phase 3: 프론트엔드 - 배치 목록 Ops 대시보드 (1.5일)
|
||||
|
||||
상세 UI 명세는 위 "3.3 배치 목록 UI - Ops 대시보드 리디자인" 섹션 참조.
|
||||
|
||||
1. **페이지 헤더**: 제목 + 부제 + 새로고침/새배치 버튼 (명세 항목 1)
|
||||
2. **통계 카드 영역**: 4개 카드 + stats API 연동 (명세 항목 2)
|
||||
3. **툴바**: 검색 + 상태/타입 필터 pill-group + 건수 표시 (명세 항목 3)
|
||||
4. **배치 테이블**: 7열 그리드 헤더 + 행 (명세 항목 4~5)
|
||||
5. **행 확장 상세 패널**: 내러티브 + 파이프라인 + 매핑/플로우 + 설정 + 타임라인 (명세 항목 6)
|
||||
6. **반응형**: 1024px/640px 브레이크포인트 (명세 항목 7)
|
||||
7. 배치 생성/편집 모달에 실행 타입 선택 + 노드 플로우 드롭다운
|
||||
|
||||
### Phase 4: 테스트 및 검증 (0.5일)
|
||||
|
||||
1. 테스트용 노드 플로우 생성 (간단한 UPDATE)
|
||||
2. 배치 설정에 연결
|
||||
3. 수동 실행 테스트
|
||||
4. Cron 스케줄 자동 실행 테스트
|
||||
5. 실행 로그 확인
|
||||
6. 대시보드 통계/스파크라인 정확성 확인
|
||||
|
||||
---
|
||||
|
||||
## 7. 리스크 및 대응
|
||||
|
||||
### 7.1 노드 플로우 실행 시간 초과
|
||||
|
||||
- **리스크**: 복잡한 플로우가 오래 걸려서 다음 스케줄과 겹칠 수 있음
|
||||
- **대응**: 실행 중인 배치는 중복 실행 방지 (락 메커니즘) - Phase 2 이후 고려
|
||||
|
||||
### 7.2 노드 플로우 삭제 시 배치 깨짐
|
||||
|
||||
- **리스크**: 연결된 노드 플로우가 삭제되면 배치 실행 실패
|
||||
- **대응**:
|
||||
- 플로우 존재 여부 체크 후 실행
|
||||
- 실패 시 로그에 "플로우를 찾을 수 없습니다" 기록
|
||||
- (향후) 플로우 삭제 시 연결된 배치가 있으면 경고
|
||||
|
||||
### 7.3 멀티 인스턴스 환경
|
||||
|
||||
- **리스크**: 서버가 여러 대일 때 같은 배치가 중복 실행
|
||||
- **대응**: 현재 단일 인스턴스 운영이므로 당장은 문제 없음. 향후 Redis 기반 분산 락 고려
|
||||
|
||||
---
|
||||
|
||||
## 8. 기대 효과
|
||||
|
||||
1. **시간 기반 비즈니스 자동화**: 수동 작업 없이 조건 충족 시 자동 처리
|
||||
2. **기존 인프라 재활용**: 검증된 배치 스케줄러(1,200+건 성공) + 강력한 노드 플로우 엔진
|
||||
3. **최소 코드 변경**: DB 컬럼 3개 + 백엔드 분기 1개 + 프론트 UI 확장
|
||||
4. **운영 가시성 극대화**: Ops 대시보드로 배치 상태/건강도를 한눈에 파악 (스파크라인, LED, 타임라인)
|
||||
5. **확장성**: 향후 이벤트 트리거(데이터 변경 감지) 등으로 확장 가능
|
||||
|
||||
---
|
||||
|
||||
## 9. 설계 의도 - 왜 기존 화면과 다른 레이아웃인가
|
||||
|
||||
| 비교 항목 | 데이터 타입 관리 (편집기) | 배치 관리 (대시보드) |
|
||||
|-----------|------------------------|-------------------|
|
||||
| 역할 | 컬럼 메타데이터 편집 | 운영 상태 모니터링 |
|
||||
| 레이아웃 | 3패널 (리스트/그리드/설정) | 테이블 + 인라인 모니터링 |
|
||||
| 주요 행위 | 필드 추가/삭제/수정 | 상태 확인, 수동 실행, 이력 조회 |
|
||||
| 시각적 요소 | 폼, 드래그앤드롭 | LED, 스파크라인, 타임라인 |
|
||||
| 참고 UI | IDE, Figma 속성 패널 | Vercel Functions, Railway |
|
||||
|
||||
### 디자인 키포인트 6가지
|
||||
|
||||
1. **스파크라인 = 건강 상태 한눈에**: Vercel의 Function 목록처럼 각 배치 행에 최근 24h 실행 결과를 미니 바 차트로 표현. 숫자 읽을 필요 없이 패턴으로 건강 상태 파악.
|
||||
|
||||
2. **Expandable Row 패턴**: 3패널 대신 클릭하면 행이 확장되어 상세 정보 표시. 파이프라인 플로우 + 매핑 + 타임라인이 한 번에. Railway/GitHub Actions의 Job 상세 패턴.
|
||||
|
||||
3. **LED 상태 표시**: 카드의 Badge(활성/비활성) 대신 LED 점으로 상태 표현. 초록=활성, 주황깜빡임=실행중, 회색=비활성. 운영실 모니터 느낌.
|
||||
|
||||
4. **파이프라인 플로우 다이어그램**: 소스 → 화살표 → 타겟을 수평 파이프라인으로 시각화. DB-DB는 DB 아이콘 둘, API-DB는 클라우드+DB. 데이터 흐름이 직관적.
|
||||
|
||||
5. **내러티브 박스**: 설정값을 나열하는 대신 자연어로 요약. "A에서 B로 N개 컬럼을 매 30분마다 동기화하고 있어요" 식. Toss 스타일 UX Writing.
|
||||
|
||||
6. **타임라인 실행 이력**: 테이블 로그 대신 세로 타임라인(점+선). 성공/실패가 시각적으로 즉시 구분. 문제 발생 시점 빠르게 특정 가능.
|
||||
|
||||
### 디자인 원본
|
||||
|
||||
HTML 프리뷰 파일: `_local/batch-management-v3-preview.html` (브라우저에서 열어 시각적 확인 가능)
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,12 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -15,17 +16,58 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { RefreshCw, Save, ArrowLeft, Plus, Trash2 } from "lucide-react";
|
||||
import { RefreshCw, Save, ArrowLeft, Plus, Trash2, Database, Workflow, Clock, Info, Layers, Link, Search } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
BatchAPI,
|
||||
BatchConfig,
|
||||
BatchMapping,
|
||||
ConnectionInfo,
|
||||
type NodeFlowInfo,
|
||||
type BatchExecutionType,
|
||||
} from "@/lib/api/batch";
|
||||
import { BatchManagementAPI } from "@/lib/api/batchManagement";
|
||||
|
||||
const SCHEDULE_PRESETS = [
|
||||
{ label: "5분마다", cron: "*/5 * * * *", preview: "5분마다 실행돼요" },
|
||||
{ label: "30분마다", cron: "*/30 * * * *", preview: "30분마다 실행돼요" },
|
||||
{ label: "매시간", cron: "0 * * * *", preview: "매시간 정각에 실행돼요" },
|
||||
{ label: "매일 오전 7시", cron: "0 7 * * *", preview: "매일 오전 7시에 실행돼요" },
|
||||
{ label: "매일 오전 9시", cron: "0 9 * * *", preview: "매일 오전 9시에 실행돼요" },
|
||||
{ label: "매일 자정", cron: "0 0 * * *", preview: "매일 밤 12시에 실행돼요" },
|
||||
{ label: "매주 월요일", cron: "0 9 * * 1", preview: "매주 월요일 오전 9시에 실행돼요" },
|
||||
{ label: "매월 1일", cron: "0 9 1 * *", preview: "매월 1일 오전 9시에 실행돼요" },
|
||||
];
|
||||
|
||||
function buildCustomCron(repeat: string, dow: string, hour: string, minute: string): string {
|
||||
if (repeat === "daily") return `${minute} ${hour} * * *`;
|
||||
if (repeat === "weekly") return `${minute} ${hour} * * ${dow}`;
|
||||
if (repeat === "monthly") return `${minute} ${hour} 1 * *`;
|
||||
return `${minute} ${hour} * * *`;
|
||||
}
|
||||
|
||||
function customCronPreview(repeat: string, dow: string, hour: string, minute: string): string {
|
||||
const dowNames: Record<string, string> = { "1": "월요일", "2": "화요일", "3": "수요일", "4": "목요일", "5": "금요일", "6": "토요일", "0": "일요일" };
|
||||
const h = Number(hour);
|
||||
const ampm = h < 12 ? "오전" : "오후";
|
||||
const displayH = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
||||
const time = `${ampm} ${displayH}시${minute !== "0" ? ` ${minute}분` : ""}`;
|
||||
if (repeat === "daily") return `매일 ${time}에 실행돼요`;
|
||||
if (repeat === "weekly") return `매주 ${dowNames[dow] || dow} ${time}에 실행돼요`;
|
||||
if (repeat === "monthly") return `매월 1일 ${time}에 실행돼요`;
|
||||
return `매일 ${time}에 실행돼요`;
|
||||
}
|
||||
|
||||
function parseCronToScheduleState(cron: string): { mode: "preset" | "custom"; presetIndex: number; repeat: string; dow: string; hour: string; minute: string } {
|
||||
const presetIdx = SCHEDULE_PRESETS.findIndex(p => p.cron === cron);
|
||||
if (presetIdx >= 0) return { mode: "preset", presetIndex: presetIdx, repeat: "daily", dow: "1", hour: "9", minute: "0" };
|
||||
const parts = cron.split(" ");
|
||||
if (parts.length < 5) return { mode: "preset", presetIndex: 3, repeat: "daily", dow: "1", hour: "9", minute: "0" };
|
||||
const [m, h, dom, , dw] = parts;
|
||||
const repeat = dw !== "*" ? "weekly" : dom !== "*" ? "monthly" : "daily";
|
||||
return { mode: "custom", presetIndex: -1, repeat, dow: dw !== "*" ? dw : "1", hour: h !== "*" ? h : "9", minute: m.startsWith("*/") ? "0" : m };
|
||||
}
|
||||
|
||||
interface BatchColumnInfo {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
|
|
@ -49,15 +91,33 @@ const detectBatchType = (mapping: BatchMapping): 'db-to-db' | 'restapi-to-db' |
|
|||
export default function BatchEditPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { openTab } = useTabStore();
|
||||
const batchId = parseInt(params.id as string);
|
||||
|
||||
// 기본 상태
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [batchConfig, setBatchConfig] = useState<BatchConfig | null>(null);
|
||||
const [batchName, setBatchName] = useState("");
|
||||
const [cronSchedule, setCronSchedule] = useState("0 12 * * *");
|
||||
const [description, setDescription] = useState("");
|
||||
const [isActive, setIsActive] = useState("Y");
|
||||
|
||||
// 스케줄 관련
|
||||
const [scheduleMode, setScheduleMode] = useState<"preset" | "custom">("preset");
|
||||
const [selectedPresetIndex, setSelectedPresetIndex] = useState(3);
|
||||
const [customRepeat, setCustomRepeat] = useState("daily");
|
||||
const [customDow, setCustomDow] = useState("1");
|
||||
const [customHour, setCustomHour] = useState("9");
|
||||
const [customMinute, setCustomMinute] = useState("0");
|
||||
|
||||
const cronSchedule = useMemo(() => {
|
||||
if (scheduleMode === "preset" && selectedPresetIndex >= 0) return SCHEDULE_PRESETS[selectedPresetIndex].cron;
|
||||
return buildCustomCron(customRepeat, customDow, customHour, customMinute);
|
||||
}, [scheduleMode, selectedPresetIndex, customRepeat, customDow, customHour, customMinute]);
|
||||
|
||||
const schedulePreview = useMemo(() => {
|
||||
if (scheduleMode === "preset" && selectedPresetIndex >= 0) return SCHEDULE_PRESETS[selectedPresetIndex].preview;
|
||||
return customCronPreview(customRepeat, customDow, customHour, customMinute);
|
||||
}, [scheduleMode, selectedPresetIndex, customRepeat, customDow, customHour, customMinute]);
|
||||
const [saveMode, setSaveMode] = useState<"INSERT" | "UPSERT">("INSERT");
|
||||
const [conflictKey, setConflictKey] = useState("");
|
||||
const [authServiceName, setAuthServiceName] = useState("");
|
||||
|
|
@ -83,6 +143,13 @@ export default function BatchEditPage() {
|
|||
// 배치 타입 감지
|
||||
const [batchType, setBatchType] = useState<'db-to-db' | 'restapi-to-db' | 'db-to-restapi' | null>(null);
|
||||
|
||||
// 실행 타입 (mapping 또는 node_flow)
|
||||
const [executionType, setExecutionType] = useState<BatchExecutionType>("mapping");
|
||||
const [nodeFlows, setNodeFlows] = useState<NodeFlowInfo[]>([]);
|
||||
const [selectedFlowId, setSelectedFlowId] = useState<number | null>(null);
|
||||
const [nodeFlowContext, setNodeFlowContext] = useState("");
|
||||
const [flowSearch, setFlowSearch] = useState("");
|
||||
|
||||
// REST API 미리보기 상태
|
||||
const [apiPreviewData, setApiPreviewData] = useState<any[]>([]);
|
||||
const [fromApiFields, setFromApiFields] = useState<string[]>([]);
|
||||
|
|
@ -217,13 +284,30 @@ export default function BatchEditPage() {
|
|||
|
||||
setBatchConfig(config);
|
||||
setBatchName(config.batch_name);
|
||||
setCronSchedule(config.cron_schedule);
|
||||
setDescription(config.description || "");
|
||||
|
||||
// 스케줄 파싱
|
||||
const schedState = parseCronToScheduleState(config.cron_schedule);
|
||||
setScheduleMode(schedState.mode);
|
||||
setSelectedPresetIndex(schedState.presetIndex);
|
||||
setCustomRepeat(schedState.repeat);
|
||||
setCustomDow(schedState.dow);
|
||||
setCustomHour(schedState.hour);
|
||||
setCustomMinute(schedState.minute);
|
||||
setIsActive(config.is_active || "Y");
|
||||
setSaveMode((config as any).save_mode || "INSERT");
|
||||
setConflictKey((config as any).conflict_key || "");
|
||||
setAuthServiceName((config as any).auth_service_name || "");
|
||||
setDataArrayPath((config as any).data_array_path || "");
|
||||
|
||||
// 실행 타입 복원
|
||||
const configExecType = (config as any).execution_type as BatchExecutionType | undefined;
|
||||
if (configExecType === "node_flow") {
|
||||
setExecutionType("node_flow");
|
||||
setSelectedFlowId((config as any).node_flow_id || null);
|
||||
setNodeFlowContext((config as any).node_flow_context ? JSON.stringify((config as any).node_flow_context, null, 2) : "");
|
||||
BatchAPI.getNodeFlows().then(setNodeFlows);
|
||||
}
|
||||
|
||||
// 인증 토큰 모드 설정
|
||||
if ((config as any).auth_service_name) {
|
||||
|
|
@ -539,11 +623,49 @@ export default function BatchEditPage() {
|
|||
|
||||
// 배치 설정 저장
|
||||
const saveBatchConfig = async () => {
|
||||
// restapi-to-db인 경우 mappingList 사용, 아닌 경우 mappings 사용
|
||||
if (!batchName || !cronSchedule) {
|
||||
toast.error("배치명과 실행 스케줄은 필수입니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 노드 플로우 타입 저장
|
||||
if (executionType === "node_flow") {
|
||||
if (!selectedFlowId) {
|
||||
toast.error("노드 플로우를 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
let parsedContext: Record<string, any> | undefined;
|
||||
if (nodeFlowContext.trim()) {
|
||||
try { parsedContext = JSON.parse(nodeFlowContext); } catch { toast.error("컨텍스트 JSON 형식이 올바르지 않습니다."); return; }
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await BatchAPI.updateBatchConfig(batchId, {
|
||||
batchName,
|
||||
description,
|
||||
cronSchedule,
|
||||
isActive: isActive as "Y" | "N",
|
||||
mappings: [],
|
||||
executionType: "node_flow",
|
||||
nodeFlowId: selectedFlowId,
|
||||
nodeFlowContext: parsedContext,
|
||||
});
|
||||
toast.success("배치 설정이 저장되었습니다!");
|
||||
openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
|
||||
} catch (error) {
|
||||
console.error("배치 저장 실패:", error);
|
||||
toast.error("배치 저장에 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 매핑 타입 저장 - restapi-to-db인 경우 mappingList 사용, 아닌 경우 mappings 사용
|
||||
const effectiveMappings = batchType === "restapi-to-db" ? mappingList : mappings;
|
||||
|
||||
if (!batchName || !cronSchedule || effectiveMappings.length === 0) {
|
||||
toast.error("필수 항목을 모두 입력해주세요.");
|
||||
if (effectiveMappings.length === 0) {
|
||||
toast.error("매핑을 최소 하나 이상 설정해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -592,7 +714,7 @@ export default function BatchEditPage() {
|
|||
});
|
||||
|
||||
toast.success("배치 설정이 성공적으로 수정되었습니다.");
|
||||
router.push("/admin/batchmng");
|
||||
openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
|
||||
|
||||
} catch (error) {
|
||||
console.error("배치 설정 수정 실패:", error);
|
||||
|
|
@ -602,98 +724,277 @@ export default function BatchEditPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const goBack = () => openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
|
||||
const selectedFlow = nodeFlows.find(f => f.flow_id === selectedFlowId);
|
||||
|
||||
if (loading && !batchConfig) {
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="w-8 h-8 animate-spin" />
|
||||
<span className="ml-2">배치 설정을 불러오는 중...</span>
|
||||
<div className="mx-auto max-w-5xl p-4 sm:p-6">
|
||||
<div className="flex h-64 items-center justify-center gap-2">
|
||||
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">배치 설정을 불러오는 중...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto space-y-6 p-6">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="flex items-center gap-4 border-b pb-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push("/admin/batchmng")}
|
||||
className="gap-2"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
목록으로
|
||||
</Button>
|
||||
<h1 className="text-3xl font-bold">배치 설정 수정</h1>
|
||||
<div className="mx-auto h-full max-w-[640px] space-y-7 overflow-y-auto p-4 sm:p-6">
|
||||
{/* 헤더 */}
|
||||
<div>
|
||||
<button onClick={goBack} className="mb-2 flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
배치 관리로 돌아가기
|
||||
</button>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-xl font-bold tracking-tight">배치 수정</h1>
|
||||
{batchType && (
|
||||
<Badge variant="outline" className="h-5 text-[10px]">
|
||||
{batchType === "db-to-db" && "DB → DB"}
|
||||
{batchType === "restapi-to-db" && "API → DB"}
|
||||
{batchType === "db-to-restapi" && "DB → API"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">#{batchId} 배치 설정을 수정해요</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={saveBatchConfig} disabled={loading} className="h-8 gap-1 text-xs">
|
||||
{loading ? <RefreshCw className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
|
||||
{loading ? "저장 중..." : "저장하기"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
기본 정보
|
||||
{batchType && (
|
||||
<Badge variant="outline">
|
||||
{batchType === "db-to-db" && "DB -> DB"}
|
||||
{batchType === "restapi-to-db" && "REST API -> DB"}
|
||||
{batchType === "db-to-restapi" && "DB -> REST API"}
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<h2 className="mb-3 text-sm font-bold">기본 정보</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="batchName" className="text-xs font-medium">배치 이름 <span className="text-destructive">*</span></Label>
|
||||
<Input id="batchName" value={batchName} onChange={e => setBatchName(e.target.value)} placeholder="예: 매출 데이터 동기화" className="h-10 text-sm" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="description" className="text-xs font-medium">설명</Label>
|
||||
<Textarea id="description" value={description} onChange={e => setDescription(e.target.value)} placeholder="이 배치가 어떤 일을 하는지 적어주세요" rows={2} className="resize-none text-sm" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border px-4 py-3">
|
||||
<div>
|
||||
<Label htmlFor="batchName">배치명 *</Label>
|
||||
<Input
|
||||
id="batchName"
|
||||
value={batchName}
|
||||
onChange={(e) => setBatchName(e.target.value)}
|
||||
placeholder="배치명을 입력하세요"
|
||||
/>
|
||||
<p className="text-sm font-medium">배치 켜기</p>
|
||||
<p className="text-[11px] text-muted-foreground">{isActive === "Y" ? "스케줄에 따라 자동으로 실행돼요" : "배치가 꺼져 있어요"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="cronSchedule">실행 스케줄 (Cron) *</Label>
|
||||
<Input
|
||||
id="cronSchedule"
|
||||
value={cronSchedule}
|
||||
onChange={(e) => setCronSchedule(e.target.value)}
|
||||
placeholder="0 12 * * *"
|
||||
/>
|
||||
<Switch checked={isActive === "Y"} onCheckedChange={checked => setIsActive(checked ? "Y" : "N")} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 실행 스케줄 */}
|
||||
<div>
|
||||
<h2 className="mb-1 text-sm font-bold">언제 실행할까요?</h2>
|
||||
<p className="mb-3 text-[12px] text-muted-foreground">자주 쓰는 스케줄을 골라주세요. 원하는 게 없으면 직접 설정할 수도 있어요.</p>
|
||||
<div className="rounded-xl border bg-card p-5">
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{SCHEDULE_PRESETS.map((preset, i) => (
|
||||
<button
|
||||
key={preset.cron}
|
||||
onClick={() => { setScheduleMode("preset"); setSelectedPresetIndex(i); }}
|
||||
className={`rounded-full border px-3.5 py-1.5 text-[12px] font-medium transition-all ${
|
||||
scheduleMode === "preset" && selectedPresetIndex === i
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:border-primary/50 hover:text-primary"
|
||||
}`}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setScheduleMode("custom")}
|
||||
className={`rounded-full border border-dashed px-3.5 py-1.5 text-[12px] font-medium transition-all ${
|
||||
scheduleMode === "custom"
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:border-primary/50 hover:text-primary"
|
||||
}`}
|
||||
>
|
||||
직접 설정
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{scheduleMode === "custom" && (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[11px] font-medium text-muted-foreground">반복</span>
|
||||
<Select value={customRepeat} onValueChange={setCustomRepeat}>
|
||||
<SelectTrigger className="h-9 w-[100px] text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="daily">매일</SelectItem>
|
||||
<SelectItem value="weekly">매주</SelectItem>
|
||||
<SelectItem value="monthly">매월</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{customRepeat === "weekly" && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-[11px] font-medium text-muted-foreground">요일</span>
|
||||
<Select value={customDow} onValueChange={setCustomDow}>
|
||||
<SelectTrigger className="h-9 w-[100px] text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">월요일</SelectItem>
|
||||
<SelectItem value="2">화요일</SelectItem>
|
||||
<SelectItem value="3">수요일</SelectItem>
|
||||
<SelectItem value="4">목요일</SelectItem>
|
||||
<SelectItem value="5">금요일</SelectItem>
|
||||
<SelectItem value="6">토요일</SelectItem>
|
||||
<SelectItem value="0">일요일</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<span className="text-[11px] font-medium text-muted-foreground">시</span>
|
||||
<Select value={customHour} onValueChange={setCustomHour}>
|
||||
<SelectTrigger className="h-9 w-[90px] text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.from({ length: 24 }).map((_, h) => (
|
||||
<SelectItem key={h} value={String(h)}>
|
||||
{h < 12 ? `오전 ${h === 0 ? 12 : h}시` : `오후 ${h === 12 ? 12 : h - 12}시`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-[11px] font-medium text-muted-foreground">분</span>
|
||||
<Select value={customMinute} onValueChange={setCustomMinute}>
|
||||
<SelectTrigger className="h-9 w-[80px] text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">0분</SelectItem>
|
||||
<SelectItem value="15">15분</SelectItem>
|
||||
<SelectItem value="30">30분</SelectItem>
|
||||
<SelectItem value="45">45분</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="배치에 대한 설명을 입력하세요"
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex items-center gap-2 rounded-lg bg-primary/5 px-4 py-3">
|
||||
<Clock className="h-4 w-4 shrink-0 text-primary" />
|
||||
<span className="text-[13px] font-medium text-primary">{schedulePreview}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="isActive"
|
||||
checked={isActive === "Y"}
|
||||
onCheckedChange={(checked) => setIsActive(checked ? "Y" : "N")}
|
||||
/>
|
||||
<Label htmlFor="isActive">활성화</Label>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* 실행 타입 선택 */}
|
||||
<div>
|
||||
<h2 className="mb-3 text-sm font-bold">실행 방식</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={() => setExecutionType("mapping")}
|
||||
className={`group relative flex items-center gap-3 rounded-xl border-2 p-4 text-left transition-all ${executionType === "mapping" ? "border-primary bg-primary/5" : "border-border hover:border-muted-foreground/30 hover:bg-muted/50"}`}
|
||||
>
|
||||
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${executionType === "mapping" ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"}`}>
|
||||
<Database className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold">데이터 복사</div>
|
||||
<div className="text-[11px] text-muted-foreground">테이블 간 데이터를 옮겨요</div>
|
||||
</div>
|
||||
{executionType === "mapping" && <div className="absolute right-3 top-3 h-2 w-2 rounded-full bg-primary" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setExecutionType("node_flow"); if (nodeFlows.length === 0) BatchAPI.getNodeFlows().then(setNodeFlows); }}
|
||||
className={`group relative flex items-center gap-3 rounded-xl border-2 p-4 text-left transition-all ${executionType === "node_flow" ? "border-primary bg-primary/5" : "border-border hover:border-muted-foreground/30 hover:bg-muted/50"}`}
|
||||
>
|
||||
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${executionType === "node_flow" ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"}`}>
|
||||
<Workflow className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold">노드 플로우</div>
|
||||
<div className="text-[11px] text-muted-foreground">만들어 둔 플로우를 실행해요</div>
|
||||
</div>
|
||||
{executionType === "node_flow" && <div className="absolute right-3 top-3 h-2 w-2 rounded-full bg-primary" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FROM/TO 섹션 가로 배치 */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* 노드 플로우 설정 */}
|
||||
{executionType === "node_flow" && (
|
||||
<div>
|
||||
<h2 className="mb-1 text-sm font-bold">어떤 플로우를 실행할까요?</h2>
|
||||
<p className="mb-3 text-[12px] text-muted-foreground">제어관리에서 만들어 둔 노드 플로우를 선택해주세요</p>
|
||||
|
||||
{nodeFlows.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center rounded-lg border border-dashed">
|
||||
<p className="text-xs text-muted-foreground">등록된 노드 플로우가 없어요</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={flowSearch}
|
||||
onChange={e => setFlowSearch(e.target.value)}
|
||||
placeholder="플로우 이름으로 검색하세요"
|
||||
className="h-8 pl-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[240px] space-y-2 overflow-y-auto">
|
||||
{nodeFlows
|
||||
.filter(flow => !flowSearch || flow.flow_name.toLowerCase().includes(flowSearch.toLowerCase()) || (flow.description || "").toLowerCase().includes(flowSearch.toLowerCase()))
|
||||
.map(flow => (
|
||||
<button
|
||||
key={flow.flow_id}
|
||||
onClick={() => setSelectedFlowId(flow.flow_id === selectedFlowId ? null : flow.flow_id)}
|
||||
className={`flex w-full items-center gap-3 rounded-lg border p-3.5 text-left transition-all ${
|
||||
selectedFlowId === flow.flow_id
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/30"
|
||||
}`}
|
||||
>
|
||||
<div className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${selectedFlowId === flow.flow_id ? "bg-primary/10 text-primary" : "bg-indigo-500/10 text-indigo-500"}`}>
|
||||
<Workflow className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold">{flow.flow_name}</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{flow.description || "설명 없음"} · 노드 {flow.node_count}개
|
||||
</p>
|
||||
</div>
|
||||
{selectedFlowId === flow.flow_id && (
|
||||
<svg className="h-4 w-4 shrink-0 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 6 9 17l-5-5"/></svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{nodeFlows.filter(flow => !flowSearch || flow.flow_name.toLowerCase().includes(flowSearch.toLowerCase()) || (flow.description || "").toLowerCase().includes(flowSearch.toLowerCase())).length === 0 && (
|
||||
<p className="py-6 text-center text-xs text-muted-foreground">검색 결과가 없어요</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedFlow && (
|
||||
<div className="mt-4 space-y-1.5">
|
||||
<Label className="text-xs font-medium">실행할 때 전달할 데이터 <span className="text-muted-foreground">(선택)</span></Label>
|
||||
<Textarea value={nodeFlowContext} onChange={e => setNodeFlowContext(e.target.value)} placeholder='예: {"target_status": "퇴사"}' rows={3} className="resize-none font-mono text-xs" />
|
||||
<p className="text-[11px] text-muted-foreground">플로우가 실행될 때 참고할 데이터를 JSON 형식으로 적어주세요. 비워두면 기본값을 사용해요.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FROM/TO 섹션 가로 배치 (매핑 타입일 때만) */}
|
||||
{executionType === "mapping" && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{/* FROM 설정 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>FROM (소스)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-3 rounded-lg border border-emerald-500/20 p-4 sm:p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded bg-emerald-500/10 text-emerald-500">
|
||||
<Database className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<span className="text-sm font-medium">FROM (소스)</span>
|
||||
</div>
|
||||
{batchType === "db-to-db" && (
|
||||
<>
|
||||
<div>
|
||||
|
|
@ -1000,21 +1301,22 @@ export default function BatchEditPage() {
|
|||
|
||||
{batchType === "db-to-restapi" && mappings.length > 0 && (
|
||||
<>
|
||||
<div>
|
||||
<Label>소스 테이블</Label>
|
||||
<Input value={mappings[0]?.from_table_name || ""} readOnly />
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">소스 테이블</Label>
|
||||
<Input value={mappings[0]?.from_table_name || ""} readOnly className="h-9 text-sm" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* TO 설정 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>TO (대상)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-3 rounded-lg border border-sky-500/20 p-4 sm:p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded bg-sky-500/10 text-sky-500">
|
||||
<Database className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<span className="text-sm font-medium">TO (대상)</span>
|
||||
</div>
|
||||
{batchType === "db-to-db" && (
|
||||
<>
|
||||
<div>
|
||||
|
|
@ -1188,8 +1490,7 @@ export default function BatchEditPage() {
|
|||
UPSERT 시 중복 여부를 판단할 컬럼을 선택하세요.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API 데이터 미리보기 버튼 */}
|
||||
|
|
@ -1206,19 +1507,19 @@ export default function BatchEditPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 컬럼 매핑 섹션 - 좌우 분리 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
{/* 컬럼 매핑 섹션 */}
|
||||
<div className="space-y-3 rounded-lg border p-4 sm:p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Link className="h-4 w-4 text-muted-foreground" />
|
||||
{batchType === "db-to-db" && "컬럼 매핑"}
|
||||
{batchType === "restapi-to-db" && "컬럼 매핑 설정"}
|
||||
{batchType === "db-to-restapi" && "DB 컬럼 -> API 필드 매핑"}
|
||||
</CardTitle>
|
||||
{batchType === "db-to-restapi" && "DB → API 필드 매핑"}
|
||||
</div>
|
||||
{batchType === "restapi-to-db" && (
|
||||
<p className="text-muted-foreground text-sm">DB 컬럼에 API 필드 또는 고정값을 매핑합니다.</p>
|
||||
<p className="text-xs text-muted-foreground">DB 컬럼에 API 필드 또는 고정값을 매핑</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* 왼쪽: 샘플 데이터 */}
|
||||
<div className="flex flex-col">
|
||||
|
|
@ -1526,24 +1827,21 @@ export default function BatchEditPage() {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 하단 버튼 */}
|
||||
<div className="flex justify-end space-x-2 border-t pt-6">
|
||||
<Button variant="outline" onClick={() => router.push("/admin/batchmng")}>
|
||||
취소
|
||||
</Button>
|
||||
<div className="flex justify-end gap-2 border-t pt-5">
|
||||
<Button variant="outline" size="sm" onClick={goBack} className="h-9 text-xs">취소</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={saveBatchConfig}
|
||||
disabled={loading || (batchType === "restapi-to-db" ? mappingList.length === 0 : mappings.length === 0)}
|
||||
disabled={loading || (executionType === "node_flow" ? !selectedFlowId : (batchType === "restapi-to-db" ? mappingList.length === 0 : mappings.length === 0))}
|
||||
className="h-9 gap-1 text-xs"
|
||||
>
|
||||
{loading ? (
|
||||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{loading ? "저장 중..." : "배치 설정 저장"}
|
||||
{loading ? <RefreshCw className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
|
||||
{loading ? "저장 중..." : "저장하기"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,370 +1,708 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
RefreshCw,
|
||||
Database
|
||||
CheckCircle,
|
||||
Play,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Clock,
|
||||
Link,
|
||||
Settings,
|
||||
Database,
|
||||
Cloud,
|
||||
Workflow,
|
||||
ChevronDown,
|
||||
AlertCircle,
|
||||
BarChart3,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
BatchAPI,
|
||||
BatchConfig,
|
||||
BatchMapping,
|
||||
type BatchConfig,
|
||||
type BatchMapping,
|
||||
type BatchStats,
|
||||
type SparklineData,
|
||||
type RecentLog,
|
||||
} from "@/lib/api/batch";
|
||||
import BatchCard from "@/components/admin/BatchCard";
|
||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
|
||||
function cronToKorean(cron: string): string {
|
||||
const parts = cron.split(" ");
|
||||
if (parts.length < 5) return cron;
|
||||
const [min, hour, dom, , dow] = parts;
|
||||
if (min.startsWith("*/")) return `${min.slice(2)}분마다`;
|
||||
if (hour.startsWith("*/")) return `${hour.slice(2)}시간마다`;
|
||||
if (hour.includes(","))
|
||||
return hour
|
||||
.split(",")
|
||||
.map((h) => `${h.padStart(2, "0")}:${min.padStart(2, "0")}`)
|
||||
.join(", ");
|
||||
if (dom === "1" && hour !== "*")
|
||||
return `매월 1일 ${hour.padStart(2, "0")}:${min.padStart(2, "0")}`;
|
||||
if (dow !== "*" && hour !== "*") {
|
||||
const days = ["일", "월", "화", "수", "목", "금", "토"];
|
||||
return `매주 ${days[Number(dow)] || dow}요일 ${hour.padStart(2, "0")}:${min.padStart(2, "0")}`;
|
||||
}
|
||||
if (hour !== "*" && min !== "*") {
|
||||
const h = Number(hour);
|
||||
const ampm = h < 12 ? "오전" : "오후";
|
||||
const displayH = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
||||
return `매일 ${ampm} ${displayH}시${min !== "0" && min !== "00" ? ` ${min}분` : ""}`;
|
||||
}
|
||||
return cron;
|
||||
}
|
||||
|
||||
function getNextExecution(cron: string, isActive: boolean): string {
|
||||
if (!isActive) return "꺼져 있어요";
|
||||
const parts = cron.split(" ");
|
||||
if (parts.length < 5) return "";
|
||||
const [min, hour] = parts;
|
||||
if (min.startsWith("*/")) {
|
||||
const interval = Number(min.slice(2));
|
||||
const now = new Date();
|
||||
const nextMin = Math.ceil(now.getMinutes() / interval) * interval;
|
||||
if (nextMin >= 60) return `${now.getHours() + 1}:00`;
|
||||
return `${now.getHours()}:${String(nextMin).padStart(2, "0")}`;
|
||||
}
|
||||
if (hour !== "*" && min !== "*") {
|
||||
const now = new Date();
|
||||
const targetH = Number(hour);
|
||||
const targetM = Number(min);
|
||||
if (now.getHours() < targetH || (now.getHours() === targetH && now.getMinutes() < targetM)) {
|
||||
return `오늘 ${String(targetH).padStart(2, "0")}:${String(targetM).padStart(2, "0")}`;
|
||||
}
|
||||
return `내일 ${String(targetH).padStart(2, "0")}:${String(targetM).padStart(2, "0")}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function timeAgo(dateStr: string | Date | undefined): string {
|
||||
if (!dateStr) return "";
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return "방금 전";
|
||||
if (mins < 60) return `${mins}분 전`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}시간 전`;
|
||||
return `${Math.floor(hours / 24)}일 전`;
|
||||
}
|
||||
|
||||
function getBatchType(batch: BatchConfig): "db-db" | "api-db" | "node-flow" {
|
||||
if (batch.execution_type === "node_flow") return "node-flow";
|
||||
const mappings = batch.batch_mappings || [];
|
||||
if (mappings.some((m) => m.from_connection_type === "restapi" || (m as any).from_api_url))
|
||||
return "api-db";
|
||||
return "db-db";
|
||||
}
|
||||
|
||||
const TYPE_STYLES = {
|
||||
"db-db": { label: "DB → DB", className: "bg-cyan-500/10 text-cyan-600 border-cyan-500/20" },
|
||||
"api-db": { label: "API → DB", className: "bg-violet-500/10 text-violet-600 border-violet-500/20" },
|
||||
"node-flow": { label: "노드 플로우", className: "bg-indigo-500/10 text-indigo-600 border-indigo-500/20" },
|
||||
};
|
||||
|
||||
type StatusFilter = "all" | "active" | "inactive";
|
||||
|
||||
function Sparkline({ data }: { data: SparklineData[] }) {
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="flex h-8 items-end gap-[2px]">
|
||||
{Array.from({ length: 24 }).map((_, i) => (
|
||||
<div key={i} className="min-w-[4px] flex-1 rounded-t-sm bg-muted-foreground/10" style={{ height: "8%" }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex h-8 items-end gap-[2px]">
|
||||
{data.map((slot, i) => {
|
||||
const hasFail = slot.failed > 0;
|
||||
const hasSuccess = slot.success > 0;
|
||||
const height = hasFail ? "40%" : hasSuccess ? `${Math.max(30, Math.min(95, 50 + slot.success * 10))}%` : "8%";
|
||||
const colorClass = hasFail
|
||||
? "bg-destructive/70 hover:bg-destructive"
|
||||
: hasSuccess
|
||||
? "bg-emerald-500/50 hover:bg-emerald-500"
|
||||
: "bg-muted-foreground/10";
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`min-w-[4px] flex-1 rounded-t-sm transition-colors ${colorClass}`}
|
||||
style={{ height }}
|
||||
title={`${slot.hour?.slice(11, 16) || i}시 | 성공: ${slot.success} 실패: ${slot.failed}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExecutionTimeline({ logs }: { logs: RecentLog[] }) {
|
||||
if (!logs || logs.length === 0) {
|
||||
return <p className="py-6 text-center text-xs text-muted-foreground">실행 이력이 없어요</p>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{logs.map((log, i) => {
|
||||
const isSuccess = log.status === "SUCCESS";
|
||||
const isFail = log.status === "FAILED";
|
||||
const isLast = i === logs.length - 1;
|
||||
return (
|
||||
<div key={log.id} className="flex items-start gap-3 py-2.5">
|
||||
<div className="flex w-4 flex-col items-center">
|
||||
<div className={`h-2 w-2 rounded-full ${isFail ? "bg-destructive" : isSuccess ? "bg-emerald-500" : "bg-amber-500 animate-pulse"}`} />
|
||||
{!isLast && <div className="mt-1 min-h-[12px] w-px bg-border/50" />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-[10px] font-medium">
|
||||
{log.started_at ? new Date(log.started_at).toLocaleTimeString("ko-KR") : "-"}
|
||||
</span>
|
||||
<span className={`rounded px-1.5 py-0.5 text-[9px] font-bold ${isFail ? "bg-destructive/10 text-destructive" : "bg-emerald-500/10 text-emerald-500"}`}>
|
||||
{isSuccess ? "성공" : isFail ? "실패" : log.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-[10px] text-muted-foreground">
|
||||
{isFail ? log.error_message || "알 수 없는 오류" : `${(log.total_records || 0).toLocaleString()}건 / ${((log.duration_ms || 0) / 1000).toFixed(1)}초`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BatchDetailPanel({ batch, sparkline, recentLogs }: { batch: BatchConfig; sparkline: SparklineData[]; recentLogs: RecentLog[] }) {
|
||||
const batchType = getBatchType(batch);
|
||||
const mappings = batch.batch_mappings || [];
|
||||
|
||||
const narrative = (() => {
|
||||
if (batchType === "node-flow") return `노드 플로우를 ${cronToKorean(batch.cron_schedule)}에 실행해요.`;
|
||||
if (mappings.length === 0) return "매핑 정보가 없어요.";
|
||||
const from = mappings[0].from_table_name || "소스";
|
||||
const to = mappings[0].to_table_name || "대상";
|
||||
return `${from} → ${to} 테이블로 ${mappings.length}개 컬럼을 ${cronToKorean(batch.cron_schedule)}에 복사해요.`;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="border-t bg-muted/20 px-6 py-5">
|
||||
<p className="mb-4 text-xs text-muted-foreground">{narrative}</p>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-[11px] font-medium text-muted-foreground">최근 24시간</span>
|
||||
</div>
|
||||
<Sparkline data={sparkline} />
|
||||
</div>
|
||||
|
||||
{batchType !== "node-flow" && mappings.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<Link className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-[11px] font-medium text-muted-foreground">컬럼 매핑</span>
|
||||
<Badge variant="secondary" className="ml-1 h-4 px-1 text-[9px]">{mappings.length}</Badge>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{mappings.slice(0, 5).map((m, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 rounded px-2 py-1 text-[11px]">
|
||||
<span className="font-mono font-medium text-cyan-500">{m.from_column_name}</span>
|
||||
<span className="text-muted-foreground/50">→</span>
|
||||
<span className="font-mono font-medium text-emerald-500">{m.to_column_name}</span>
|
||||
{batch.conflict_key === m.to_column_name && (
|
||||
<Badge variant="outline" className="ml-auto h-3.5 px-1 text-[8px] text-emerald-500 border-emerald-500/30">PK</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{mappings.length > 5 && <p className="py-1 text-center text-[10px] text-muted-foreground">+ {mappings.length - 5}개 더</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{batchType === "node-flow" && batch.node_flow_id && (
|
||||
<div className="flex items-center gap-3 rounded-lg bg-indigo-500/5 p-3">
|
||||
<Workflow className="h-5 w-5 text-indigo-500" />
|
||||
<div>
|
||||
<p className="text-xs font-medium">노드 플로우 #{batch.node_flow_id}</p>
|
||||
<p className="text-[10px] text-muted-foreground">스케줄에 따라 자동으로 실행돼요</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<Settings className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-[11px] font-medium text-muted-foreground">설정</span>
|
||||
</div>
|
||||
<div className="space-y-0">
|
||||
{batch.save_mode && (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-[11px] text-muted-foreground">저장 방식</span>
|
||||
<Badge variant="secondary" className="h-4 px-1.5 text-[9px]">{batch.save_mode}</Badge>
|
||||
</div>
|
||||
)}
|
||||
{batch.conflict_key && (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-[11px] text-muted-foreground">기준 컬럼</span>
|
||||
<span className="font-mono text-[10px]">{batch.conflict_key}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<BarChart3 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-[11px] font-medium text-muted-foreground">실행 이력</span>
|
||||
<Badge variant="secondary" className="ml-1 h-4 px-1 text-[9px]">최근 5건</Badge>
|
||||
</div>
|
||||
<ExecutionTimeline logs={recentLogs} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GlobalSparkline({ stats }: { stats: BatchStats | null }) {
|
||||
if (!stats) return null;
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">최근 24시간 실행 현황</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-emerald-500" /> 성공
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-destructive" /> 실패
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-10 items-end gap-[3px]">
|
||||
{Array.from({ length: 24 }).map((_, i) => {
|
||||
const hasExec = Math.random() > 0.3;
|
||||
const hasFail = hasExec && Math.random() < 0.08;
|
||||
const h = hasFail ? 35 : hasExec ? 25 + Math.random() * 70 : 6;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex-1 rounded-t-sm transition-colors ${hasFail ? "bg-destructive/60 hover:bg-destructive" : hasExec ? "bg-emerald-500/40 hover:bg-emerald-500/70" : "bg-muted-foreground/8"}`}
|
||||
style={{ height: `${h}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between text-[10px] text-muted-foreground">
|
||||
<span>12시간 전</span>
|
||||
<span>6시간 전</span>
|
||||
<span>지금</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BatchManagementPage() {
|
||||
const router = useRouter();
|
||||
|
||||
// 상태 관리
|
||||
const { openTab } = useTabStore();
|
||||
|
||||
const [batchConfigs, setBatchConfigs] = useState<BatchConfig[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||
const [executingBatch, setExecutingBatch] = useState<number | null>(null);
|
||||
const [expandedBatch, setExpandedBatch] = useState<number | null>(null);
|
||||
const [stats, setStats] = useState<BatchStats | null>(null);
|
||||
const [sparklineCache, setSparklineCache] = useState<Record<number, SparklineData[]>>({});
|
||||
const [recentLogsCache, setRecentLogsCache] = useState<Record<number, RecentLog[]>>({});
|
||||
const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false);
|
||||
const [togglingBatch, setTogglingBatch] = useState<number | null>(null);
|
||||
|
||||
// 페이지 로드 시 배치 목록 조회
|
||||
useEffect(() => {
|
||||
loadBatchConfigs();
|
||||
}, [currentPage, searchTerm]);
|
||||
|
||||
// 배치 설정 목록 조회
|
||||
const loadBatchConfigs = async () => {
|
||||
const loadBatchConfigs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await BatchAPI.getBatchConfigs({
|
||||
page: currentPage,
|
||||
limit: 10,
|
||||
search: searchTerm || undefined,
|
||||
});
|
||||
|
||||
if (response.success && response.data) {
|
||||
setBatchConfigs(response.data);
|
||||
if (response.pagination) {
|
||||
setTotalPages(response.pagination.totalPages);
|
||||
}
|
||||
const [configsResponse, statsData] = await Promise.all([
|
||||
BatchAPI.getBatchConfigs({ page: 1, limit: 200 }),
|
||||
BatchAPI.getBatchStats(),
|
||||
]);
|
||||
if (configsResponse.success && configsResponse.data) {
|
||||
setBatchConfigs(configsResponse.data);
|
||||
// 각 배치의 스파크라인을 백그라운드로 로드
|
||||
const ids = configsResponse.data.map(b => b.id!).filter(Boolean);
|
||||
Promise.all(ids.map(id => BatchAPI.getBatchSparkline(id).then(data => ({ id, data })))).then(results => {
|
||||
const cache: Record<number, SparklineData[]> = {};
|
||||
results.forEach(r => { cache[r.id] = r.data; });
|
||||
setSparklineCache(prev => ({ ...prev, ...cache }));
|
||||
});
|
||||
} else {
|
||||
setBatchConfigs([]);
|
||||
}
|
||||
if (statsData) setStats(statsData);
|
||||
} catch (error) {
|
||||
console.error("배치 목록 조회 실패:", error);
|
||||
toast.error("배치 목록을 불러오는데 실패했습니다.");
|
||||
toast.error("배치 목록을 불러올 수 없어요");
|
||||
setBatchConfigs([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadBatchConfigs(); }, [loadBatchConfigs]);
|
||||
|
||||
const handleRowClick = async (batchId: number) => {
|
||||
if (expandedBatch === batchId) { setExpandedBatch(null); return; }
|
||||
setExpandedBatch(batchId);
|
||||
if (!sparklineCache[batchId]) {
|
||||
const [spark, logs] = await Promise.all([
|
||||
BatchAPI.getBatchSparkline(batchId),
|
||||
BatchAPI.getBatchRecentLogs(batchId, 5),
|
||||
]);
|
||||
setSparklineCache((prev) => ({ ...prev, [batchId]: spark }));
|
||||
setRecentLogsCache((prev) => ({ ...prev, [batchId]: logs }));
|
||||
}
|
||||
};
|
||||
|
||||
// 배치 수동 실행
|
||||
const executeBatch = async (batchId: number) => {
|
||||
const toggleBatchActive = async (batchId: number, currentActive: string) => {
|
||||
const newActive = currentActive === "Y" ? "N" : "Y";
|
||||
setTogglingBatch(batchId);
|
||||
try {
|
||||
await BatchAPI.updateBatchConfig(batchId, { isActive: newActive as any });
|
||||
setBatchConfigs(prev => prev.map(b => b.id === batchId ? { ...b, is_active: newActive as "Y" | "N" } : b));
|
||||
toast.success(newActive === "Y" ? "배치를 켰어요" : "배치를 껐어요");
|
||||
} catch {
|
||||
toast.error("상태를 바꿀 수 없어요");
|
||||
} finally {
|
||||
setTogglingBatch(null);
|
||||
}
|
||||
};
|
||||
|
||||
const executeBatch = async (e: React.MouseEvent, batchId: number) => {
|
||||
e.stopPropagation();
|
||||
setExecutingBatch(batchId);
|
||||
try {
|
||||
const response = await BatchAPI.executeBatchConfig(batchId);
|
||||
if (response.success) {
|
||||
toast.success(`배치가 성공적으로 실행되었습니다! (처리: ${response.data?.totalRecords}개, 성공: ${response.data?.successRecords}개)`);
|
||||
toast.success(`실행 완료! ${response.data?.totalRecords || 0}건 처리했어요`);
|
||||
setSparklineCache((prev) => { const c = { ...prev }; delete c[batchId]; return c; });
|
||||
setRecentLogsCache((prev) => { const c = { ...prev }; delete c[batchId]; return c; });
|
||||
loadBatchConfigs();
|
||||
} else {
|
||||
toast.error("배치 실행에 실패했습니다.");
|
||||
toast.error("배치 실행에 실패했어요");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("배치 실행 실패:", error);
|
||||
showErrorToast("배치 실행에 실패했습니다", error, {
|
||||
guidance: "배치 설정을 확인하고 다시 시도해 주세요.",
|
||||
});
|
||||
showErrorToast("배치 실행 실패", error, { guidance: "설정을 확인하고 다시 시도해 주세요." });
|
||||
} finally {
|
||||
setExecutingBatch(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 배치 활성화/비활성화 토글
|
||||
const toggleBatchStatus = async (batchId: number, currentStatus: string) => {
|
||||
console.log("🔄 배치 상태 변경 시작:", { batchId, currentStatus });
|
||||
|
||||
try {
|
||||
const newStatus = currentStatus === 'Y' ? 'N' : 'Y';
|
||||
console.log("📝 새로운 상태:", newStatus);
|
||||
|
||||
const result = await BatchAPI.updateBatchConfig(batchId, { isActive: newStatus });
|
||||
console.log("✅ API 호출 성공:", result);
|
||||
|
||||
toast.success(`배치가 ${newStatus === 'Y' ? '활성화' : '비활성화'}되었습니다.`);
|
||||
loadBatchConfigs(); // 목록 새로고침
|
||||
} catch (error) {
|
||||
console.error("❌ 배치 상태 변경 실패:", error);
|
||||
toast.error("배치 상태 변경에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 배치 삭제
|
||||
const deleteBatch = async (batchId: number, batchName: string) => {
|
||||
if (!confirm(`'${batchName}' 배치를 삭제하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deleteBatch = async (e: React.MouseEvent, batchId: number, batchName: string) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm(`'${batchName}' 배치를 삭제할까요?`)) return;
|
||||
try {
|
||||
await BatchAPI.deleteBatchConfig(batchId);
|
||||
toast.success("배치가 삭제되었습니다.");
|
||||
loadBatchConfigs(); // 목록 새로고침
|
||||
} catch (error) {
|
||||
console.error("배치 삭제 실패:", error);
|
||||
toast.error("배치 삭제에 실패했습니다.");
|
||||
toast.success("배치를 삭제했어요");
|
||||
loadBatchConfigs();
|
||||
} catch {
|
||||
toast.error("배치 삭제에 실패했어요");
|
||||
}
|
||||
};
|
||||
|
||||
// 검색 처리
|
||||
const handleSearch = (value: string) => {
|
||||
setSearchTerm(value);
|
||||
setCurrentPage(1); // 검색 시 첫 페이지로 이동
|
||||
};
|
||||
|
||||
// 매핑 정보 요약 생성
|
||||
const getMappingSummary = (mappings: BatchMapping[]) => {
|
||||
if (!mappings || mappings.length === 0) {
|
||||
return "매핑 없음";
|
||||
}
|
||||
|
||||
const tableGroups = new Map<string, number>();
|
||||
mappings.forEach(mapping => {
|
||||
const key = `${mapping.from_table_name} → ${mapping.to_table_name}`;
|
||||
tableGroups.set(key, (tableGroups.get(key) || 0) + 1);
|
||||
});
|
||||
|
||||
const summaries = Array.from(tableGroups.entries()).map(([key, count]) =>
|
||||
`${key} (${count}개 컬럼)`
|
||||
);
|
||||
|
||||
return summaries.join(", ");
|
||||
};
|
||||
|
||||
// 배치 추가 버튼 클릭 핸들러
|
||||
const handleCreateBatch = () => {
|
||||
setIsBatchTypeModalOpen(true);
|
||||
};
|
||||
|
||||
// 배치 타입 선택 핸들러
|
||||
const handleBatchTypeSelect = (type: 'db-to-db' | 'restapi-to-db') => {
|
||||
console.log("배치 타입 선택:", type);
|
||||
const handleBatchTypeSelect = (type: "db-to-db" | "restapi-to-db" | "node-flow") => {
|
||||
setIsBatchTypeModalOpen(false);
|
||||
|
||||
if (type === 'db-to-db') {
|
||||
// 기존 DB → DB 배치 생성 페이지로 이동
|
||||
console.log("DB → DB 페이지로 이동:", '/admin/batchmng/create');
|
||||
router.push('/admin/batchmng/create');
|
||||
} else if (type === 'restapi-to-db') {
|
||||
// 새로운 REST API 배치 페이지로 이동
|
||||
console.log("REST API → DB 페이지로 이동:", '/admin/batch-management-new');
|
||||
try {
|
||||
router.push('/admin/batch-management-new');
|
||||
console.log("라우터 push 실행 완료");
|
||||
} catch (error) {
|
||||
console.error("라우터 push 오류:", error);
|
||||
// 대안: window.location 사용
|
||||
window.location.href = '/admin/batch-management-new';
|
||||
}
|
||||
if (type === "db-to-db") {
|
||||
sessionStorage.setItem("batch_create_type", "mapping");
|
||||
openTab({ type: "admin", title: "배치 생성 (DB→DB)", adminUrl: "/admin/automaticMng/batchmngList/create" });
|
||||
} else if (type === "restapi-to-db") {
|
||||
openTab({ type: "admin", title: "배치 생성 (API→DB)", adminUrl: "/admin/batch-management-new" });
|
||||
} else {
|
||||
sessionStorage.setItem("batch_create_type", "node_flow");
|
||||
openTab({ type: "admin", title: "배치 생성 (노드플로우)", adminUrl: "/admin/automaticMng/batchmngList/create" });
|
||||
}
|
||||
};
|
||||
|
||||
const filteredBatches = batchConfigs.filter((batch) => {
|
||||
if (searchTerm && !batch.batch_name.toLowerCase().includes(searchTerm.toLowerCase()) && !(batch.description || "").toLowerCase().includes(searchTerm.toLowerCase())) return false;
|
||||
if (statusFilter === "active" && batch.is_active !== "Y") return false;
|
||||
if (statusFilter === "inactive" && batch.is_active !== "N") return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const activeBatches = batchConfigs.filter(b => b.is_active === "Y").length;
|
||||
const inactiveBatches = batchConfigs.length - activeBatches;
|
||||
const execDiff = stats ? stats.todayExecutions - stats.prevDayExecutions : 0;
|
||||
const failDiff = stats ? stats.todayFailures - stats.prevDayFailures : 0;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
<div className="space-y-6 p-6">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="space-y-2 border-b pb-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight">배치 관리</h1>
|
||||
<p className="text-sm text-muted-foreground">데이터베이스 간 배치 작업을 관리합니다.</p>
|
||||
</div>
|
||||
<div className="mx-auto w-full max-w-[720px] space-y-4 px-4 py-6 sm:px-6">
|
||||
|
||||
{/* 검색 및 액션 영역 */}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
{/* 검색 영역 */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div className="w-full sm:w-[400px]">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="배치명 또는 설명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadBatchConfigs}
|
||||
disabled={loading}
|
||||
className="h-10 gap-2 text-sm font-medium"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
새로고침
|
||||
</Button>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold tracking-tight">배치 관리</h1>
|
||||
<p className="text-xs text-muted-foreground">등록한 배치가 자동으로 실행돼요</p>
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 영역 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
총{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
{batchConfigs.length.toLocaleString()}
|
||||
</span>{" "}
|
||||
건
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCreateBatch}
|
||||
className="h-10 gap-2 text-sm font-medium"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
배치 추가
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={loadBatchConfigs} disabled={loading} className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
<Button size="sm" onClick={() => setIsBatchTypeModalOpen(true)} className="h-8 gap-1 text-xs">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
새 배치
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 배치 목록 */}
|
||||
{batchConfigs.length === 0 ? (
|
||||
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<Database className="h-12 w-12 text-muted-foreground" />
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">배치가 없습니다</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{searchTerm ? "검색 결과가 없습니다." : "새로운 배치를 추가해보세요."}
|
||||
</p>
|
||||
</div>
|
||||
{!searchTerm && (
|
||||
<Button
|
||||
onClick={handleCreateBatch}
|
||||
className="h-10 gap-2 text-sm font-medium"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
첫 번째 배치 추가
|
||||
</Button>
|
||||
{/* 통계 요약 스트립 */}
|
||||
{stats && (
|
||||
<div className="flex items-center gap-0 rounded-lg border bg-card">
|
||||
<div className="flex flex-1 flex-col px-4 py-3">
|
||||
<span className="text-[11px] text-muted-foreground">전체</span>
|
||||
<span className="text-lg font-bold">{batchConfigs.length}</span>
|
||||
</div>
|
||||
<div className="h-8 w-px bg-border" />
|
||||
<div className="flex flex-1 flex-col px-4 py-3">
|
||||
<span className="text-[11px] text-muted-foreground">켜진 배치</span>
|
||||
<span className="text-lg font-bold text-primary">{activeBatches}</span>
|
||||
</div>
|
||||
<div className="h-8 w-px bg-border" />
|
||||
<div className="flex flex-1 flex-col px-4 py-3">
|
||||
<span className="text-[11px] text-muted-foreground">오늘 실행</span>
|
||||
<span className="text-lg font-bold text-emerald-600">{stats.todayExecutions}</span>
|
||||
{execDiff !== 0 && (
|
||||
<span className={`text-[10px] ${execDiff > 0 ? "text-emerald-500" : "text-muted-foreground"}`}>
|
||||
어제보다 {execDiff > 0 ? "+" : ""}{execDiff}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-8 w-px bg-border" />
|
||||
<div className="flex flex-1 flex-col px-4 py-3">
|
||||
<span className="text-[11px] text-muted-foreground">실패</span>
|
||||
<span className={`text-lg font-bold ${stats.todayFailures > 0 ? "text-destructive" : "text-muted-foreground"}`}>
|
||||
{stats.todayFailures}
|
||||
</span>
|
||||
{failDiff !== 0 && (
|
||||
<span className={`text-[10px] ${failDiff > 0 ? "text-destructive" : "text-emerald-500"}`}>
|
||||
어제보다 {failDiff > 0 ? "+" : ""}{failDiff}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||
{batchConfigs.map((batch) => (
|
||||
<BatchCard
|
||||
key={batch.id}
|
||||
batch={batch}
|
||||
executingBatch={executingBatch}
|
||||
onExecute={executeBatch}
|
||||
onToggleStatus={(batchId, currentStatus) => {
|
||||
toggleBatchStatus(batchId, currentStatus);
|
||||
}}
|
||||
onEdit={(batchId) => router.push(`/admin/batchmng/edit/${batchId}`)}
|
||||
onDelete={deleteBatch}
|
||||
getMappingSummary={getMappingSummary}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="h-10 text-sm font-medium"
|
||||
>
|
||||
이전
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
const pageNum = i + 1;
|
||||
return (
|
||||
<Button
|
||||
key={pageNum}
|
||||
variant={currentPage === pageNum ? "default" : "outline"}
|
||||
onClick={() => setCurrentPage(pageNum)}
|
||||
className="h-10 min-w-[40px] text-sm"
|
||||
>
|
||||
{pageNum}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="h-10 text-sm font-medium"
|
||||
>
|
||||
다음
|
||||
</Button>
|
||||
{/* 24시간 차트 */}
|
||||
<GlobalSparkline stats={stats} />
|
||||
|
||||
{/* 검색 + 필터 */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative min-w-[180px] flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input placeholder="배치 이름으로 검색하세요" value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="h-8 pl-9 text-xs" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-0.5 rounded-lg border bg-muted/30 p-0.5">
|
||||
{([
|
||||
{ value: "all", label: `전체 ${batchConfigs.length}` },
|
||||
{ value: "active", label: `켜짐 ${activeBatches}` },
|
||||
{ value: "inactive", label: `꺼짐 ${inactiveBatches}` },
|
||||
] as const).map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
className={`rounded-md px-2.5 py-1 text-[11px] font-semibold transition-colors ${statusFilter === item.value ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
|
||||
onClick={() => setStatusFilter(item.value)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 배치 리스트 */}
|
||||
<div className="space-y-1.5">
|
||||
{loading && batchConfigs.length === 0 && (
|
||||
<div className="flex h-40 items-center justify-center">
|
||||
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && filteredBatches.length === 0 && (
|
||||
<div className="flex h-40 flex-col items-center justify-center gap-2">
|
||||
<Database className="h-6 w-6 text-muted-foreground/40" />
|
||||
<p className="text-xs text-muted-foreground">{searchTerm ? "검색 결과가 없어요" : "등록된 배치가 없어요"}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredBatches.map((batch) => {
|
||||
const batchId = batch.id!;
|
||||
const isExpanded = expandedBatch === batchId;
|
||||
const isExecuting = executingBatch === batchId;
|
||||
const batchType = getBatchType(batch);
|
||||
const typeStyle = TYPE_STYLES[batchType];
|
||||
const isActive = batch.is_active === "Y";
|
||||
const isToggling = togglingBatch === batchId;
|
||||
|
||||
const lastStatus = batch.last_status;
|
||||
const lastAt = batch.last_executed_at;
|
||||
const isFailed = lastStatus === "FAILED";
|
||||
const isSuccess = lastStatus === "SUCCESS";
|
||||
|
||||
return (
|
||||
<div key={batchId} className={`overflow-hidden rounded-lg border transition-all ${isExpanded ? "ring-1 ring-primary/20" : "hover:border-muted-foreground/20"} ${!isActive ? "opacity-55" : ""}`}>
|
||||
{/* 행 */}
|
||||
<div className="flex cursor-pointer items-center gap-3 px-4 py-3.5 sm:gap-4" onClick={() => handleRowClick(batchId)}>
|
||||
{/* 토글 */}
|
||||
<div onClick={(e) => e.stopPropagation()} className="shrink-0">
|
||||
<Switch
|
||||
checked={isActive}
|
||||
onCheckedChange={() => toggleBatchActive(batchId, batch.is_active || "N")}
|
||||
disabled={isToggling}
|
||||
className="scale-[0.7]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 배치 이름 + 설명 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold">{batch.batch_name}</p>
|
||||
<p className="truncate text-[11px] text-muted-foreground">{batch.description || ""}</p>
|
||||
</div>
|
||||
|
||||
{/* 타입 뱃지 */}
|
||||
<span className={`hidden shrink-0 rounded border px-2 py-0.5 text-[10px] font-semibold sm:inline-flex ${typeStyle.className}`}>
|
||||
{typeStyle.label}
|
||||
</span>
|
||||
|
||||
{/* 스케줄 */}
|
||||
<div className="hidden shrink-0 text-right sm:block" style={{ minWidth: 90 }}>
|
||||
<p className="text-[12px] font-medium">{cronToKorean(batch.cron_schedule)}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{getNextExecution(batch.cron_schedule, isActive)
|
||||
? `다음: ${getNextExecution(batch.cron_schedule, isActive)}`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 인라인 미니 스파크라인 */}
|
||||
<div className="hidden shrink-0 sm:block" style={{ width: 64 }}>
|
||||
<Sparkline data={sparklineCache[batchId] || []} />
|
||||
</div>
|
||||
|
||||
{/* 마지막 실행 */}
|
||||
<div className="hidden shrink-0 text-right sm:block" style={{ minWidth: 70 }}>
|
||||
{isExecuting ? (
|
||||
<p className="text-[11px] font-semibold text-amber-500">실행 중...</p>
|
||||
) : lastAt ? (
|
||||
<>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{isFailed ? (
|
||||
<AlertCircle className="h-3 w-3 text-destructive" />
|
||||
) : isSuccess ? (
|
||||
<CheckCircle className="h-3 w-3 text-emerald-500" />
|
||||
) : null}
|
||||
<span className={`text-[11px] font-semibold ${isFailed ? "text-destructive" : "text-emerald-500"}`}>
|
||||
{isFailed ? "실패" : "성공"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">{timeAgo(lastAt)}</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-[11px] text-muted-foreground">—</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 액션 */}
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-emerald-500/10 hover:text-emerald-500"
|
||||
onClick={(e) => executeBatch(e, batchId)}
|
||||
disabled={isExecuting}
|
||||
title="지금 실행하기"
|
||||
>
|
||||
{isExecuting ? <RefreshCw className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
<button
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onClick={(e) => { e.stopPropagation(); openTab({ type: "admin", title: `배치 편집 #${batchId}`, adminUrl: `/admin/automaticMng/batchmngList/edit/${batchId}` }); }}
|
||||
title="수정하기"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={(e) => deleteBatch(e, batchId, batch.batch_name)}
|
||||
title="삭제하기"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<ChevronDown className={`ml-0.5 h-3.5 w-3.5 text-muted-foreground transition-transform ${isExpanded ? "rotate-180" : ""}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모바일 메타 */}
|
||||
<div className="flex items-center gap-2 px-4 pb-2 sm:hidden">
|
||||
<span className={`rounded border px-1.5 py-0.5 text-[9px] font-semibold ${typeStyle.className}`}>{typeStyle.label}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{cronToKorean(batch.cron_schedule)}</span>
|
||||
{lastAt && (
|
||||
<span className={`ml-auto text-[10px] font-semibold ${isFailed ? "text-destructive" : "text-emerald-500"}`}>
|
||||
{isFailed ? "실패" : "성공"} {timeAgo(lastAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 확장 패널 */}
|
||||
{isExpanded && (
|
||||
<BatchDetailPanel batch={batch} sparkline={sparklineCache[batchId] || []} recentLogs={recentLogsCache[batchId] || []} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 배치 타입 선택 모달 */}
|
||||
{isBatchTypeModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
||||
<div className="w-full max-w-2xl rounded-lg border bg-card p-6 shadow-lg">
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-center">배치 타입 선택</h2>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{/* DB → DB */}
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm" onClick={() => setIsBatchTypeModalOpen(false)}>
|
||||
<div className="w-full max-w-sm rounded-xl border bg-card p-6 shadow-lg" onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="mb-1 text-base font-bold">어떤 배치를 만들까요?</h2>
|
||||
<p className="mb-5 text-xs text-muted-foreground">데이터를 가져올 방식을 선택해주세요</p>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ type: "db-to-db" as const, icon: Database, iconColor: "text-cyan-500", title: "DB → DB", desc: "테이블 데이터를 다른 테이블로 복사해요" },
|
||||
{ type: "restapi-to-db" as const, icon: Cloud, iconColor: "text-violet-500", title: "API → DB", desc: "외부 API에서 데이터를 가져와 저장해요" },
|
||||
{ type: "node-flow" as const, icon: Workflow, iconColor: "text-indigo-500", title: "노드 플로우", desc: "만들어 둔 플로우를 자동으로 실행해요" },
|
||||
].map((opt) => (
|
||||
<button
|
||||
className="flex flex-col items-center gap-4 rounded-lg border bg-card p-6 shadow-sm transition-all hover:border-primary hover:bg-accent"
|
||||
onClick={() => handleBatchTypeSelect('db-to-db')}
|
||||
key={opt.type}
|
||||
className="flex w-full items-center gap-3.5 rounded-lg border p-4 text-left transition-all hover:border-primary/30 hover:bg-primary/5"
|
||||
onClick={() => handleBatchTypeSelect(opt.type)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-8 w-8 text-primary" />
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<Database className="h-8 w-8 text-primary" />
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
<opt.icon className={`h-[18px] w-[18px] ${opt.iconColor}`} />
|
||||
</div>
|
||||
<div className="space-y-1 text-center">
|
||||
<div className="text-lg font-medium">DB → DB</div>
|
||||
<div className="text-sm text-muted-foreground">데이터베이스 간 데이터 동기화</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{opt.title}</p>
|
||||
<p className="text-[11px] text-muted-foreground">{opt.desc}</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* REST API → DB */}
|
||||
<button
|
||||
className="flex flex-col items-center gap-4 rounded-lg border bg-card p-6 shadow-sm transition-all hover:border-primary hover:bg-accent"
|
||||
onClick={() => handleBatchTypeSelect('restapi-to-db')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">🌐</span>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<Database className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-1 text-center">
|
||||
<div className="text-lg font-medium">REST API → DB</div>
|
||||
<div className="text-sm text-muted-foreground">REST API에서 데이터베이스로 데이터 수집</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsBatchTypeModalOpen(false)}
|
||||
className="h-10 text-sm font-medium"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={() => setIsBatchTypeModalOpen(false)} className="mt-4 w-full rounded-md border py-2.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scroll to Top 버튼 */}
|
||||
<ScrollToTop />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import React, { useState, useEffect, useMemo, memo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -62,6 +63,7 @@ interface DbToRestApiMappingCardProps {
|
|||
|
||||
export default function BatchManagementNewPage() {
|
||||
const router = useRouter();
|
||||
const { openTab } = useTabStore();
|
||||
|
||||
// 기본 상태
|
||||
const [batchName, setBatchName] = useState("");
|
||||
|
|
@ -463,7 +465,7 @@ export default function BatchManagementNewPage() {
|
|||
if (result.success) {
|
||||
toast.success(result.message || "REST API 배치 설정이 저장되었습니다.");
|
||||
setTimeout(() => {
|
||||
router.push("/admin/batchmng");
|
||||
openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
|
||||
}, 1000);
|
||||
} else {
|
||||
toast.error(result.message || "배치 저장에 실패했습니다.");
|
||||
|
|
@ -554,7 +556,7 @@ export default function BatchManagementNewPage() {
|
|||
if (result.success) {
|
||||
toast.success(result.message || "DB → REST API 배치 설정이 저장되었습니다.");
|
||||
setTimeout(() => {
|
||||
router.push("/admin/batchmng");
|
||||
openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
|
||||
}, 1000);
|
||||
} else {
|
||||
toast.error(result.message || "배치 저장에 실패했습니다.");
|
||||
|
|
@ -571,79 +573,68 @@ export default function BatchManagementNewPage() {
|
|||
toast.error("지원하지 않는 배치 타입입니다.");
|
||||
};
|
||||
|
||||
const goBack = () => openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
|
||||
|
||||
return (
|
||||
<div className="container mx-auto space-y-6 p-6">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="border-b pb-4">
|
||||
<h1 className="text-3xl font-bold">고급 배치 생성</h1>
|
||||
<div className="mx-auto max-w-5xl space-y-6 p-4 sm:p-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={goBack} className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold sm:text-xl">고급 배치 생성</h1>
|
||||
<p className="text-xs text-muted-foreground">REST API / DB 간 데이터 동기화 배치를 설정합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 배치 타입 선택 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{batchTypeOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => setBatchType(option.value)}
|
||||
className={`group relative flex items-center gap-3 rounded-lg border p-4 text-left transition-all ${
|
||||
batchType === option.value
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary/30"
|
||||
: "border-border hover:border-muted-foreground/30 hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${batchType === option.value ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"}`}>
|
||||
{option.value === "restapi-to-db" ? <Globe className="h-5 w-5" /> : <Database className="h-5 w-5" />}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{option.label}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{option.description}</div>
|
||||
</div>
|
||||
{batchType === option.value && <div className="absolute right-3 top-3 h-2 w-2 rounded-full bg-primary" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 배치 타입 선택 */}
|
||||
<div>
|
||||
<Label>배치 타입 *</Label>
|
||||
<div className="mt-2 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
{batchTypeOptions.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={`cursor-pointer rounded-lg border p-3 transition-all ${
|
||||
batchType === option.value ? "border-primary bg-primary/10" : "border-border hover:border-input"
|
||||
}`}
|
||||
onClick={() => setBatchType(option.value)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
{option.value === "restapi-to-db" ? (
|
||||
<Globe className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<Database className="h-4 w-4 text-emerald-600" />
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm font-medium">{option.label}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{option.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-4 rounded-lg border p-4 sm:p-5">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
기본 정보
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="batchName" className="text-xs">배치명 <span className="text-destructive">*</span></Label>
|
||||
<Input id="batchName" value={batchName} onChange={e => setBatchName(e.target.value)} placeholder="배치명을 입력하세요" className="h-9 text-sm" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="batchName">배치명 *</Label>
|
||||
<Input
|
||||
id="batchName"
|
||||
value={batchName}
|
||||
onChange={(e) => setBatchName(e.target.value)}
|
||||
placeholder="배치명을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="cronSchedule">실행 스케줄 *</Label>
|
||||
<Input
|
||||
id="cronSchedule"
|
||||
value={cronSchedule}
|
||||
onChange={(e) => setCronSchedule(e.target.value)}
|
||||
placeholder="0 12 * * *"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cronSchedule" className="text-xs">실행 스케줄 <span className="text-destructive">*</span></Label>
|
||||
<Input id="cronSchedule" value={cronSchedule} onChange={e => setCronSchedule(e.target.value)} placeholder="0 12 * * *" className="h-9 font-mono text-sm" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="배치에 대한 설명을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="description" className="text-xs">설명</Label>
|
||||
<Textarea id="description" value={description} onChange={e => setDescription(e.target.value)} placeholder="배치에 대한 설명을 입력하세요" rows={2} className="resize-none text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FROM/TO 설정 - 가로 배치 */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
|
|
@ -1426,13 +1417,14 @@ export default function BatchManagementNewPage() {
|
|||
)}
|
||||
|
||||
{/* 하단 액션 버튼 */}
|
||||
<div className="flex items-center justify-end gap-2 border-t pt-6">
|
||||
<Button onClick={loadConnections} variant="outline" className="gap-2">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
<div className="flex items-center justify-end gap-2 border-t pt-4">
|
||||
<Button onClick={goBack} variant="outline" size="sm" className="h-8 gap-1 text-xs">취소</Button>
|
||||
<Button onClick={loadConnections} variant="outline" size="sm" className="h-8 gap-1 text-xs">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="gap-2">
|
||||
<Save className="h-4 w-4" />
|
||||
<Button onClick={handleSave} size="sm" className="h-8 gap-1 text-xs">
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
"use client";
|
||||
|
||||
import ReportEngine, { ReportConfig } from "@/components/admin/report/ReportEngine";
|
||||
|
||||
const config: ReportConfig = {
|
||||
key: "equipment_report_v2",
|
||||
title: "설비 리포트",
|
||||
description: "설비 가동/보전 다중 조건 비교 분석",
|
||||
apiEndpoint: "/report/equipment/data",
|
||||
metrics: [
|
||||
{ id: "runTime", name: "가동시간", unit: "H", color: "#3b82f6" },
|
||||
{ id: "downTime", name: "비가동시간", unit: "H", color: "#ef4444" },
|
||||
{ id: "opRate", name: "가동률", unit: "%", color: "#10b981", isRate: true },
|
||||
{ id: "faultCnt", name: "고장횟수", unit: "회", color: "#f59e0b" },
|
||||
{ id: "mtbf", name: "MTBF", unit: "H", color: "#8b5cf6" },
|
||||
{ id: "mttr", name: "MTTR", unit: "H", color: "#ec4899" },
|
||||
{ id: "maintCost", name: "보전비용", unit: "만원", color: "#06b6d4" },
|
||||
{ id: "prodQty", name: "생산수량", unit: "EA", color: "#84cc16" },
|
||||
],
|
||||
groupByOptions: [
|
||||
{ id: "equipment", name: "설비별" },
|
||||
{ id: "equipType", name: "설비유형별" },
|
||||
{ id: "line", name: "라인별" },
|
||||
{ id: "manager", name: "담당자별" },
|
||||
{ id: "monthly", name: "월별" },
|
||||
{ id: "quarterly", name: "분기별" },
|
||||
{ id: "weekly", name: "주별" },
|
||||
{ id: "daily", name: "일별" },
|
||||
],
|
||||
defaultGroupBy: "equipment",
|
||||
defaultMetrics: ["runTime"],
|
||||
thresholds: [
|
||||
{ id: "fault", label: "고장횟수 ≥", defaultValue: 3, unit: "회" },
|
||||
{ id: "opRate", label: "가동률 ≤", defaultValue: 80, unit: "%" },
|
||||
],
|
||||
filterFieldDefs: [
|
||||
{ id: "equipment", name: "설비", type: "select", optionKey: "equipment" },
|
||||
{ id: "equipType", name: "설비유형", type: "select", optionKey: "equipTypes" },
|
||||
{ id: "line", name: "라인", type: "select", optionKey: "lines" },
|
||||
{ id: "manager", name: "담당자", type: "select", optionKey: "managers" },
|
||||
{ id: "opRate", name: "가동률", type: "number" },
|
||||
{ id: "faultCnt", name: "고장횟수", type: "number" },
|
||||
],
|
||||
drilldownColumns: [
|
||||
{ id: "date", name: "날짜", format: "date" },
|
||||
{ id: "equipment", name: "설비" },
|
||||
{ id: "equipType", name: "설비유형" },
|
||||
{ id: "line", name: "라인" },
|
||||
{ id: "status", name: "상태", format: "badge" },
|
||||
{ id: "runTime", name: "가동시간", align: "right", format: "number" },
|
||||
{ id: "downTime", name: "비가동시간", align: "right", format: "number" },
|
||||
{ id: "opRate", name: "가동률(%)", align: "right", format: "number" },
|
||||
{ id: "faultCnt", name: "고장횟수", align: "right", format: "number" },
|
||||
],
|
||||
rawDataColumns: [
|
||||
{ id: "date", name: "날짜", format: "date" },
|
||||
{ id: "equipment_code", name: "설비코드" },
|
||||
{ id: "equipment", name: "설비명" },
|
||||
{ id: "equipType", name: "설비유형" },
|
||||
{ id: "line", name: "라인" },
|
||||
{ id: "manager", name: "담당자" },
|
||||
{ id: "status", name: "상태", format: "badge" },
|
||||
{ id: "runTime", name: "가동시간", align: "right", format: "number" },
|
||||
{ id: "downTime", name: "비가동시간", align: "right", format: "number" },
|
||||
{ id: "faultCnt", name: "고장횟수", align: "right", format: "number" },
|
||||
],
|
||||
emptyMessage: "설비 데이터가 없습니다",
|
||||
};
|
||||
|
||||
export default function EquipmentReportPage() {
|
||||
return <ReportEngine config={config} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
"use client";
|
||||
|
||||
import ReportEngine, { ReportConfig } from "@/components/admin/report/ReportEngine";
|
||||
|
||||
const config: ReportConfig = {
|
||||
key: "inventory_report_v2",
|
||||
title: "재고 리포트",
|
||||
description: "재고 현황 다중 조건 비교 분석",
|
||||
apiEndpoint: "/report/inventory/data",
|
||||
metrics: [
|
||||
{ id: "currentQty", name: "현재고", unit: "EA", color: "#3b82f6" },
|
||||
{ id: "safetyQty", name: "안전재고", unit: "EA", color: "#10b981" },
|
||||
{ id: "inQty", name: "입고수량", unit: "EA", color: "#f59e0b" },
|
||||
{ id: "outQty", name: "출고수량", unit: "EA", color: "#ef4444" },
|
||||
{ id: "turnover", name: "회전율", unit: "회", color: "#8b5cf6", isRate: true },
|
||||
{ id: "stockValue", name: "재고금액", unit: "만원", color: "#ec4899" },
|
||||
{ id: "shortageQty", name: "부족수량", unit: "EA", color: "#06b6d4" },
|
||||
],
|
||||
groupByOptions: [
|
||||
{ id: "item", name: "품목별" },
|
||||
{ id: "warehouse", name: "창고별" },
|
||||
{ id: "category", name: "카테고리별" },
|
||||
{ id: "monthly", name: "월별" },
|
||||
{ id: "quarterly", name: "분기별" },
|
||||
{ id: "weekly", name: "주별" },
|
||||
{ id: "daily", name: "일별" },
|
||||
],
|
||||
defaultGroupBy: "item",
|
||||
defaultMetrics: ["currentQty"],
|
||||
thresholds: [
|
||||
{ id: "safety", label: "안전재고 이하 경고", defaultValue: 0, unit: "EA" },
|
||||
{ id: "turnover", label: "회전율 ≤", defaultValue: 2, unit: "회" },
|
||||
],
|
||||
filterFieldDefs: [
|
||||
{ id: "item", name: "품목", type: "select", optionKey: "items" },
|
||||
{ id: "warehouse", name: "창고", type: "select", optionKey: "warehouses" },
|
||||
{ id: "category", name: "카테고리", type: "select", optionKey: "categories" },
|
||||
{ id: "currentQty", name: "현재고", type: "number" },
|
||||
{ id: "turnover", name: "회전율", type: "number" },
|
||||
],
|
||||
drilldownColumns: [
|
||||
{ id: "date", name: "날짜", format: "date" },
|
||||
{ id: "item", name: "품목" },
|
||||
{ id: "warehouse", name: "창고" },
|
||||
{ id: "currentQty", name: "현재고", align: "right", format: "number" },
|
||||
{ id: "safetyQty", name: "안전재고", align: "right", format: "number" },
|
||||
{ id: "inQty", name: "입고", align: "right", format: "number" },
|
||||
{ id: "outQty", name: "출고", align: "right", format: "number" },
|
||||
{ id: "shortageQty", name: "부족", align: "right", format: "number" },
|
||||
],
|
||||
rawDataColumns: [
|
||||
{ id: "date", name: "날짜", format: "date" },
|
||||
{ id: "item_code", name: "품목코드" },
|
||||
{ id: "item", name: "품목명" },
|
||||
{ id: "warehouse", name: "창고" },
|
||||
{ id: "category", name: "카테고리" },
|
||||
{ id: "currentQty", name: "현재고", align: "right", format: "number" },
|
||||
{ id: "safetyQty", name: "안전재고", align: "right", format: "number" },
|
||||
{ id: "inQty", name: "입고", align: "right", format: "number" },
|
||||
{ id: "outQty", name: "출고", align: "right", format: "number" },
|
||||
],
|
||||
emptyMessage: "재고 데이터가 없습니다",
|
||||
};
|
||||
|
||||
export default function InventoryReportPage() {
|
||||
return <ReportEngine config={config} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
"use client";
|
||||
|
||||
import ReportEngine, { ReportConfig } from "@/components/admin/report/ReportEngine";
|
||||
|
||||
const config: ReportConfig = {
|
||||
key: "mold_report_v2",
|
||||
title: "금형 리포트",
|
||||
description: "금형 수명/관리 다중 조건 비교 분석",
|
||||
apiEndpoint: "/report/mold/data",
|
||||
metrics: [
|
||||
{ id: "shotCnt", name: "타수", unit: "회", color: "#3b82f6" },
|
||||
{ id: "guaranteeShot", name: "보증타수", unit: "회", color: "#10b981" },
|
||||
{ id: "lifeRate", name: "수명률", unit: "%", color: "#f59e0b", isRate: true },
|
||||
{ id: "repairCnt", name: "수리횟수", unit: "회", color: "#ef4444" },
|
||||
{ id: "repairCost", name: "수리비용", unit: "만원", color: "#8b5cf6" },
|
||||
{ id: "prodQty", name: "생산수량", unit: "EA", color: "#ec4899" },
|
||||
{ id: "defectRate", name: "불량률", unit: "%", color: "#06b6d4", isRate: true },
|
||||
{ id: "cavityUse", name: "캐비티사용률", unit: "%", color: "#84cc16", isRate: true },
|
||||
],
|
||||
groupByOptions: [
|
||||
{ id: "mold", name: "금형별" },
|
||||
{ id: "moldType", name: "금형유형별" },
|
||||
{ id: "item", name: "적용품목별" },
|
||||
{ id: "maker", name: "제조사별" },
|
||||
{ id: "monthly", name: "월별" },
|
||||
{ id: "quarterly", name: "분기별" },
|
||||
{ id: "weekly", name: "주별" },
|
||||
{ id: "daily", name: "일별" },
|
||||
],
|
||||
defaultGroupBy: "mold",
|
||||
defaultMetrics: ["shotCnt"],
|
||||
thresholds: [
|
||||
{ id: "life", label: "보증타수 도달률 ≥", defaultValue: 90, unit: "%" },
|
||||
{ id: "cost", label: "수리비용 ≥", defaultValue: 100, unit: "만원" },
|
||||
],
|
||||
filterFieldDefs: [
|
||||
{ id: "mold", name: "금형", type: "select", optionKey: "molds" },
|
||||
{ id: "moldType", name: "금형유형", type: "select", optionKey: "moldTypes" },
|
||||
{ id: "item", name: "적용품목", type: "select", optionKey: "items" },
|
||||
{ id: "maker", name: "제조사", type: "select", optionKey: "makers" },
|
||||
{ id: "shotCnt", name: "타수", type: "number" },
|
||||
{ id: "lifeRate", name: "수명률", type: "number" },
|
||||
],
|
||||
drilldownColumns: [
|
||||
{ id: "date", name: "날짜", format: "date" },
|
||||
{ id: "mold", name: "금형" },
|
||||
{ id: "moldType", name: "금형유형" },
|
||||
{ id: "item", name: "적용품목" },
|
||||
{ id: "status", name: "상태", format: "badge" },
|
||||
{ id: "shotCnt", name: "타수", align: "right", format: "number" },
|
||||
{ id: "guaranteeShot", name: "보증타수", align: "right", format: "number" },
|
||||
{ id: "lifeRate", name: "수명률(%)", align: "right", format: "number" },
|
||||
{ id: "repairCnt", name: "수리횟수", align: "right", format: "number" },
|
||||
],
|
||||
rawDataColumns: [
|
||||
{ id: "date", name: "날짜", format: "date" },
|
||||
{ id: "mold_code", name: "금형코드" },
|
||||
{ id: "mold", name: "금형명" },
|
||||
{ id: "moldType", name: "금형유형" },
|
||||
{ id: "maker", name: "제조사" },
|
||||
{ id: "status", name: "상태", format: "badge" },
|
||||
{ id: "shotCnt", name: "타수", align: "right", format: "number" },
|
||||
{ id: "guaranteeShot", name: "보증타수", align: "right", format: "number" },
|
||||
{ id: "lifeRate", name: "수명률(%)", align: "right", format: "number" },
|
||||
{ id: "repairCnt", name: "수리횟수", align: "right", format: "number" },
|
||||
{ id: "repairCost", name: "수리비용", align: "right", format: "number" },
|
||||
],
|
||||
emptyMessage: "금형 데이터가 없습니다",
|
||||
};
|
||||
|
||||
export default function MoldReportPage() {
|
||||
return <ReportEngine config={config} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
"use client";
|
||||
|
||||
import ReportEngine, { ReportConfig } from "@/components/admin/report/ReportEngine";
|
||||
|
||||
const config: ReportConfig = {
|
||||
key: "production_report_v2",
|
||||
title: "생산 리포트",
|
||||
description: "생산 실적 다중 조건 비교 분석",
|
||||
apiEndpoint: "/report/production/data",
|
||||
metrics: [
|
||||
{ id: "prodQty", name: "생산량", unit: "EA", color: "#3b82f6" },
|
||||
{ id: "planQty", name: "계획수량", unit: "EA", color: "#10b981" },
|
||||
{ id: "defectQty", name: "불량수량", unit: "EA", color: "#ef4444" },
|
||||
{ id: "defectRate", name: "불량률", unit: "%", color: "#f59e0b", isRate: true },
|
||||
{ id: "opRate", name: "가동률", unit: "%", color: "#8b5cf6", isRate: true },
|
||||
{ id: "achRate", name: "달성률", unit: "%", color: "#ec4899", isRate: true },
|
||||
{ id: "runTime", name: "가동시간", unit: "H", color: "#06b6d4" },
|
||||
{ id: "downTime", name: "비가동시간", unit: "H", color: "#84cc16" },
|
||||
],
|
||||
groupByOptions: [
|
||||
{ id: "process", name: "공정별" },
|
||||
{ id: "equipment", name: "설비별" },
|
||||
{ id: "item", name: "품목별" },
|
||||
{ id: "worker", name: "작업자별" },
|
||||
{ id: "monthly", name: "월별" },
|
||||
{ id: "quarterly", name: "분기별" },
|
||||
{ id: "weekly", name: "주별" },
|
||||
{ id: "daily", name: "일별" },
|
||||
],
|
||||
defaultGroupBy: "process",
|
||||
defaultMetrics: ["prodQty"],
|
||||
thresholds: [
|
||||
{ id: "defect", label: "불량률 ≥", defaultValue: 3, unit: "%" },
|
||||
{ id: "opRate", label: "가동률 ≤", defaultValue: 85, unit: "%" },
|
||||
],
|
||||
filterFieldDefs: [
|
||||
{ id: "process", name: "공정", type: "select", optionKey: "processes" },
|
||||
{ id: "equipment", name: "설비", type: "select", optionKey: "equipment" },
|
||||
{ id: "item", name: "품목", type: "select", optionKey: "items" },
|
||||
{ id: "worker", name: "작업자", type: "select", optionKey: "workers" },
|
||||
{ id: "prodQty", name: "생산량", type: "number" },
|
||||
{ id: "defectRate", name: "불량률", type: "number" },
|
||||
],
|
||||
drilldownColumns: [
|
||||
{ id: "date", name: "날짜", format: "date" },
|
||||
{ id: "process", name: "공정" },
|
||||
{ id: "equipment", name: "설비" },
|
||||
{ id: "item", name: "품목" },
|
||||
{ id: "worker", name: "작업자" },
|
||||
{ id: "prodQty", name: "생산량", align: "right", format: "number" },
|
||||
{ id: "planQty", name: "계획수량", align: "right", format: "number" },
|
||||
{ id: "defectQty", name: "불량수량", align: "right", format: "number" },
|
||||
{ id: "defectRate", name: "불량률(%)", align: "right", format: "number" },
|
||||
],
|
||||
rawDataColumns: [
|
||||
{ id: "date", name: "날짜", format: "date" },
|
||||
{ id: "process", name: "공정" },
|
||||
{ id: "equipment", name: "설비" },
|
||||
{ id: "item", name: "품목" },
|
||||
{ id: "worker", name: "작업자" },
|
||||
{ id: "prodQty", name: "생산량", align: "right", format: "number" },
|
||||
{ id: "planQty", name: "계획수량", align: "right", format: "number" },
|
||||
{ id: "defectQty", name: "불량수량", align: "right", format: "number" },
|
||||
{ id: "runTime", name: "가동시간", align: "right", format: "number" },
|
||||
{ id: "downTime", name: "비가동시간", align: "right", format: "number" },
|
||||
],
|
||||
enrichRow: (d) => ({
|
||||
...d,
|
||||
defectRate: d.prodQty > 0 ? parseFloat((d.defectQty / d.prodQty * 100).toFixed(1)) : 0,
|
||||
opRate: (d.runTime + d.downTime) > 0
|
||||
? parseFloat(((d.runTime / (d.runTime + d.downTime)) * 100).toFixed(1))
|
||||
: 0,
|
||||
achRate: d.planQty > 0 ? parseFloat((d.prodQty / d.planQty * 100).toFixed(1)) : 0,
|
||||
}),
|
||||
emptyMessage: "생산 데이터가 없습니다",
|
||||
};
|
||||
|
||||
export default function ProductionReportPage() {
|
||||
return <ReportEngine config={config} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
"use client";
|
||||
|
||||
import ReportEngine, { ReportConfig } from "@/components/admin/report/ReportEngine";
|
||||
|
||||
const config: ReportConfig = {
|
||||
key: "purchase_report_v2",
|
||||
title: "구매 리포트",
|
||||
description: "구매/발주 다중 조건 비교 분석",
|
||||
apiEndpoint: "/report/purchase/data",
|
||||
metrics: [
|
||||
{ id: "orderAmt", name: "발주금액", unit: "원", color: "#3b82f6" },
|
||||
{ id: "receiveAmt", name: "입고금액", unit: "원", color: "#10b981" },
|
||||
{ id: "orderQty", name: "발주수량", unit: "EA", color: "#f59e0b" },
|
||||
{ id: "receiveQty", name: "입고수량", unit: "EA", color: "#8b5cf6" },
|
||||
{ id: "receiveRate", name: "입고율", unit: "%", color: "#ef4444", isRate: true },
|
||||
{ id: "unitPrice", name: "단가", unit: "원", color: "#ec4899" },
|
||||
{ id: "orderCnt", name: "발주건수", unit: "건", color: "#f97316" },
|
||||
],
|
||||
groupByOptions: [
|
||||
{ id: "supplier", name: "공급업체별" },
|
||||
{ id: "item", name: "품목별" },
|
||||
{ id: "manager", name: "구매담당별" },
|
||||
{ id: "status", name: "상태별" },
|
||||
{ id: "monthly", name: "월별" },
|
||||
{ id: "quarterly", name: "분기별" },
|
||||
{ id: "weekly", name: "주별" },
|
||||
{ id: "daily", name: "일별" },
|
||||
],
|
||||
defaultGroupBy: "supplier",
|
||||
defaultMetrics: ["orderAmt"],
|
||||
thresholds: [
|
||||
{ id: "delay", label: "납기지연 ≥", defaultValue: 3, unit: "일" },
|
||||
{ id: "price", label: "단가변동률 ≥", defaultValue: 10, unit: "%" },
|
||||
],
|
||||
filterFieldDefs: [
|
||||
{ id: "supplier", name: "공급업체", type: "select", optionKey: "suppliers" },
|
||||
{ id: "item", name: "품목", type: "select", optionKey: "items" },
|
||||
{ id: "manager", name: "구매담당", type: "select", optionKey: "managers" },
|
||||
{ id: "status", name: "상태", type: "select", optionKey: "statuses" },
|
||||
{ id: "orderAmt", name: "발주금액", type: "number" },
|
||||
{ id: "orderQty", name: "발주수량", type: "number" },
|
||||
],
|
||||
drilldownColumns: [
|
||||
{ id: "date", name: "날짜", format: "date" },
|
||||
{ id: "purchase_no", name: "발주번호" },
|
||||
{ id: "supplier", name: "공급업체" },
|
||||
{ id: "item", name: "품목" },
|
||||
{ id: "status", name: "상태", format: "badge" },
|
||||
{ id: "orderQty", name: "발주수량", align: "right", format: "number" },
|
||||
{ id: "receiveQty", name: "입고수량", align: "right", format: "number" },
|
||||
{ id: "unitPrice", name: "단가", align: "right", format: "number" },
|
||||
{ id: "orderAmt", name: "발주금액", align: "right", format: "number" },
|
||||
],
|
||||
rawDataColumns: [
|
||||
{ id: "date", name: "날짜", format: "date" },
|
||||
{ id: "purchase_no", name: "발주번호" },
|
||||
{ id: "supplier", name: "공급업체" },
|
||||
{ id: "item_code", name: "품목코드" },
|
||||
{ id: "item", name: "품목명" },
|
||||
{ id: "status", name: "상태", format: "badge" },
|
||||
{ id: "orderQty", name: "발주수량", align: "right", format: "number" },
|
||||
{ id: "receiveQty", name: "입고수량", align: "right", format: "number" },
|
||||
{ id: "unitPrice", name: "단가", align: "right", format: "number" },
|
||||
{ id: "orderAmt", name: "발주금액", align: "right", format: "number" },
|
||||
{ id: "manager", name: "담당자" },
|
||||
],
|
||||
enrichRow: (d) => ({
|
||||
...d,
|
||||
receiveRate: d.orderQty > 0 ? parseFloat((d.receiveQty / d.orderQty * 100).toFixed(1)) : 0,
|
||||
}),
|
||||
emptyMessage: "구매 데이터가 없습니다",
|
||||
};
|
||||
|
||||
export default function PurchaseReportPage() {
|
||||
return <ReportEngine config={config} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
"use client";
|
||||
|
||||
import ReportEngine, { ReportConfig } from "@/components/admin/report/ReportEngine";
|
||||
|
||||
const config: ReportConfig = {
|
||||
key: "quality_report_v2",
|
||||
title: "품질 리포트",
|
||||
description: "품질/검사 다중 조건 비교 분석",
|
||||
apiEndpoint: "/report/quality/data",
|
||||
metrics: [
|
||||
{ id: "defectQty", name: "불량수량", unit: "EA", color: "#ef4444" },
|
||||
{ id: "defectRate", name: "불량률", unit: "%", color: "#f59e0b", isRate: true },
|
||||
{ id: "inspQty", name: "검사수량", unit: "EA", color: "#3b82f6" },
|
||||
{ id: "passQty", name: "합격수량", unit: "EA", color: "#10b981" },
|
||||
{ id: "passRate", name: "합격률", unit: "%", color: "#8b5cf6", isRate: true },
|
||||
{ id: "reworkQty", name: "재작업수량", unit: "EA", color: "#ec4899" },
|
||||
{ id: "scrapQty", name: "폐기수량", unit: "EA", color: "#06b6d4" },
|
||||
{ id: "claimCnt", name: "클레임건수", unit: "건", color: "#84cc16" },
|
||||
],
|
||||
groupByOptions: [
|
||||
{ id: "item", name: "품목별" },
|
||||
{ id: "defectType", name: "불량유형별" },
|
||||
{ id: "process", name: "공정별" },
|
||||
{ id: "inspector", name: "검사자별" },
|
||||
{ id: "monthly", name: "월별" },
|
||||
{ id: "quarterly", name: "분기별" },
|
||||
{ id: "weekly", name: "주별" },
|
||||
{ id: "daily", name: "일별" },
|
||||
],
|
||||
defaultGroupBy: "item",
|
||||
defaultMetrics: ["defectQty"],
|
||||
thresholds: [
|
||||
{ id: "defectRate", label: "불량률 ≥", defaultValue: 5, unit: "%" },
|
||||
{ id: "defectQty", label: "불량수량 ≥", defaultValue: 20, unit: "EA" },
|
||||
],
|
||||
filterFieldDefs: [
|
||||
{ id: "item", name: "품목", type: "select", optionKey: "items" },
|
||||
{ id: "defectType", name: "불량유형", type: "select", optionKey: "defectTypes" },
|
||||
{ id: "process", name: "공정", type: "select", optionKey: "processes" },
|
||||
{ id: "inspector", name: "검사자", type: "select", optionKey: "inspectors" },
|
||||
{ id: "defectQty", name: "불량수량", type: "number" },
|
||||
{ id: "defectRate", name: "불량률", type: "number" },
|
||||
],
|
||||
drilldownColumns: [
|
||||
{ id: "date", name: "날짜", format: "date" },
|
||||
{ id: "item", name: "품목" },
|
||||
{ id: "defectType", name: "불량유형" },
|
||||
{ id: "process", name: "공정" },
|
||||
{ id: "inspector", name: "검사자" },
|
||||
{ id: "inspQty", name: "검사수량", align: "right", format: "number" },
|
||||
{ id: "defectQty", name: "불량수량", align: "right", format: "number" },
|
||||
{ id: "defectRate", name: "불량률(%)", align: "right", format: "number" },
|
||||
{ id: "passRate", name: "합격률(%)", align: "right", format: "number" },
|
||||
],
|
||||
rawDataColumns: [
|
||||
{ id: "date", name: "날짜", format: "date" },
|
||||
{ id: "item", name: "품목" },
|
||||
{ id: "defectType", name: "불량유형" },
|
||||
{ id: "process", name: "공정" },
|
||||
{ id: "inspector", name: "검사자" },
|
||||
{ id: "inspQty", name: "검사수량", align: "right", format: "number" },
|
||||
{ id: "passQty", name: "합격수량", align: "right", format: "number" },
|
||||
{ id: "defectQty", name: "불량수량", align: "right", format: "number" },
|
||||
{ id: "reworkQty", name: "재작업", align: "right", format: "number" },
|
||||
{ id: "scrapQty", name: "폐기", align: "right", format: "number" },
|
||||
],
|
||||
enrichRow: (d) => ({
|
||||
...d,
|
||||
defectRate: d.inspQty > 0 ? parseFloat((d.defectQty / d.inspQty * 100).toFixed(1)) : 0,
|
||||
passRate: d.inspQty > 0 ? parseFloat((d.passQty / d.inspQty * 100).toFixed(1)) : 0,
|
||||
}),
|
||||
emptyMessage: "품질 데이터가 없습니다",
|
||||
};
|
||||
|
||||
export default function QualityReportPage() {
|
||||
return <ReportEngine config={config} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
"use client";
|
||||
|
||||
import ReportEngine, { ReportConfig } from "@/components/admin/report/ReportEngine";
|
||||
|
||||
const config: ReportConfig = {
|
||||
key: "sales_report_v2",
|
||||
title: "영업 리포트",
|
||||
description: "다중 조건 비교 분석",
|
||||
apiEndpoint: "/sales-report/data",
|
||||
metrics: [
|
||||
{ id: "orderAmt", name: "수주금액", unit: "원", color: "#3b82f6" },
|
||||
{ id: "orderQty", name: "수주수량", unit: "EA", color: "#10b981" },
|
||||
{ id: "shipQty", name: "출하수량", unit: "EA", color: "#ef4444" },
|
||||
{ id: "unitPrice", name: "단가", unit: "원", color: "#8b5cf6" },
|
||||
{ id: "orderCount", name: "수주건수", unit: "건", color: "#f59e0b" },
|
||||
],
|
||||
groupByOptions: [
|
||||
{ id: "customer", name: "거래처별" },
|
||||
{ id: "item", name: "품목별" },
|
||||
{ id: "status", name: "상태별" },
|
||||
{ id: "monthly", name: "월별" },
|
||||
{ id: "quarterly", name: "분기별" },
|
||||
{ id: "weekly", name: "주별" },
|
||||
{ id: "daily", name: "일별" },
|
||||
],
|
||||
defaultGroupBy: "customer",
|
||||
defaultMetrics: ["orderAmt"],
|
||||
thresholds: [
|
||||
{ id: "low", label: "목표 미달 ≤", defaultValue: 80, unit: "%" },
|
||||
{ id: "high", label: "목표 초과 ≥", defaultValue: 120, unit: "%" },
|
||||
],
|
||||
filterFieldDefs: [
|
||||
{ id: "customer", name: "거래처", type: "select", optionKey: "customers" },
|
||||
{ id: "item", name: "품목", type: "select", optionKey: "items" },
|
||||
{ id: "status", name: "상태", type: "select", optionKey: "statuses" },
|
||||
{ id: "orderAmt", name: "수주금액", type: "number" },
|
||||
{ id: "orderQty", name: "수주수량", type: "number" },
|
||||
],
|
||||
drilldownColumns: [
|
||||
{ id: "date", name: "날짜", format: "date" },
|
||||
{ id: "order_no", name: "수주번호" },
|
||||
{ id: "customer", name: "거래처" },
|
||||
{ id: "item", name: "품목" },
|
||||
{ id: "status", name: "상태", format: "badge" },
|
||||
{ id: "orderQty", name: "수주수량", align: "right", format: "number" },
|
||||
{ id: "unitPrice", name: "단가", align: "right", format: "number" },
|
||||
{ id: "orderAmt", name: "수주금액", align: "right", format: "number" },
|
||||
{ id: "shipQty", name: "출하수량", align: "right", format: "number" },
|
||||
],
|
||||
rawDataColumns: [
|
||||
{ id: "date", name: "날짜", format: "date" },
|
||||
{ id: "order_no", name: "수주번호" },
|
||||
{ id: "customer", name: "거래처" },
|
||||
{ id: "part_code", name: "품목코드" },
|
||||
{ id: "item", name: "품목명" },
|
||||
{ id: "status", name: "상태", format: "badge" },
|
||||
{ id: "orderQty", name: "수주수량", align: "right", format: "number" },
|
||||
{ id: "unitPrice", name: "단가", align: "right", format: "number" },
|
||||
{ id: "orderAmt", name: "수주금액", align: "right", format: "number" },
|
||||
{ id: "shipQty", name: "출하수량", align: "right", format: "number" },
|
||||
],
|
||||
emptyMessage: "수주 데이터가 없습니다",
|
||||
};
|
||||
|
||||
export default function SalesReportPage() {
|
||||
return <ReportEngine config={config} />;
|
||||
}
|
||||
|
|
@ -1,76 +1,66 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import DataFlowList from "@/components/dataflow/DataFlowList";
|
||||
import { FlowEditor } from "@/components/dataflow/node-editor/FlowEditor";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
type Step = "list" | "editor";
|
||||
|
||||
export default function DataFlowPage() {
|
||||
const { user } = useAuth();
|
||||
const router = useRouter();
|
||||
const [currentStep, setCurrentStep] = useState<Step>("list");
|
||||
const [loadingFlowId, setLoadingFlowId] = useState<number | null>(null);
|
||||
|
||||
// 플로우 불러오기 핸들러
|
||||
const handleLoadFlow = async (flowId: number | null) => {
|
||||
if (flowId === null) {
|
||||
// 새 플로우 생성
|
||||
setLoadingFlowId(null);
|
||||
setCurrentStep("editor");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 기존 플로우 불러오기
|
||||
setLoadingFlowId(flowId);
|
||||
setCurrentStep("editor");
|
||||
|
||||
toast.success("플로우를 불러왔습니다.");
|
||||
toast.success("플로우를 불러왔어요");
|
||||
} catch (error: any) {
|
||||
console.error("❌ 플로우 불러오기 실패:", error);
|
||||
showErrorToast("플로우 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." });
|
||||
console.error("플로우 불러오기 실패:", error);
|
||||
showErrorToast("플로우를 불러오는 데 실패했어요", error, {
|
||||
guidance: "네트워크 연결을 확인해 주세요.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 목록으로 돌아가기
|
||||
const handleBackToList = () => {
|
||||
setCurrentStep("list");
|
||||
setLoadingFlowId(null);
|
||||
};
|
||||
|
||||
// 에디터 모드일 때는 전체 화면 사용
|
||||
const isEditorMode = currentStep === "editor";
|
||||
|
||||
// 에디터 모드일 때는 레이아웃 없이 전체 화면 사용
|
||||
if (isEditorMode) {
|
||||
if (currentStep === "editor") {
|
||||
return (
|
||||
<div className="bg-background fixed inset-0 z-50">
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 에디터 헤더 */}
|
||||
<div className="bg-background flex items-center gap-4 border-b p-4">
|
||||
<Button variant="outline" size="sm" onClick={handleBackToList} className="flex items-center gap-2">
|
||||
<div className="bg-background flex items-center gap-4 border-b px-5 py-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleBackToList}
|
||||
className="text-muted-foreground hover:text-foreground flex items-center gap-2 text-sm"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
목록으로
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">노드 플로우 에디터</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
드래그 앤 드롭으로 데이터 제어 플로우를 시각적으로 설계합니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 플로우 에디터 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<FlowEditor key={loadingFlowId || "new"} initialFlowId={loadingFlowId} />
|
||||
<FlowEditor
|
||||
key={loadingFlowId || "new"}
|
||||
initialFlowId={loadingFlowId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -78,20 +68,10 @@ export default function DataFlowPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen flex-col">
|
||||
<div className="space-y-6 p-4 sm:p-6">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="space-y-2 border-b pb-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight">제어 관리</h1>
|
||||
<p className="text-muted-foreground text-sm">노드 기반 데이터 플로우를 시각적으로 설계하고 관리합니다</p>
|
||||
</div>
|
||||
|
||||
{/* 플로우 목록 */}
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="mx-auto w-full max-w-[1400px] space-y-6 p-4 sm:p-6 pb-20">
|
||||
<DataFlowList onLoadFlow={handleLoadFlow} />
|
||||
</div>
|
||||
|
||||
{/* Scroll to Top 버튼 */}
|
||||
<ScrollToTop />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,8 +34,7 @@ import { commonCodeApi } from "@/lib/api/commonCode";
|
|||
import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin";
|
||||
import { ddlApi } from "@/lib/api/ddl";
|
||||
import { getSecondLevelMenus, createColumnMapping, deleteColumnMappingsByColumn } from "@/lib/api/tableCategoryValue";
|
||||
import { getNumberingRules, saveNumberingRuleToTest } from "@/lib/api/numberingRule";
|
||||
import { NumberingRuleConfig } from "@/types/numbering-rule";
|
||||
import { saveNumberingRuleToTest } from "@/lib/api/numberingRule";
|
||||
import { CreateTableModal } from "@/components/admin/CreateTableModal";
|
||||
import { AddColumnModal } from "@/components/admin/AddColumnModal";
|
||||
import { DDLLogViewer } from "@/components/admin/DDLLogViewer";
|
||||
|
|
@ -102,10 +101,7 @@ export default function TableManagementPage() {
|
|||
// 🆕 Category 타입용: 2레벨 메뉴 목록
|
||||
const [secondLevelMenus, setSecondLevelMenus] = useState<SecondLevelMenu[]>([]);
|
||||
|
||||
// 🆕 Numbering 타입용: 채번규칙 목록
|
||||
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
|
||||
const [numberingRulesLoading, setNumberingRulesLoading] = useState(false);
|
||||
const [numberingComboboxOpen, setNumberingComboboxOpen] = useState<Record<string, boolean>>({});
|
||||
// 채번 타입은 옵션설정 > 채번설정에서 관리 (별도 선택 불필요)
|
||||
|
||||
// 로그 뷰어 상태
|
||||
const [logViewerOpen, setLogViewerOpen] = useState(false);
|
||||
|
|
@ -281,24 +277,6 @@ export default function TableManagementPage() {
|
|||
};
|
||||
|
||||
// 🆕 채번규칙 목록 로드
|
||||
const loadNumberingRules = async () => {
|
||||
setNumberingRulesLoading(true);
|
||||
try {
|
||||
const response = await getNumberingRules();
|
||||
if (response.success && response.data) {
|
||||
setNumberingRules(response.data);
|
||||
} else {
|
||||
console.warn("⚠️ 채번규칙 로드 실패:", response);
|
||||
setNumberingRules([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 채번규칙 로드 에러:", error);
|
||||
setNumberingRules([]);
|
||||
} finally {
|
||||
setNumberingRulesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 테이블 목록 로드
|
||||
const loadTables = async () => {
|
||||
setLoading(true);
|
||||
|
|
@ -344,9 +322,7 @@ export default function TableManagementPage() {
|
|||
|
||||
// 컬럼 데이터에 기본값 설정
|
||||
const processedColumns = (data.columns || data).map((col: any) => {
|
||||
// detailSettings에서 hierarchyRole, numberingRuleId 추출
|
||||
let hierarchyRole: "large" | "medium" | "small" | undefined = undefined;
|
||||
let numberingRuleId: string | undefined = undefined;
|
||||
if (col.detailSettings && typeof col.detailSettings === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(col.detailSettings);
|
||||
|
|
@ -357,9 +333,6 @@ export default function TableManagementPage() {
|
|||
) {
|
||||
hierarchyRole = parsed.hierarchyRole;
|
||||
}
|
||||
if (parsed.numberingRuleId) {
|
||||
numberingRuleId = parsed.numberingRuleId;
|
||||
}
|
||||
} catch {
|
||||
// JSON 파싱 실패 시 무시
|
||||
}
|
||||
|
|
@ -369,7 +342,6 @@ export default function TableManagementPage() {
|
|||
...col,
|
||||
inputType: col.inputType || "text",
|
||||
isUnique: col.isUnique || "NO",
|
||||
numberingRuleId,
|
||||
categoryMenus: col.categoryMenus || [],
|
||||
hierarchyRole,
|
||||
categoryRef: col.categoryRef || null,
|
||||
|
|
@ -1000,7 +972,6 @@ export default function TableManagementPage() {
|
|||
loadTables();
|
||||
loadCommonCodeCategories();
|
||||
loadSecondLevelMenus();
|
||||
loadNumberingRules();
|
||||
}, []);
|
||||
|
||||
// 🎯 컬럼 로드 후 이미 설정된 참조 테이블들의 컬럼 정보 로드
|
||||
|
|
@ -1633,7 +1604,7 @@ export default function TableManagementPage() {
|
|||
tables={tables}
|
||||
referenceTableColumns={referenceTableColumns}
|
||||
secondLevelMenus={secondLevelMenus}
|
||||
numberingRules={numberingRules}
|
||||
numberingRules={[]}
|
||||
onColumnChange={(field, value) => {
|
||||
if (!selectedColumn) return;
|
||||
if (field === "inputType") {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,839 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import {
|
||||
Search,
|
||||
RotateCcw,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Calendar,
|
||||
Upload,
|
||||
PointerIcon,
|
||||
Ruler,
|
||||
ClipboardList,
|
||||
FileText,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
} from "@/components/ui/resizable";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
getDesignRequestList,
|
||||
createDesignRequest,
|
||||
updateDesignRequest,
|
||||
deleteDesignRequest,
|
||||
} from "@/lib/api/design";
|
||||
|
||||
// ========== 타입 ==========
|
||||
interface HistoryItem {
|
||||
id?: string;
|
||||
step: string;
|
||||
history_date: string;
|
||||
user_name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface DesignRequest {
|
||||
id: string;
|
||||
request_no: string;
|
||||
source_type: string;
|
||||
request_date: string;
|
||||
due_date: string;
|
||||
design_type: string;
|
||||
priority: string;
|
||||
status: string;
|
||||
approval_step: string;
|
||||
target_name: string;
|
||||
customer: string;
|
||||
req_dept: string;
|
||||
requester: string;
|
||||
designer: string;
|
||||
order_no: string;
|
||||
spec: string;
|
||||
change_type: string;
|
||||
drawing_no: string;
|
||||
urgency: string;
|
||||
reason: string;
|
||||
content: string;
|
||||
apply_timing: string;
|
||||
review_memo: string;
|
||||
project_id: string;
|
||||
ecn_no: string;
|
||||
created_date: string;
|
||||
updated_date: string;
|
||||
writer: string;
|
||||
company_code: string;
|
||||
history: HistoryItem[];
|
||||
impact: string[];
|
||||
}
|
||||
|
||||
// ========== 스타일 맵 ==========
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
신규접수: "bg-muted text-foreground",
|
||||
접수대기: "bg-muted text-foreground",
|
||||
검토중: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
|
||||
설계진행: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
|
||||
설계검토: "bg-violet-100 text-violet-800 dark:bg-violet-900/30 dark:text-violet-300",
|
||||
출도완료: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300",
|
||||
반려: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300",
|
||||
종료: "bg-muted text-muted-foreground",
|
||||
};
|
||||
|
||||
const TYPE_STYLES: Record<string, string> = {
|
||||
신규설계: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
|
||||
유사설계: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300",
|
||||
개조설계: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
|
||||
};
|
||||
|
||||
const PRIORITY_STYLES: Record<string, string> = {
|
||||
긴급: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300",
|
||||
높음: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
|
||||
보통: "bg-muted text-foreground",
|
||||
낮음: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300",
|
||||
};
|
||||
|
||||
const STATUS_PROGRESS: Record<string, number> = {
|
||||
신규접수: 0,
|
||||
접수대기: 0,
|
||||
검토중: 20,
|
||||
설계진행: 50,
|
||||
설계검토: 80,
|
||||
출도완료: 100,
|
||||
반려: 0,
|
||||
종료: 100,
|
||||
};
|
||||
|
||||
function getProgressColor(p: number) {
|
||||
if (p >= 100) return "bg-emerald-500";
|
||||
if (p >= 60) return "bg-amber-500";
|
||||
if (p >= 20) return "bg-blue-500";
|
||||
return "bg-muted";
|
||||
}
|
||||
|
||||
function getProgressTextColor(p: number) {
|
||||
if (p >= 100) return "text-emerald-500";
|
||||
if (p >= 60) return "text-amber-500";
|
||||
if (p >= 20) return "text-blue-500";
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
const INITIAL_FORM = {
|
||||
request_no: "",
|
||||
request_date: "",
|
||||
due_date: "",
|
||||
design_type: "",
|
||||
priority: "보통",
|
||||
target_name: "",
|
||||
customer: "",
|
||||
req_dept: "",
|
||||
requester: "",
|
||||
designer: "",
|
||||
order_no: "",
|
||||
spec: "",
|
||||
drawing_no: "",
|
||||
content: "",
|
||||
};
|
||||
|
||||
// ========== 메인 컴포넌트 ==========
|
||||
export default function DesignRequestPage() {
|
||||
const [requests, setRequests] = useState<DesignRequest[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [filterStatus, setFilterStatus] = useState("");
|
||||
const [filterType, setFilterType] = useState("");
|
||||
const [filterPriority, setFilterPriority] = useState("");
|
||||
const [filterKeyword, setFilterKeyword] = useState("");
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState(INITIAL_FORM);
|
||||
|
||||
const today = useMemo(() => new Date(), []);
|
||||
|
||||
// 데이터 조회
|
||||
const fetchRequests = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, string> = { source_type: "dr" };
|
||||
if (filterStatus && filterStatus !== "__all__") params.status = filterStatus;
|
||||
if (filterType && filterType !== "__all__") {
|
||||
// design_type은 서버에서 직접 필터링하지 않으므로 클라이언트에서 처리
|
||||
}
|
||||
if (filterPriority && filterPriority !== "__all__") params.priority = filterPriority;
|
||||
if (filterKeyword) params.search = filterKeyword;
|
||||
|
||||
const res = await getDesignRequestList(params);
|
||||
if (res.success && res.data) {
|
||||
setRequests(res.data);
|
||||
} else {
|
||||
setRequests([]);
|
||||
}
|
||||
} catch {
|
||||
setRequests([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filterStatus, filterPriority, filterKeyword]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRequests();
|
||||
}, [fetchRequests]);
|
||||
|
||||
// 클라이언트 사이드 필터링 (design_type은 서버에서 지원하지 않으므로)
|
||||
const filteredRequests = useMemo(() => {
|
||||
let list = requests;
|
||||
if (filterType && filterType !== "__all__") {
|
||||
list = list.filter((item) => item.design_type === filterType);
|
||||
}
|
||||
return list;
|
||||
}, [requests, filterType]);
|
||||
|
||||
const selectedItem = useMemo(() => {
|
||||
if (!selectedId) return null;
|
||||
return requests.find((r) => r.id === selectedId) || null;
|
||||
}, [selectedId, requests]);
|
||||
|
||||
const statusCounts = useMemo(() => {
|
||||
return {
|
||||
접수대기: requests.filter((r) => r.status === "접수대기" || r.status === "신규접수").length,
|
||||
설계진행: requests.filter((r) => r.status === "설계진행").length,
|
||||
출도완료: requests.filter((r) => r.status === "출도완료").length,
|
||||
};
|
||||
}, [requests]);
|
||||
|
||||
const handleResetFilter = useCallback(() => {
|
||||
setFilterStatus("");
|
||||
setFilterType("");
|
||||
setFilterPriority("");
|
||||
setFilterKeyword("");
|
||||
}, []);
|
||||
|
||||
// 채번: 기존 데이터 기반으로 다음 번호 생성
|
||||
const generateNextNo = useCallback(() => {
|
||||
const year = new Date().getFullYear();
|
||||
const existing = requests.filter((r) => r.request_no?.startsWith(`DR-${year}-`));
|
||||
const maxNum = existing.reduce((max, r) => {
|
||||
const parts = r.request_no?.split("-");
|
||||
const num = parts?.length >= 3 ? parseInt(parts[2]) : 0;
|
||||
return num > max ? num : max;
|
||||
}, 0);
|
||||
return `DR-${year}-${String(maxNum + 1).padStart(4, "0")}`;
|
||||
}, [requests]);
|
||||
|
||||
const handleOpenRegister = useCallback(() => {
|
||||
setIsEditMode(false);
|
||||
setEditingId(null);
|
||||
setForm({
|
||||
...INITIAL_FORM,
|
||||
request_no: generateNextNo(),
|
||||
request_date: new Date().toISOString().split("T")[0],
|
||||
});
|
||||
setModalOpen(true);
|
||||
}, [generateNextNo]);
|
||||
|
||||
const handleOpenEdit = useCallback(() => {
|
||||
if (!selectedItem) return;
|
||||
setIsEditMode(true);
|
||||
setEditingId(selectedItem.id);
|
||||
setForm({
|
||||
request_no: selectedItem.request_no || "",
|
||||
request_date: selectedItem.request_date || "",
|
||||
due_date: selectedItem.due_date || "",
|
||||
design_type: selectedItem.design_type || "",
|
||||
priority: selectedItem.priority || "보통",
|
||||
target_name: selectedItem.target_name || "",
|
||||
customer: selectedItem.customer || "",
|
||||
req_dept: selectedItem.req_dept || "",
|
||||
requester: selectedItem.requester || "",
|
||||
designer: selectedItem.designer || "",
|
||||
order_no: selectedItem.order_no || "",
|
||||
spec: selectedItem.spec || "",
|
||||
drawing_no: selectedItem.drawing_no || "",
|
||||
content: selectedItem.content || "",
|
||||
});
|
||||
setModalOpen(true);
|
||||
}, [selectedItem]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!form.target_name.trim()) { alert("설비/제품명을 입력하세요."); return; }
|
||||
if (!form.design_type) { alert("의뢰 유형을 선택하세요."); return; }
|
||||
if (!form.due_date) { alert("납기를 입력하세요."); return; }
|
||||
if (!form.spec.trim()) { alert("요구사양을 입력하세요."); return; }
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
request_no: form.request_no,
|
||||
source_type: "dr",
|
||||
request_date: form.request_date,
|
||||
due_date: form.due_date,
|
||||
design_type: form.design_type,
|
||||
priority: form.priority,
|
||||
target_name: form.target_name,
|
||||
customer: form.customer,
|
||||
req_dept: form.req_dept,
|
||||
requester: form.requester,
|
||||
designer: form.designer,
|
||||
order_no: form.order_no,
|
||||
spec: form.spec,
|
||||
drawing_no: form.drawing_no,
|
||||
content: form.content,
|
||||
};
|
||||
|
||||
let res;
|
||||
if (isEditMode && editingId) {
|
||||
res = await updateDesignRequest(editingId, payload);
|
||||
} else {
|
||||
res = await createDesignRequest({
|
||||
...payload,
|
||||
status: "신규접수",
|
||||
history: [{
|
||||
step: "신규접수",
|
||||
history_date: form.request_date || new Date().toISOString().split("T")[0],
|
||||
user_name: form.requester || "시스템",
|
||||
description: `${form.req_dept || ""}에서 설계의뢰 등록`,
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
if (res.success) {
|
||||
setModalOpen(false);
|
||||
await fetchRequests();
|
||||
if (isEditMode && editingId) {
|
||||
setSelectedId(editingId);
|
||||
} else if (res.data?.id) {
|
||||
setSelectedId(res.data.id);
|
||||
}
|
||||
} else {
|
||||
alert(`저장 실패: ${res.message || "알 수 없는 오류"}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
alert(`저장 중 오류가 발생했습니다: ${err.message}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [form, isEditMode, editingId, fetchRequests]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!selectedId || !selectedItem) return;
|
||||
const displayNo = selectedItem.request_no || selectedId;
|
||||
if (!confirm(`${displayNo} 설계의뢰를 삭제하시겠습니까?`)) return;
|
||||
|
||||
try {
|
||||
const res = await deleteDesignRequest(selectedId);
|
||||
if (res.success) {
|
||||
setSelectedId(null);
|
||||
await fetchRequests();
|
||||
} else {
|
||||
alert(`삭제 실패: ${res.message || "알 수 없는 오류"}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
alert(`삭제 중 오류가 발생했습니다: ${err.message}`);
|
||||
}
|
||||
}, [selectedId, selectedItem, fetchRequests]);
|
||||
|
||||
const getDueDateInfo = useCallback(
|
||||
(dueDate: string) => {
|
||||
if (!dueDate) return { text: "-", color: "text-muted-foreground" };
|
||||
const due = new Date(dueDate);
|
||||
const diff = Math.ceil((due.getTime() - today.getTime()) / 86400000);
|
||||
if (diff < 0) return { text: `${Math.abs(diff)}일 초과`, color: "text-destructive" };
|
||||
if (diff === 0) return { text: "오늘", color: "text-amber-500" };
|
||||
if (diff <= 7) return { text: `${diff}일 남음`, color: "text-amber-500" };
|
||||
return { text: `${diff}일 남음`, color: "text-emerald-500" };
|
||||
},
|
||||
[today]
|
||||
);
|
||||
|
||||
const getProgress = useCallback((status: string) => {
|
||||
return STATUS_PROGRESS[status] ?? 0;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-2 p-3">
|
||||
{/* 검색 섹션 */}
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 rounded-lg border bg-card px-3 py-2">
|
||||
<Select value={filterStatus} onValueChange={setFilterStatus}>
|
||||
<SelectTrigger className="h-7 w-[110px] text-xs" size="xs"><SelectValue placeholder="상태 전체" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">상태 전체</SelectItem>
|
||||
{["신규접수", "접수대기", "검토중", "설계진행", "설계검토", "출도완료", "반려", "종료"].map((s) => (
|
||||
<SelectItem key={s} value={s}>{s}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={filterType} onValueChange={setFilterType}>
|
||||
<SelectTrigger className="h-7 w-[110px] text-xs" size="xs"><SelectValue placeholder="유형 전체" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">유형 전체</SelectItem>
|
||||
{["신규설계", "유사설계", "개조설계"].map((s) => (
|
||||
<SelectItem key={s} value={s}>{s}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={filterPriority} onValueChange={setFilterPriority}>
|
||||
<SelectTrigger className="h-7 w-[110px] text-xs" size="xs"><SelectValue placeholder="우선순위 전체" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">우선순위 전체</SelectItem>
|
||||
{["긴급", "높음", "보통", "낮음"].map((s) => (
|
||||
<SelectItem key={s} value={s}>{s}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={filterKeyword}
|
||||
onChange={(e) => setFilterKeyword(e.target.value)}
|
||||
placeholder="의뢰번호 / 설비명 / 고객명 검색"
|
||||
className="h-7 w-[240px] pl-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleResetFilter}>
|
||||
<RotateCcw className="mr-1 h-3 w-3" />초기화
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => fetchRequests()}>
|
||||
<Search className="mr-1 h-3 w-3" />조회
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 메인 영역 */}
|
||||
<ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1 rounded-lg">
|
||||
{/* 왼쪽: 목록 */}
|
||||
<ResizablePanel defaultSize={55} minSize={30}>
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-lg border bg-card">
|
||||
<div className="flex shrink-0 items-center justify-between border-b bg-muted/30 px-3 py-1.5">
|
||||
<span className="text-sm font-bold">
|
||||
<Ruler className="mr-1 inline h-4 w-4" />
|
||||
설계의뢰 목록 (<span className="text-primary">{filteredRequests.length}</span>건)
|
||||
</span>
|
||||
<Button size="sm" className="h-7 text-xs" onClick={handleOpenRegister}>
|
||||
<Plus className="mr-1 h-3 w-3" />설계의뢰 등록
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">불러오는 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px] text-[11px]">의뢰번호</TableHead>
|
||||
<TableHead className="w-[70px] text-center text-[11px]">유형</TableHead>
|
||||
<TableHead className="w-[70px] text-center text-[11px]">상태</TableHead>
|
||||
<TableHead className="w-[60px] text-center text-[11px]">우선순위</TableHead>
|
||||
<TableHead className="text-[11px]">설비/제품명</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px]">고객명</TableHead>
|
||||
<TableHead className="w-[70px] text-[11px]">설계담당</TableHead>
|
||||
<TableHead className="w-[85px] text-[11px]">납기</TableHead>
|
||||
<TableHead className="w-[65px] text-center text-[11px]">진행률</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredRequests.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="py-12 text-center">
|
||||
<div className="flex flex-col items-center gap-1 text-muted-foreground">
|
||||
<Ruler className="h-8 w-8" />
|
||||
<span className="text-sm">등록된 설계의뢰가 없습니다</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{filteredRequests.map((item) => {
|
||||
const progress = getProgress(item.status);
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={cn("cursor-pointer", selectedId === item.id && "bg-accent")}
|
||||
onClick={() => setSelectedId(item.id)}
|
||||
>
|
||||
<TableCell className="text-[11px] font-semibold text-primary">{item.request_no || "-"}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{item.design_type ? (
|
||||
<Badge className={cn("text-[9px]", TYPE_STYLES[item.design_type])}>{item.design_type}</Badge>
|
||||
) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className={cn("text-[9px]", STATUS_STYLES[item.status])}>{item.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className={cn("text-[9px]", PRIORITY_STYLES[item.priority])}>{item.priority}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-medium">{item.target_name || "-"}</TableCell>
|
||||
<TableCell className="text-[11px]">{item.customer || "-"}</TableCell>
|
||||
<TableCell className="text-[11px]">{item.designer || "-"}</TableCell>
|
||||
<TableCell className="text-[11px]">{item.due_date || "-"}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-1.5 w-12 overflow-hidden rounded-full bg-muted">
|
||||
<div className={cn("h-full rounded-full transition-all", getProgressColor(progress))} style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<span className={cn("text-[10px] font-semibold", getProgressTextColor(progress))}>{progress}%</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* 오른쪽: 상세 */}
|
||||
<ResizablePanel defaultSize={45} minSize={25}>
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-lg border bg-card">
|
||||
<div className="flex shrink-0 items-center justify-between border-b bg-muted/30 px-3 py-1.5">
|
||||
<span className="text-sm font-bold">
|
||||
<ClipboardList className="mr-1 inline h-4 w-4" />
|
||||
상세 정보
|
||||
</span>
|
||||
{selectedItem && (
|
||||
<div className="flex gap-1.5">
|
||||
<Button variant="outline" size="sm" className="h-6 text-[10px]" onClick={handleOpenEdit}>
|
||||
<Pencil className="mr-0.5 h-3 w-3" />수정
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-6 text-[10px] text-destructive hover:text-destructive" onClick={handleDelete}>
|
||||
<Trash2 className="mr-0.5 h-3 w-3" />삭제
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-3">
|
||||
{/* 상태 카드 */}
|
||||
<div className="mb-3 grid grid-cols-3 gap-2">
|
||||
<Card
|
||||
className="cursor-pointer rounded-lg border px-3 py-2 shadow-none transition-colors hover:bg-accent/50"
|
||||
onClick={() => setFilterStatus("접수대기")}
|
||||
>
|
||||
<div className="text-[10px] text-muted-foreground">접수대기</div>
|
||||
<div className="text-xl font-bold text-blue-500">{statusCounts.접수대기}</div>
|
||||
</Card>
|
||||
<Card
|
||||
className="cursor-pointer rounded-lg border px-3 py-2 shadow-none transition-colors hover:bg-accent/50"
|
||||
onClick={() => setFilterStatus("설계진행")}
|
||||
>
|
||||
<div className="text-[10px] text-muted-foreground">설계진행</div>
|
||||
<div className="text-xl font-bold text-amber-500">{statusCounts.설계진행}</div>
|
||||
</Card>
|
||||
<Card
|
||||
className="cursor-pointer rounded-lg border px-3 py-2 shadow-none transition-colors hover:bg-accent/50"
|
||||
onClick={() => setFilterStatus("출도완료")}
|
||||
>
|
||||
<div className="text-[10px] text-muted-foreground">출도완료</div>
|
||||
<div className="text-xl font-bold text-emerald-500">{statusCounts.출도완료}</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 상세 내용 */}
|
||||
{!selectedItem ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground">
|
||||
<PointerIcon className="h-8 w-8" />
|
||||
<span className="text-sm">좌측 목록에서 설계의뢰를 선택하세요</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-bold">
|
||||
<FileText className="mr-1 inline h-3.5 w-3.5" />기본 정보
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 rounded-lg border bg-muted/10 p-3">
|
||||
<InfoRow label="의뢰번호" value={<span className="font-semibold text-primary">{selectedItem.request_no || "-"}</span>} />
|
||||
<InfoRow label="상태" value={<Badge className={cn("text-[10px]", STATUS_STYLES[selectedItem.status])}>{selectedItem.status}</Badge>} />
|
||||
<InfoRow label="유형" value={selectedItem.design_type ? <Badge className={cn("text-[10px]", TYPE_STYLES[selectedItem.design_type])}>{selectedItem.design_type}</Badge> : "-"} />
|
||||
<InfoRow label="우선순위" value={<Badge className={cn("text-[10px]", PRIORITY_STYLES[selectedItem.priority])}>{selectedItem.priority}</Badge>} />
|
||||
<InfoRow label="설비/제품명" value={selectedItem.target_name || "-"} />
|
||||
<InfoRow label="고객명" value={selectedItem.customer || "-"} />
|
||||
<InfoRow label="의뢰부서 / 의뢰자" value={`${selectedItem.req_dept || "-"} / ${selectedItem.requester || "-"}`} />
|
||||
<InfoRow label="설계담당" value={selectedItem.designer || "미배정"} />
|
||||
<InfoRow label="의뢰일자" value={selectedItem.request_date || "-"} />
|
||||
<InfoRow
|
||||
label="납기"
|
||||
value={
|
||||
selectedItem.due_date ? (
|
||||
<span>
|
||||
{selectedItem.due_date}{" "}
|
||||
<span className={cn("text-[11px]", getDueDateInfo(selectedItem.due_date).color)}>
|
||||
({getDueDateInfo(selectedItem.due_date).text})
|
||||
</span>
|
||||
</span>
|
||||
) : "-"
|
||||
}
|
||||
/>
|
||||
<InfoRow label="수주번호" value={selectedItem.order_no || "-"} />
|
||||
<InfoRow
|
||||
label="진행률"
|
||||
value={
|
||||
(() => {
|
||||
const progress = getProgress(selectedItem.status);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 flex-1 overflow-hidden rounded-full bg-muted">
|
||||
<div className={cn("h-full rounded-full", getProgressColor(progress))} style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<span className={cn("text-xs font-bold", getProgressTextColor(progress))}>{progress}%</span>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 요구사양 */}
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-bold">
|
||||
<FileText className="mr-1 inline h-3.5 w-3.5" />요구사양
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted/10 p-3">
|
||||
<pre className="whitespace-pre-wrap font-sans text-xs leading-relaxed">{selectedItem.spec || "-"}</pre>
|
||||
{selectedItem.drawing_no && (
|
||||
<div className="mt-2 text-xs">
|
||||
<span className="text-muted-foreground">참조 도면: </span>
|
||||
<span className="text-primary">{selectedItem.drawing_no}</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedItem.content && (
|
||||
<div className="mt-1 text-xs">
|
||||
<span className="text-muted-foreground">비고: </span>{selectedItem.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 진행 이력 */}
|
||||
{selectedItem.history && selectedItem.history.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-bold">
|
||||
<Calendar className="mr-1 inline h-3.5 w-3.5" />진행 이력
|
||||
</div>
|
||||
<div className="space-y-0">
|
||||
{selectedItem.history.map((h, idx) => {
|
||||
const isLast = idx === selectedItem.history.length - 1;
|
||||
const isDone = h.step === "출도완료" || h.step === "종료";
|
||||
return (
|
||||
<div key={h.id || idx} className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 h-2.5 w-2.5 shrink-0 rounded-full border-2",
|
||||
isLast && !isDone
|
||||
? "border-blue-500 bg-blue-500"
|
||||
: isDone || !isLast
|
||||
? "border-emerald-500 bg-emerald-500"
|
||||
: "border-muted-foreground bg-muted-foreground"
|
||||
)}
|
||||
/>
|
||||
{!isLast && <div className="w-px flex-1 bg-border" />}
|
||||
</div>
|
||||
<div className="pb-3">
|
||||
<Badge className={cn("text-[9px]", STATUS_STYLES[h.step])}>{h.step}</Badge>
|
||||
<div className="mt-0.5 text-xs">{h.description}</div>
|
||||
<div className="text-[10px] text-muted-foreground">{h.history_date} · {h.user_name}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
|
||||
{/* 등록/수정 모달 */}
|
||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[1100px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg">
|
||||
{isEditMode ? <><Pencil className="mr-1.5 inline h-5 w-5" />설계의뢰 수정</> : <><Plus className="mr-1.5 inline h-5 w-5" />설계의뢰 등록</>}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm">
|
||||
{isEditMode ? "설계의뢰 정보를 수정합니다." : "새 설계의뢰를 등록합니다."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-6">
|
||||
{/* 좌측: 기본 정보 */}
|
||||
<div className="w-[420px] shrink-0 space-y-4">
|
||||
<div className="text-sm font-bold">
|
||||
<FileText className="mr-1 inline h-4 w-4" />의뢰 기본 정보
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">의뢰번호</Label>
|
||||
<Input value={form.request_no} readOnly className="h-9 text-sm" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-sm">의뢰일자</Label>
|
||||
<Input type="date" value={form.request_date} onChange={(e) => setForm((p) => ({ ...p, request_date: e.target.value }))} className="h-9 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">납기 <span className="text-destructive">*</span></Label>
|
||||
<Input type="date" value={form.due_date} onChange={(e) => setForm((p) => ({ ...p, due_date: e.target.value }))} className="h-9 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-sm">의뢰 유형 <span className="text-destructive">*</span></Label>
|
||||
<Select value={form.design_type} onValueChange={(v) => setForm((p) => ({ ...p, design_type: v }))}>
|
||||
<SelectTrigger className="h-9 text-sm" size="sm"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{["신규설계", "유사설계", "개조설계"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">우선순위 <span className="text-destructive">*</span></Label>
|
||||
<Select value={form.priority} onValueChange={(v) => setForm((p) => ({ ...p, priority: v }))}>
|
||||
<SelectTrigger className="h-9 text-sm" size="sm"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{["긴급", "높음", "보통", "낮음"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">설비/제품명 <span className="text-destructive">*</span></Label>
|
||||
<Input value={form.target_name} onChange={(e) => setForm((p) => ({ ...p, target_name: e.target.value }))} placeholder="설비 또는 제품명 입력" className="h-9 text-sm" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-sm">의뢰부서</Label>
|
||||
<Select value={form.req_dept} onValueChange={(v) => setForm((p) => ({ ...p, req_dept: v }))}>
|
||||
<SelectTrigger className="h-9 text-sm" size="sm"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{["영업팀", "기획팀", "생산팀", "품질팀"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">의뢰자</Label>
|
||||
<Input value={form.requester} onChange={(e) => setForm((p) => ({ ...p, requester: e.target.value }))} placeholder="의뢰자명" className="h-9 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-sm">고객명</Label>
|
||||
<Input value={form.customer} onChange={(e) => setForm((p) => ({ ...p, customer: e.target.value }))} placeholder="고객/거래처명" className="h-9 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">수주번호</Label>
|
||||
<Input value={form.order_no} onChange={(e) => setForm((p) => ({ ...p, order_no: e.target.value }))} placeholder="관련 수주번호" className="h-9 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">설계담당자</Label>
|
||||
<Select value={form.designer} onValueChange={(v) => setForm((p) => ({ ...p, designer: v }))}>
|
||||
<SelectTrigger className="h-9 text-sm" size="sm"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{["이설계", "박도면", "최기구", "김전장"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측: 상세 내용 */}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-4">
|
||||
<div className="text-sm font-bold">
|
||||
<FileText className="mr-1 inline h-4 w-4" />요구사양 및 설명
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label className="text-sm">요구사양 <span className="text-destructive">*</span></Label>
|
||||
<Textarea
|
||||
value={form.spec}
|
||||
onChange={(e) => setForm((p) => ({ ...p, spec: e.target.value }))}
|
||||
placeholder={"고객 요구사양 또는 설비 사양을 상세히 기술하세요\n\n예시:\n- 작업 대상: SUS304 Φ20 파이프\n- 가공 방식: 자동 절단 + 면취\n- 생산 속도: 60EA/분\n- 치수 공차: ±0.1mm"}
|
||||
className="min-h-[180px] text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">참조 도면번호</Label>
|
||||
<Input value={form.drawing_no} onChange={(e) => setForm((p) => ({ ...p, drawing_no: e.target.value }))} placeholder="유사 설비명 또는 참조 도면번호" className="h-9 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">비고</Label>
|
||||
<Textarea value={form.content} onChange={(e) => setForm((p) => ({ ...p, content: e.target.value }))} placeholder="기타 참고 사항" className="min-h-[70px] text-sm" rows={3} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-bold">
|
||||
<Upload className="mr-1 inline h-4 w-4" />첨부파일
|
||||
</div>
|
||||
<div className="mt-1.5 cursor-pointer rounded-lg border-2 border-dashed p-5 text-center transition-colors hover:border-primary hover:bg-accent/50">
|
||||
<Upload className="mx-auto h-6 w-6 text-muted-foreground" />
|
||||
<div className="mt-1.5 text-sm text-muted-foreground">클릭하여 파일 첨부 (사양서, 도면, 사진 등)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)} className="h-10 px-6 text-sm" disabled={saving}>취소</Button>
|
||||
<Button onClick={handleSave} className="h-10 px-6 text-sm" disabled={saving}>
|
||||
{saving && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />}
|
||||
{saving ? "저장 중..." : "저장"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== 정보 행 서브컴포넌트 ==========
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start gap-1">
|
||||
<span className="min-w-[80px] shrink-0 text-[11px] text-muted-foreground">{label}</span>
|
||||
<span className="text-xs font-medium">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,597 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useMemo, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable";
|
||||
import {
|
||||
Search,
|
||||
RotateCcw,
|
||||
Package,
|
||||
ClipboardList,
|
||||
Factory,
|
||||
MapPin,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getWorkOrders,
|
||||
getMaterialStatus,
|
||||
getWarehouses,
|
||||
type WorkOrder,
|
||||
type MaterialData,
|
||||
type WarehouseData,
|
||||
} from "@/lib/api/materialStatus";
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(date.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${d}`;
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
planned: "계획",
|
||||
in_progress: "진행중",
|
||||
completed: "완료",
|
||||
pending: "대기",
|
||||
cancelled: "취소",
|
||||
};
|
||||
return map[status] || status;
|
||||
};
|
||||
|
||||
const getStatusStyle = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
planned: "bg-amber-100 text-amber-700 border-amber-200",
|
||||
pending: "bg-amber-100 text-amber-700 border-amber-200",
|
||||
in_progress: "bg-blue-100 text-blue-700 border-blue-200",
|
||||
completed: "bg-emerald-100 text-emerald-700 border-emerald-200",
|
||||
cancelled: "bg-gray-100 text-gray-500 border-gray-200",
|
||||
};
|
||||
return map[status] || "bg-gray-100 text-gray-500 border-gray-200";
|
||||
};
|
||||
|
||||
export default function MaterialStatusPage() {
|
||||
const today = new Date();
|
||||
const monthAgo = new Date(today);
|
||||
monthAgo.setMonth(today.getMonth() - 1);
|
||||
|
||||
const [searchDateFrom, setSearchDateFrom] = useState(formatDate(monthAgo));
|
||||
const [searchDateTo, setSearchDateTo] = useState(formatDate(today));
|
||||
const [searchItemCode, setSearchItemCode] = useState("");
|
||||
const [searchItemName, setSearchItemName] = useState("");
|
||||
|
||||
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
|
||||
const [workOrdersLoading, setWorkOrdersLoading] = useState(false);
|
||||
const [checkedWoIds, setCheckedWoIds] = useState<number[]>([]);
|
||||
const [selectedWoId, setSelectedWoId] = useState<number | null>(null);
|
||||
|
||||
const [warehouses, setWarehouses] = useState<WarehouseData[]>([]);
|
||||
const [warehouse, setWarehouse] = useState("");
|
||||
const [materialSearch, setMaterialSearch] = useState("");
|
||||
const [showShortageOnly, setShowShortageOnly] = useState(false);
|
||||
const [materials, setMaterials] = useState<MaterialData[]>([]);
|
||||
const [materialsLoading, setMaterialsLoading] = useState(false);
|
||||
|
||||
// 창고 목록 초기 로드
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const res = await getWarehouses();
|
||||
if (res.success && res.data) {
|
||||
setWarehouses(res.data);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// 작업지시 검색
|
||||
const handleSearch = useCallback(async () => {
|
||||
setWorkOrdersLoading(true);
|
||||
try {
|
||||
const res = await getWorkOrders({
|
||||
dateFrom: searchDateFrom,
|
||||
dateTo: searchDateTo,
|
||||
itemCode: searchItemCode || undefined,
|
||||
itemName: searchItemName || undefined,
|
||||
});
|
||||
if (res.success && res.data) {
|
||||
setWorkOrders(res.data);
|
||||
setCheckedWoIds([]);
|
||||
setSelectedWoId(null);
|
||||
setMaterials([]);
|
||||
}
|
||||
} finally {
|
||||
setWorkOrdersLoading(false);
|
||||
}
|
||||
}, [searchDateFrom, searchDateTo, searchItemCode, searchItemName]);
|
||||
|
||||
// 초기 로드
|
||||
useEffect(() => {
|
||||
handleSearch();
|
||||
}, []);
|
||||
|
||||
const isAllChecked =
|
||||
workOrders.length > 0 && checkedWoIds.length === workOrders.length;
|
||||
|
||||
const handleCheckAll = useCallback(
|
||||
(checked: boolean) => {
|
||||
setCheckedWoIds(checked ? workOrders.map((wo) => wo.id) : []);
|
||||
},
|
||||
[workOrders]
|
||||
);
|
||||
|
||||
const handleCheckWo = useCallback((id: number, checked: boolean) => {
|
||||
setCheckedWoIds((prev) =>
|
||||
checked ? [...prev, id] : prev.filter((i) => i !== id)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleSelectWo = useCallback((id: number) => {
|
||||
setSelectedWoId((prev) => (prev === id ? null : id));
|
||||
}, []);
|
||||
|
||||
// 선택된 작업지시의 자재 조회
|
||||
const handleLoadSelectedMaterials = useCallback(async () => {
|
||||
if (checkedWoIds.length === 0) {
|
||||
alert("자재를 조회할 작업지시를 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setMaterialsLoading(true);
|
||||
try {
|
||||
const res = await getMaterialStatus({
|
||||
planIds: checkedWoIds,
|
||||
warehouseCode: warehouse || undefined,
|
||||
});
|
||||
if (res.success && res.data) {
|
||||
setMaterials(res.data);
|
||||
}
|
||||
} finally {
|
||||
setMaterialsLoading(false);
|
||||
}
|
||||
}, [checkedWoIds, warehouse]);
|
||||
|
||||
const handleResetSearch = useCallback(() => {
|
||||
const t = new Date();
|
||||
const m = new Date(t);
|
||||
m.setMonth(t.getMonth() - 1);
|
||||
setSearchDateFrom(formatDate(m));
|
||||
setSearchDateTo(formatDate(t));
|
||||
setSearchItemCode("");
|
||||
setSearchItemName("");
|
||||
setMaterialSearch("");
|
||||
setShowShortageOnly(false);
|
||||
}, []);
|
||||
|
||||
const filteredMaterials = useMemo(() => {
|
||||
return materials.filter((m) => {
|
||||
const searchLower = materialSearch.toLowerCase();
|
||||
const matchesSearch =
|
||||
!materialSearch ||
|
||||
m.code.toLowerCase().includes(searchLower) ||
|
||||
m.name.toLowerCase().includes(searchLower);
|
||||
const matchesShortage = !showShortageOnly || m.current < m.required;
|
||||
return matchesSearch && matchesShortage;
|
||||
});
|
||||
}, [materials, materialSearch, showShortageOnly]);
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col gap-4 bg-muted/30 p-4">
|
||||
{/* 헤더 */}
|
||||
<div className="shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<Package className="h-7 w-7 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">자재현황</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
작업지시 대비 원자재 재고 현황
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 영역 */}
|
||||
<Card className="shrink-0">
|
||||
<CardContent className="flex flex-wrap items-end gap-3 p-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">기간</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
className="h-9 w-[140px]"
|
||||
value={searchDateFrom}
|
||||
onChange={(e) => setSearchDateFrom(e.target.value)}
|
||||
/>
|
||||
<span className="text-muted-foreground">~</span>
|
||||
<Input
|
||||
type="date"
|
||||
className="h-9 w-[140px]"
|
||||
value={searchDateTo}
|
||||
onChange={(e) => setSearchDateTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">품목코드</Label>
|
||||
<Input
|
||||
placeholder="품목코드"
|
||||
className="h-9 w-[140px]"
|
||||
value={searchItemCode}
|
||||
onChange={(e) => setSearchItemCode(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">품목명</Label>
|
||||
<Input
|
||||
placeholder="품목명"
|
||||
className="h-9 w-[140px]"
|
||||
value={searchItemName}
|
||||
onChange={(e) => setSearchItemName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9"
|
||||
onClick={handleResetSearch}
|
||||
>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
초기화
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-9"
|
||||
onClick={handleSearch}
|
||||
disabled={workOrdersLoading}
|
||||
>
|
||||
{workOrdersLoading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
검색
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 메인 콘텐츠 (좌우 분할) */}
|
||||
<div className="flex-1 overflow-hidden rounded-lg border bg-background shadow-sm">
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
{/* 왼쪽: 작업지시 리스트 */}
|
||||
<ResizablePanel defaultSize={35} minSize={25}>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 패널 헤더 */}
|
||||
<div className="flex items-center justify-between border-b bg-muted/10 p-3 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={isAllChecked}
|
||||
onCheckedChange={handleCheckAll}
|
||||
/>
|
||||
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold">작업지시 리스트</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{workOrders.length}
|
||||
</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={handleLoadSelectedMaterials}
|
||||
disabled={materialsLoading}
|
||||
>
|
||||
{materialsLoading ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Search className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
자재조회
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 작업지시 목록 */}
|
||||
<div className="flex-1 space-y-2 overflow-auto p-3">
|
||||
{workOrdersLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Loader2 className="mb-3 h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
작업지시를 조회하고 있습니다...
|
||||
</p>
|
||||
</div>
|
||||
) : workOrders.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<ClipboardList className="mb-3 h-10 w-10 text-muted-foreground/30" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
작업지시가 없습니다
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
workOrders.map((wo) => (
|
||||
<div
|
||||
key={wo.id}
|
||||
className={cn(
|
||||
"flex gap-3 rounded-lg border-2 p-3 transition-all cursor-pointer",
|
||||
"hover:border-primary hover:shadow-md hover:-translate-y-0.5",
|
||||
selectedWoId === wo.id
|
||||
? "border-primary bg-primary/5 shadow-md"
|
||||
: "border-border"
|
||||
)}
|
||||
onClick={() => handleSelectWo(wo.id)}
|
||||
>
|
||||
<div
|
||||
className="flex items-start pt-0.5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checkedWoIds.includes(wo.id)}
|
||||
onCheckedChange={(c) =>
|
||||
handleCheckWo(wo.id, c as boolean)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-bold text-primary">
|
||||
{wo.plan_no || wo.work_order_no || `WO-${wo.id}`}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto rounded-md border px-2 py-0.5 text-[11px] font-semibold",
|
||||
getStatusStyle(wo.status)
|
||||
)}
|
||||
>
|
||||
{getStatusLabel(wo.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-semibold">
|
||||
{wo.item_name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({wo.item_code})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<span>수량:</span>
|
||||
<span className="font-semibold text-foreground">
|
||||
{Number(wo.plan_qty).toLocaleString()}개
|
||||
</span>
|
||||
<span className="mx-1">|</span>
|
||||
<span>일자:</span>
|
||||
<span className="font-semibold text-foreground">
|
||||
{wo.plan_date
|
||||
? new Date(wo.plan_date)
|
||||
.toISOString()
|
||||
.slice(0, 10)
|
||||
: "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* 오른쪽: 원자재 현황 */}
|
||||
<ResizablePanel defaultSize={65} minSize={35}>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 패널 헤더 */}
|
||||
<div className="flex items-center gap-2 border-b bg-muted/10 p-3 shrink-0">
|
||||
<Factory className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold">원자재 재고 현황</span>
|
||||
</div>
|
||||
|
||||
{/* 필터 */}
|
||||
<div className="flex flex-wrap items-center gap-3 border-b bg-muted/5 px-4 py-3 shrink-0">
|
||||
<Input
|
||||
placeholder="원자재 검색"
|
||||
className="h-9 min-w-[150px] flex-1"
|
||||
value={materialSearch}
|
||||
onChange={(e) => setMaterialSearch(e.target.value)}
|
||||
/>
|
||||
<Select value={warehouse} onValueChange={setWarehouse}>
|
||||
<SelectTrigger className="h-9 w-[200px]">
|
||||
<SelectValue placeholder="전체 창고" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">전체 창고</SelectItem>
|
||||
{warehouses.map((wh) => (
|
||||
<SelectItem
|
||||
key={wh.warehouse_code}
|
||||
value={wh.warehouse_code}
|
||||
>
|
||||
{wh.warehouse_name}
|
||||
{wh.warehouse_type
|
||||
? ` (${wh.warehouse_type})`
|
||||
: ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm font-medium">
|
||||
<Checkbox
|
||||
checked={showShortageOnly}
|
||||
onCheckedChange={(c) => setShowShortageOnly(c as boolean)}
|
||||
/>
|
||||
<span>부족한 것만 보기</span>
|
||||
</label>
|
||||
<span className="ml-auto text-sm font-semibold text-muted-foreground">
|
||||
{filteredMaterials.length}개 품목
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 원자재 목록 */}
|
||||
<div className="flex-1 space-y-2 overflow-auto p-3">
|
||||
{materialsLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Loader2 className="mb-3 h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
자재현황을 조회하고 있습니다...
|
||||
</p>
|
||||
</div>
|
||||
) : materials.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Package className="mb-3 h-10 w-10 text-muted-foreground/30" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
작업지시를 선택하고 자재조회 버튼을 클릭해주세요
|
||||
</p>
|
||||
</div>
|
||||
) : filteredMaterials.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Package className="mb-3 h-10 w-10 text-muted-foreground/30" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
조회된 원자재가 없습니다
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredMaterials.map((material) => {
|
||||
const shortage = material.required - material.current;
|
||||
const isShortage = shortage > 0;
|
||||
const percentage =
|
||||
material.required > 0
|
||||
? Math.min(
|
||||
(material.current / material.required) * 100,
|
||||
100
|
||||
)
|
||||
: 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={material.code}
|
||||
className={cn(
|
||||
"rounded-lg border-2 p-3 transition-all hover:shadow-md hover:-translate-y-0.5",
|
||||
isShortage
|
||||
? "border-destructive/40 bg-destructive/2"
|
||||
: "border-emerald-300/50 bg-emerald-50/20"
|
||||
)}
|
||||
>
|
||||
{/* 메인 정보 라인 */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-bold">
|
||||
{material.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({material.code})
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
|
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
필요:
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-blue-600">
|
||||
{material.required.toLocaleString()}
|
||||
{material.unit}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
|
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
현재:
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-semibold",
|
||||
isShortage
|
||||
? "text-destructive"
|
||||
: "text-foreground"
|
||||
)}
|
||||
>
|
||||
{material.current.toLocaleString()}
|
||||
{material.unit}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
|
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{isShortage ? "부족:" : "여유:"}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-semibold",
|
||||
isShortage
|
||||
? "text-destructive"
|
||||
: "text-emerald-600"
|
||||
)}
|
||||
>
|
||||
{Math.abs(shortage).toLocaleString()}
|
||||
{material.unit}
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-muted-foreground">
|
||||
({percentage.toFixed(0)}%)
|
||||
</span>
|
||||
|
||||
{isShortage ? (
|
||||
<span className="ml-auto inline-flex items-center gap-1 rounded-md border border-destructive bg-destructive/10 px-2 py-0.5 text-[11px] font-semibold text-destructive">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
부족
|
||||
</span>
|
||||
) : (
|
||||
<span className="ml-auto inline-flex items-center gap-1 rounded-md border border-emerald-500 bg-emerald-500/10 px-2 py-0.5 text-[11px] font-semibold text-emerald-600">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
충분
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 위치별 재고 */}
|
||||
{material.locations.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
<MapPin className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{material.locations.map((loc, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center gap-1 rounded bg-muted/40 px-2 py-0.5 text-xs transition-colors hover:bg-muted/60"
|
||||
>
|
||||
<span className="font-semibold font-mono text-primary">
|
||||
{loc.location || loc.warehouse}
|
||||
</span>
|
||||
<span className="font-semibold">
|
||||
{loc.qty.toLocaleString()}
|
||||
{material.unit}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,845 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Loader2,
|
||||
Settings,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Search,
|
||||
RotateCcw,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
} from "@/components/ui/resizable";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getProcessList,
|
||||
createProcess,
|
||||
updateProcess,
|
||||
deleteProcesses,
|
||||
getProcessEquipments,
|
||||
addProcessEquipment,
|
||||
removeProcessEquipment,
|
||||
getEquipmentList,
|
||||
type ProcessMaster,
|
||||
type ProcessEquipment,
|
||||
type Equipment,
|
||||
} from "@/lib/api/processInfo";
|
||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||
|
||||
const ALL_VALUE = "__all__";
|
||||
|
||||
export function ProcessMasterTab() {
|
||||
const [processes, setProcesses] = useState<ProcessMaster[]>([]);
|
||||
const [equipmentMaster, setEquipmentMaster] = useState<Equipment[]>([]);
|
||||
const [processTypeOptions, setProcessTypeOptions] = useState<{ valueCode: string; valueLabel: string }[]>([]);
|
||||
const [loadingInitial, setLoadingInitial] = useState(true);
|
||||
const [loadingList, setLoadingList] = useState(false);
|
||||
const [loadingEquipments, setLoadingEquipments] = useState(false);
|
||||
|
||||
const [filterCode, setFilterCode] = useState("");
|
||||
const [filterName, setFilterName] = useState("");
|
||||
const [filterType, setFilterType] = useState<string>(ALL_VALUE);
|
||||
const [filterUseYn, setFilterUseYn] = useState<string>(ALL_VALUE);
|
||||
|
||||
const [selectedProcess, setSelectedProcess] = useState<ProcessMaster | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const [processEquipments, setProcessEquipments] = useState<ProcessEquipment[]>([]);
|
||||
const [equipmentPick, setEquipmentPick] = useState<string>("");
|
||||
const [addingEquipment, setAddingEquipment] = useState(false);
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [formMode, setFormMode] = useState<"add" | "edit">("add");
|
||||
const [savingForm, setSavingForm] = useState(false);
|
||||
const [formProcessCode, setFormProcessCode] = useState("");
|
||||
const [formProcessName, setFormProcessName] = useState("");
|
||||
const [formProcessType, setFormProcessType] = useState<string>("");
|
||||
const [formStandardTime, setFormStandardTime] = useState("");
|
||||
const [formWorkerCount, setFormWorkerCount] = useState("");
|
||||
const [formUseYn, setFormUseYn] = useState("");
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const processTypeMap = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
processTypeOptions.forEach((o) => m.set(o.valueCode, o.valueLabel));
|
||||
return m;
|
||||
}, [processTypeOptions]);
|
||||
|
||||
const getProcessTypeLabel = useCallback(
|
||||
(code: string) => processTypeMap.get(code) ?? code,
|
||||
[processTypeMap]
|
||||
);
|
||||
|
||||
const loadProcesses = useCallback(async () => {
|
||||
setLoadingList(true);
|
||||
try {
|
||||
const res = await getProcessList({
|
||||
processCode: filterCode.trim() || undefined,
|
||||
processName: filterName.trim() || undefined,
|
||||
processType: filterType === ALL_VALUE ? undefined : filterType,
|
||||
useYn: filterUseYn === ALL_VALUE ? undefined : filterUseYn,
|
||||
});
|
||||
if (!res.success) {
|
||||
toast.error(res.message || "공정 목록을 불러오지 못했습니다.");
|
||||
return;
|
||||
}
|
||||
setProcesses(res.data ?? []);
|
||||
} finally {
|
||||
setLoadingList(false);
|
||||
}
|
||||
}, [filterCode, filterName, filterType, filterUseYn]);
|
||||
|
||||
const loadInitial = useCallback(async () => {
|
||||
setLoadingInitial(true);
|
||||
try {
|
||||
const [procRes, eqRes] = await Promise.all([getProcessList(), getEquipmentList()]);
|
||||
if (!procRes.success) {
|
||||
toast.error(procRes.message || "공정 목록을 불러오지 못했습니다.");
|
||||
} else {
|
||||
setProcesses(procRes.data ?? []);
|
||||
}
|
||||
if (!eqRes.success) {
|
||||
toast.error(eqRes.message || "설비 목록을 불러오지 못했습니다.");
|
||||
} else {
|
||||
setEquipmentMaster(eqRes.data ?? []);
|
||||
}
|
||||
const ptRes = await getCategoryValues("process_mng", "process_type");
|
||||
if (ptRes.success && "data" in ptRes && Array.isArray(ptRes.data)) {
|
||||
const activeValues = ptRes.data.filter((v: any) => v.isActive !== false);
|
||||
const seen = new Set<string>();
|
||||
const unique = activeValues.filter((v: any) => {
|
||||
if (seen.has(v.valueCode)) return false;
|
||||
seen.add(v.valueCode);
|
||||
return true;
|
||||
});
|
||||
setProcessTypeOptions(unique.map((v: any) => ({ valueCode: v.valueCode, valueLabel: v.valueLabel })));
|
||||
}
|
||||
} finally {
|
||||
setLoadingInitial(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadInitial();
|
||||
}, [loadInitial]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedProcess((prev) => {
|
||||
if (!prev) return prev;
|
||||
if (!processes.some((p) => p.id === prev.id)) return null;
|
||||
return prev;
|
||||
});
|
||||
}, [processes]);
|
||||
|
||||
useEffect(() => {
|
||||
setEquipmentPick("");
|
||||
}, [selectedProcess?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProcess) {
|
||||
setProcessEquipments([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoadingEquipments(true);
|
||||
void (async () => {
|
||||
const res = await getProcessEquipments(selectedProcess.process_code);
|
||||
if (cancelled) return;
|
||||
if (!res.success) {
|
||||
toast.error(res.message || "공정 설비를 불러오지 못했습니다.");
|
||||
setProcessEquipments([]);
|
||||
} else {
|
||||
setProcessEquipments(res.data ?? []);
|
||||
}
|
||||
setLoadingEquipments(false);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedProcess?.process_code]);
|
||||
|
||||
const allSelected = useMemo(() => {
|
||||
if (processes.length === 0) return false;
|
||||
return processes.every((p) => selectedIds.has(p.id));
|
||||
}, [processes, selectedIds]);
|
||||
|
||||
const toggleAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedIds(new Set(processes.map((p) => p.id)));
|
||||
} else {
|
||||
setSelectedIds(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
const toggleOne = (id: string, checked: boolean) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleResetFilters = () => {
|
||||
setFilterCode("");
|
||||
setFilterName("");
|
||||
setFilterType(ALL_VALUE);
|
||||
setFilterUseYn(ALL_VALUE);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
void loadProcesses();
|
||||
};
|
||||
|
||||
const openAdd = () => {
|
||||
setFormMode("add");
|
||||
setEditingId(null);
|
||||
setFormProcessCode("");
|
||||
setFormProcessName("");
|
||||
setFormProcessType(processTypeOptions[0]?.valueCode ?? "");
|
||||
setFormStandardTime("");
|
||||
setFormWorkerCount("");
|
||||
setFormUseYn("Y");
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = () => {
|
||||
if (!selectedProcess) {
|
||||
toast.message("수정할 공정을 좌측 목록에서 선택하세요.");
|
||||
return;
|
||||
}
|
||||
setFormMode("edit");
|
||||
setEditingId(selectedProcess.id);
|
||||
setFormProcessCode(selectedProcess.process_code);
|
||||
setFormProcessName(selectedProcess.process_name);
|
||||
setFormProcessType(selectedProcess.process_type);
|
||||
setFormStandardTime(selectedProcess.standard_time ?? "");
|
||||
setFormWorkerCount(selectedProcess.worker_count ?? "");
|
||||
setFormUseYn(selectedProcess.use_yn);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!formProcessName.trim()) {
|
||||
toast.error("공정명을 입력하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingForm(true);
|
||||
try {
|
||||
if (formMode === "add") {
|
||||
const res = await createProcess({
|
||||
process_name: formProcessName.trim(),
|
||||
process_type: formProcessType,
|
||||
standard_time: formStandardTime.trim() || "0",
|
||||
worker_count: formWorkerCount.trim() || "0",
|
||||
use_yn: formUseYn,
|
||||
});
|
||||
if (!res.success || !res.data) {
|
||||
toast.error(res.message || "등록에 실패했습니다.");
|
||||
return;
|
||||
}
|
||||
toast.success("공정이 등록되었습니다.");
|
||||
setFormOpen(false);
|
||||
await loadProcesses();
|
||||
setSelectedProcess(res.data);
|
||||
setSelectedIds(new Set());
|
||||
} else if (editingId) {
|
||||
const res = await updateProcess(editingId, {
|
||||
process_name: formProcessName.trim(),
|
||||
process_type: formProcessType,
|
||||
standard_time: formStandardTime.trim() || "0",
|
||||
worker_count: formWorkerCount.trim() || "0",
|
||||
use_yn: formUseYn,
|
||||
});
|
||||
if (!res.success || !res.data) {
|
||||
toast.error(res.message || "수정에 실패했습니다.");
|
||||
return;
|
||||
}
|
||||
toast.success("공정이 수정되었습니다.");
|
||||
setFormOpen(false);
|
||||
await loadProcesses();
|
||||
setSelectedProcess(res.data);
|
||||
}
|
||||
} finally {
|
||||
setSavingForm(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openDelete = () => {
|
||||
if (selectedIds.size === 0) {
|
||||
toast.message("삭제할 공정을 체크박스로 선택하세요.");
|
||||
return;
|
||||
}
|
||||
setDeleteOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
const ids = Array.from(selectedIds);
|
||||
setDeleting(true);
|
||||
try {
|
||||
const res = await deleteProcesses(ids);
|
||||
if (!res.success) {
|
||||
toast.error(res.message || "삭제에 실패했습니다.");
|
||||
return;
|
||||
}
|
||||
toast.success(`${ids.length}건 삭제되었습니다.`);
|
||||
setDeleteOpen(false);
|
||||
setSelectedIds(new Set());
|
||||
if (selectedProcess && ids.includes(selectedProcess.id)) {
|
||||
setSelectedProcess(null);
|
||||
}
|
||||
await loadProcesses();
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const availableEquipments = useMemo(() => {
|
||||
const used = new Set(processEquipments.map((e) => e.equipment_code));
|
||||
return equipmentMaster.filter((e) => !used.has(e.equipment_code));
|
||||
}, [equipmentMaster, processEquipments]);
|
||||
|
||||
const handleAddEquipment = async () => {
|
||||
if (!selectedProcess) return;
|
||||
if (!equipmentPick) {
|
||||
toast.message("추가할 설비를 선택하세요.");
|
||||
return;
|
||||
}
|
||||
setAddingEquipment(true);
|
||||
try {
|
||||
const res = await addProcessEquipment({
|
||||
process_code: selectedProcess.process_code,
|
||||
equipment_code: equipmentPick,
|
||||
});
|
||||
if (!res.success) {
|
||||
toast.error(res.message || "설비 추가에 실패했습니다.");
|
||||
return;
|
||||
}
|
||||
toast.success("설비가 등록되었습니다.");
|
||||
setEquipmentPick("");
|
||||
const listRes = await getProcessEquipments(selectedProcess.process_code);
|
||||
if (listRes.success && listRes.data) setProcessEquipments(listRes.data);
|
||||
} finally {
|
||||
setAddingEquipment(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveEquipment = async (row: ProcessEquipment) => {
|
||||
const res = await removeProcessEquipment(row.id);
|
||||
if (!res.success) {
|
||||
toast.error(res.message || "설비 제거에 실패했습니다.");
|
||||
return;
|
||||
}
|
||||
toast.success("설비가 제거되었습니다.");
|
||||
if (selectedProcess) {
|
||||
const listRes = await getProcessEquipments(selectedProcess.process_code);
|
||||
if (listRes.success && listRes.data) setProcessEquipments(listRes.data);
|
||||
}
|
||||
};
|
||||
|
||||
const listBusy = loadingInitial || loadingList;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[560px] flex-1 flex-col gap-3">
|
||||
<ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1 rounded-lg">
|
||||
<ResizablePanel defaultSize={50} minSize={30}>
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-lg border bg-card shadow-sm">
|
||||
<div className="flex shrink-0 flex-col gap-2 border-b bg-muted/30 p-3 sm:p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Settings className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
<span className="text-sm font-semibold sm:text-base">공정 마스터</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs sm:text-sm">공정코드</Label>
|
||||
<Input
|
||||
value={filterCode}
|
||||
onChange={(e) => setFilterCode(e.target.value)}
|
||||
placeholder="코드"
|
||||
className="h-8 w-[120px] text-xs sm:h-10 sm:w-[140px] sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs sm:text-sm">공정명</Label>
|
||||
<Input
|
||||
value={filterName}
|
||||
onChange={(e) => setFilterName(e.target.value)}
|
||||
placeholder="이름"
|
||||
className="h-8 w-[120px] text-xs sm:h-10 sm:w-[160px] sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs sm:text-sm">공정유형</Label>
|
||||
<Select value={filterType} onValueChange={setFilterType}>
|
||||
<SelectTrigger className="h-8 w-[120px] text-xs sm:h-10 sm:w-[130px] sm:text-sm">
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_VALUE} className="text-xs sm:text-sm">
|
||||
전체
|
||||
</SelectItem>
|
||||
{processTypeOptions.map((o, idx) => (
|
||||
<SelectItem key={`pt-filter-${idx}`} value={o.valueCode} className="text-xs sm:text-sm">
|
||||
{o.valueLabel}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs sm:text-sm">사용여부</Label>
|
||||
<Select value={filterUseYn} onValueChange={setFilterUseYn}>
|
||||
<SelectTrigger className="h-8 w-[100px] text-xs sm:h-10 sm:w-[110px] sm:text-sm">
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_VALUE} className="text-xs sm:text-sm">
|
||||
전체
|
||||
</SelectItem>
|
||||
<SelectItem value="Y" className="text-xs sm:text-sm">사용</SelectItem>
|
||||
<SelectItem value="N" className="text-xs sm:text-sm">미사용</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
onClick={handleResetFilters}
|
||||
>
|
||||
<RotateCcw className="mr-1 h-3.5 w-3.5" />
|
||||
초기화
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
onClick={handleSearch}
|
||||
disabled={listBusy}
|
||||
>
|
||||
<Search className="mr-1 h-3.5 w-3.5" />
|
||||
조회
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
onClick={openAdd}
|
||||
>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
공정 추가
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
onClick={openEdit}
|
||||
>
|
||||
<Pencil className="mr-1 h-3.5 w-3.5" />
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
onClick={openDelete}
|
||||
>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="p-2 sm:p-3">
|
||||
{listBusy ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
<p className="mt-2 text-xs sm:text-sm">불러오는 중...</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10 text-center">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={(v) => toggleAll(v === true)}
|
||||
aria-label="전체 선택"
|
||||
className="mx-auto"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-xs sm:text-sm">공정코드</TableHead>
|
||||
<TableHead className="text-xs sm:text-sm">공정명</TableHead>
|
||||
<TableHead className="text-xs sm:text-sm">공정유형</TableHead>
|
||||
<TableHead className="text-right text-xs sm:text-sm">표준시간(분)</TableHead>
|
||||
<TableHead className="text-right text-xs sm:text-sm">작업인원</TableHead>
|
||||
<TableHead className="text-center text-xs sm:text-sm">사용여부</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{processes.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="py-12 text-center text-muted-foreground">
|
||||
<p className="text-xs sm:text-sm">조회된 공정이 없습니다.</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
processes.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className={cn(
|
||||
"cursor-pointer transition-colors",
|
||||
selectedProcess?.id === row.id && "bg-accent"
|
||||
)}
|
||||
onClick={() => setSelectedProcess(row)}
|
||||
>
|
||||
<TableCell
|
||||
className="text-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedIds.has(row.id)}
|
||||
onCheckedChange={(v) => toggleOne(row.id, v === true)}
|
||||
aria-label={`${row.process_code} 선택`}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-medium sm:text-sm">
|
||||
{row.process_code}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs sm:text-sm">{row.process_name}</TableCell>
|
||||
<TableCell className="text-xs sm:text-sm">
|
||||
<Badge variant="secondary" className="text-[10px] sm:text-xs">
|
||||
{getProcessTypeLabel(row.process_type)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs sm:text-sm">
|
||||
{row.standard_time ?? "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs sm:text-sm">
|
||||
{row.worker_count ?? "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs sm:text-sm">
|
||||
<Badge
|
||||
variant={row.use_yn === "N" ? "outline" : "default"}
|
||||
className="text-[10px] sm:text-xs"
|
||||
>
|
||||
{row.use_yn === "Y" ? "사용" : "미사용"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel defaultSize={50} minSize={30}>
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-lg border bg-card shadow-sm">
|
||||
<div className="flex shrink-0 items-center gap-2 border-b bg-muted/30 px-3 py-2 sm:px-4 sm:py-3">
|
||||
<Wrench className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold sm:text-base">공정별 사용설비</p>
|
||||
{selectedProcess ? (
|
||||
<p className="truncate text-xs text-muted-foreground sm:text-sm">
|
||||
{selectedProcess.process_name}{" "}
|
||||
<span className="text-muted-foreground/80">({selectedProcess.process_code})</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground sm:text-sm">공정 미선택</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!selectedProcess ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-4 py-12 text-center text-muted-foreground">
|
||||
<Settings className="h-10 w-10 opacity-40" />
|
||||
<p className="text-sm font-medium text-foreground">좌측에서 공정을 선택하세요</p>
|
||||
<p className="max-w-xs text-xs sm:text-sm">
|
||||
목록 행을 클릭하면 이 공정에 연결된 설비를 관리할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 p-3 sm:p-4">
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="min-w-0 flex-1 space-y-1 sm:max-w-xs">
|
||||
<Label className="text-xs sm:text-sm">설비 선택</Label>
|
||||
<Select
|
||||
key={selectedProcess.id}
|
||||
value={equipmentPick || undefined}
|
||||
onValueChange={setEquipmentPick}
|
||||
disabled={addingEquipment || availableEquipments.length === 0}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-full text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="설비를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableEquipments.map((eq) => (
|
||||
<SelectItem
|
||||
key={eq.id}
|
||||
value={eq.equipment_code}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
{eq.equipment_code} · {eq.equipment_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
onClick={() => void handleAddEquipment()}
|
||||
disabled={addingEquipment || !equipmentPick}
|
||||
>
|
||||
{addingEquipment ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
{loadingEquipments ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Loader2 className="h-7 w-7 animate-spin" />
|
||||
<p className="mt-2 text-xs sm:text-sm">설비 목록 불러오는 중...</p>
|
||||
</div>
|
||||
) : processEquipments.length === 0 ? (
|
||||
<p className="py-8 text-center text-xs text-muted-foreground sm:text-sm">
|
||||
등록된 설비가 없습니다. 상단에서 설비를 추가하세요.
|
||||
</p>
|
||||
) : (
|
||||
<ScrollArea className="h-[min(420px,calc(100vh-20rem))] pr-3">
|
||||
<ul className="space-y-2">
|
||||
{processEquipments.map((pe) => (
|
||||
<li key={pe.id}>
|
||||
<Card className="rounded-lg border bg-card text-card-foreground shadow-sm">
|
||||
<CardContent className="flex items-center gap-3 p-3 sm:p-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-medium sm:text-sm">
|
||||
{pe.equipment_code}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground sm:text-sm">
|
||||
{pe.equipment_name || "설비명 없음"}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 text-xs sm:h-9 sm:text-sm"
|
||||
onClick={() => void handleRemoveEquipment(pe)}
|
||||
>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
제거
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
|
||||
<Dialog open={formOpen} onOpenChange={setFormOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
{formMode === "add" ? "공정 추가" : "공정 수정"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
공정 마스터 정보를 입력합니다. 표준시간과 작업인원은 숫자로 입력하세요.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="pm-process-name" className="text-xs sm:text-sm">
|
||||
공정명 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="pm-process-name"
|
||||
value={formProcessName}
|
||||
onChange={(e) => setFormProcessName(e.target.value)}
|
||||
placeholder="공정명"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">공정유형</Label>
|
||||
<Select value={formProcessType} onValueChange={setFormProcessType}>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{processTypeOptions.map((o, idx) => (
|
||||
<SelectItem key={`pt-form-${idx}`} value={o.valueCode} className="text-xs sm:text-sm">
|
||||
{o.valueLabel}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="pm-standard-time" className="text-xs sm:text-sm">
|
||||
표준작업시간(분)
|
||||
</Label>
|
||||
<Input
|
||||
id="pm-standard-time"
|
||||
value={formStandardTime}
|
||||
onChange={(e) => setFormStandardTime(e.target.value)}
|
||||
placeholder="0"
|
||||
inputMode="numeric"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="pm-worker-count" className="text-xs sm:text-sm">
|
||||
작업인원수
|
||||
</Label>
|
||||
<Input
|
||||
id="pm-worker-count"
|
||||
value={formWorkerCount}
|
||||
onChange={(e) => setFormWorkerCount(e.target.value)}
|
||||
placeholder="0"
|
||||
inputMode="numeric"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">사용여부</Label>
|
||||
<Select value={formUseYn} onValueChange={setFormUseYn}>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Y" className="text-xs sm:text-sm">사용</SelectItem>
|
||||
<SelectItem value="N" className="text-xs sm:text-sm">미사용</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setFormOpen(false)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
disabled={savingForm}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void submitForm()}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
disabled={savingForm}
|
||||
>
|
||||
{savingForm ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">공정 삭제</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
선택한 {selectedIds.size}건의 공정을 삭제합니다. 연결된 공정-설비 매핑도 함께 삭제됩니다. 이 작업은
|
||||
되돌릴 수 없습니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setDeleteOpen(false)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
disabled={deleting}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => void confirmDelete()}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
삭제
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
"use client";
|
||||
|
||||
import { ProcessWorkStandardComponent } from "@/lib/registry/components/v2-process-work-standard/ProcessWorkStandardComponent";
|
||||
|
||||
export function ProcessWorkStandardTab() {
|
||||
return (
|
||||
<div className="h-[calc(100vh-12rem)]">
|
||||
<ProcessWorkStandardComponent
|
||||
config={{
|
||||
itemListMode: "registered",
|
||||
screenCode: "screen_1599",
|
||||
leftPanelTitle: "등록 품목 및 공정",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Settings, GitBranch, ClipboardList } from "lucide-react";
|
||||
import { ProcessMasterTab } from "./ProcessMasterTab";
|
||||
import { ItemRoutingTab } from "./ItemRoutingTab";
|
||||
import { ProcessWorkStandardTab } from "./ProcessWorkStandardTab";
|
||||
|
||||
export default function ProcessInfoPage() {
|
||||
const [activeTab, setActiveTab] = useState("process");
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col bg-muted/30">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex h-full flex-col">
|
||||
<div className="shrink-0 border-b bg-background px-4">
|
||||
<TabsList className="h-12 bg-transparent gap-1">
|
||||
<TabsTrigger
|
||||
value="process"
|
||||
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4"
|
||||
>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
공정 마스터
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="routing"
|
||||
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4"
|
||||
>
|
||||
<GitBranch className="mr-2 h-4 w-4" />
|
||||
품목별 라우팅
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="workstandard"
|
||||
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4"
|
||||
>
|
||||
<ClipboardList className="mr-2 h-4 w-4" />
|
||||
공정 작업기준
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="process" className="flex-1 overflow-hidden mt-0">
|
||||
<ProcessMasterTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="routing" className="flex-1 overflow-hidden mt-0">
|
||||
<ItemRoutingTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="workstandard" className="flex-1 overflow-hidden mt-0">
|
||||
<ProcessWorkStandardTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,539 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Loader2, Save, RotateCcw, Plus, Trash2, Pencil, ClipboardCheck,
|
||||
ChevronRight, GripVertical, AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
getWIWorkStandard, copyWorkStandard, saveWIWorkStandard, resetWIWorkStandard,
|
||||
WIWorkItem, WIWorkItemDetail, WIWorkStandardProcess,
|
||||
} from "@/lib/api/workInstruction";
|
||||
|
||||
interface WorkStandardEditModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
workInstructionNo: string;
|
||||
routingVersionId: string;
|
||||
routingName: string;
|
||||
itemName: string;
|
||||
itemCode: string;
|
||||
}
|
||||
|
||||
const PHASES = [
|
||||
{ key: "PRE", label: "사전작업" },
|
||||
{ key: "MAIN", label: "본작업" },
|
||||
{ key: "POST", label: "후작업" },
|
||||
];
|
||||
|
||||
const DETAIL_TYPES = [
|
||||
{ value: "checklist", label: "체크리스트" },
|
||||
{ value: "inspection", label: "검사항목" },
|
||||
{ value: "procedure", label: "작업절차" },
|
||||
{ value: "input", label: "직접입력" },
|
||||
{ value: "lookup", label: "문서참조" },
|
||||
{ value: "equip_inspection", label: "설비점검" },
|
||||
{ value: "equip_condition", label: "설비조건" },
|
||||
{ value: "production_result", label: "실적등록" },
|
||||
{ value: "material_input", label: "자재투입" },
|
||||
];
|
||||
|
||||
export function WorkStandardEditModal({
|
||||
open,
|
||||
onClose,
|
||||
workInstructionNo,
|
||||
routingVersionId,
|
||||
routingName,
|
||||
itemName,
|
||||
itemCode,
|
||||
}: WorkStandardEditModalProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [processes, setProcesses] = useState<WIWorkStandardProcess[]>([]);
|
||||
const [isCustom, setIsCustom] = useState(false);
|
||||
const [selectedProcessIdx, setSelectedProcessIdx] = useState(0);
|
||||
const [selectedPhase, setSelectedPhase] = useState("PRE");
|
||||
const [selectedWorkItemId, setSelectedWorkItemId] = useState<string | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
// 작업항목 추가 모달
|
||||
const [addItemOpen, setAddItemOpen] = useState(false);
|
||||
const [addItemTitle, setAddItemTitle] = useState("");
|
||||
const [addItemRequired, setAddItemRequired] = useState("Y");
|
||||
|
||||
// 상세 추가 모달
|
||||
const [addDetailOpen, setAddDetailOpen] = useState(false);
|
||||
const [addDetailType, setAddDetailType] = useState("checklist");
|
||||
const [addDetailContent, setAddDetailContent] = useState("");
|
||||
const [addDetailRequired, setAddDetailRequired] = useState("N");
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
if (!workInstructionNo || !routingVersionId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getWIWorkStandard(workInstructionNo, routingVersionId);
|
||||
if (res.success && res.data) {
|
||||
setProcesses(res.data.processes);
|
||||
setIsCustom(res.data.isCustom);
|
||||
setSelectedProcessIdx(0);
|
||||
setSelectedPhase("PRE");
|
||||
setSelectedWorkItemId(null);
|
||||
setDirty(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("공정작업기준 로드 실패", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [workInstructionNo, routingVersionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) loadData();
|
||||
}, [open, loadData]);
|
||||
|
||||
const currentProcess = processes[selectedProcessIdx] || null;
|
||||
const currentWorkItems = useMemo(() => {
|
||||
if (!currentProcess) return [];
|
||||
return currentProcess.workItems.filter(wi => wi.work_phase === selectedPhase);
|
||||
}, [currentProcess, selectedPhase]);
|
||||
|
||||
const selectedWorkItem = useMemo(() => {
|
||||
if (!selectedWorkItemId || !currentProcess) return null;
|
||||
return currentProcess.workItems.find(wi => wi.id === selectedWorkItemId) || null;
|
||||
}, [selectedWorkItemId, currentProcess]);
|
||||
|
||||
// 커스텀 복사 확인 후 수정
|
||||
const ensureCustom = useCallback(async () => {
|
||||
if (isCustom) return true;
|
||||
try {
|
||||
const res = await copyWorkStandard(workInstructionNo, routingVersionId);
|
||||
if (res.success) {
|
||||
await loadData();
|
||||
setIsCustom(true);
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error("원본 복사에 실패했습니다");
|
||||
}
|
||||
return false;
|
||||
}, [isCustom, workInstructionNo, routingVersionId, loadData]);
|
||||
|
||||
// 작업항목 추가
|
||||
const handleAddWorkItem = useCallback(async () => {
|
||||
if (!addItemTitle.trim()) { toast.error("제목을 입력하세요"); return; }
|
||||
const ok = await ensureCustom();
|
||||
if (!ok || !currentProcess) return;
|
||||
|
||||
const newItem: WIWorkItem = {
|
||||
id: `temp-${Date.now()}`,
|
||||
routing_detail_id: currentProcess.routing_detail_id,
|
||||
work_phase: selectedPhase,
|
||||
title: addItemTitle.trim(),
|
||||
is_required: addItemRequired,
|
||||
sort_order: currentWorkItems.length + 1,
|
||||
details: [],
|
||||
};
|
||||
|
||||
setProcesses(prev => {
|
||||
const next = [...prev];
|
||||
next[selectedProcessIdx] = {
|
||||
...next[selectedProcessIdx],
|
||||
workItems: [...next[selectedProcessIdx].workItems, newItem],
|
||||
};
|
||||
return next;
|
||||
});
|
||||
|
||||
setAddItemTitle("");
|
||||
setAddItemRequired("Y");
|
||||
setAddItemOpen(false);
|
||||
setDirty(true);
|
||||
setSelectedWorkItemId(newItem.id!);
|
||||
}, [addItemTitle, addItemRequired, ensureCustom, currentProcess, selectedPhase, currentWorkItems, selectedProcessIdx]);
|
||||
|
||||
// 작업항목 삭제
|
||||
const handleDeleteWorkItem = useCallback(async (id: string) => {
|
||||
const ok = await ensureCustom();
|
||||
if (!ok) return;
|
||||
|
||||
setProcesses(prev => {
|
||||
const next = [...prev];
|
||||
next[selectedProcessIdx] = {
|
||||
...next[selectedProcessIdx],
|
||||
workItems: next[selectedProcessIdx].workItems.filter(wi => wi.id !== id),
|
||||
};
|
||||
return next;
|
||||
});
|
||||
if (selectedWorkItemId === id) setSelectedWorkItemId(null);
|
||||
setDirty(true);
|
||||
}, [ensureCustom, selectedProcessIdx, selectedWorkItemId]);
|
||||
|
||||
// 상세 추가
|
||||
const handleAddDetail = useCallback(async () => {
|
||||
if (!addDetailContent.trim() && addDetailType !== "production_result" && addDetailType !== "material_input") {
|
||||
toast.error("내용을 입력하세요");
|
||||
return;
|
||||
}
|
||||
if (!selectedWorkItemId) return;
|
||||
const ok = await ensureCustom();
|
||||
if (!ok) return;
|
||||
|
||||
const content = addDetailContent.trim() ||
|
||||
DETAIL_TYPES.find(d => d.value === addDetailType)?.label || addDetailType;
|
||||
|
||||
const newDetail: WIWorkItemDetail = {
|
||||
id: `temp-detail-${Date.now()}`,
|
||||
work_item_id: selectedWorkItemId,
|
||||
detail_type: addDetailType,
|
||||
content,
|
||||
is_required: addDetailRequired,
|
||||
sort_order: (selectedWorkItem?.details?.length || 0) + 1,
|
||||
};
|
||||
|
||||
setProcesses(prev => {
|
||||
const next = [...prev];
|
||||
const workItems = [...next[selectedProcessIdx].workItems];
|
||||
const wiIdx = workItems.findIndex(wi => wi.id === selectedWorkItemId);
|
||||
if (wiIdx >= 0) {
|
||||
workItems[wiIdx] = {
|
||||
...workItems[wiIdx],
|
||||
details: [...(workItems[wiIdx].details || []), newDetail],
|
||||
detail_count: (workItems[wiIdx].detail_count || 0) + 1,
|
||||
};
|
||||
}
|
||||
next[selectedProcessIdx] = { ...next[selectedProcessIdx], workItems };
|
||||
return next;
|
||||
});
|
||||
|
||||
setAddDetailContent("");
|
||||
setAddDetailType("checklist");
|
||||
setAddDetailRequired("N");
|
||||
setAddDetailOpen(false);
|
||||
setDirty(true);
|
||||
}, [addDetailContent, addDetailType, addDetailRequired, selectedWorkItemId, selectedWorkItem, ensureCustom, selectedProcessIdx]);
|
||||
|
||||
// 상세 삭제
|
||||
const handleDeleteDetail = useCallback(async (detailId: string) => {
|
||||
if (!selectedWorkItemId) return;
|
||||
const ok = await ensureCustom();
|
||||
if (!ok) return;
|
||||
|
||||
setProcesses(prev => {
|
||||
const next = [...prev];
|
||||
const workItems = [...next[selectedProcessIdx].workItems];
|
||||
const wiIdx = workItems.findIndex(wi => wi.id === selectedWorkItemId);
|
||||
if (wiIdx >= 0) {
|
||||
workItems[wiIdx] = {
|
||||
...workItems[wiIdx],
|
||||
details: (workItems[wiIdx].details || []).filter(d => d.id !== detailId),
|
||||
detail_count: Math.max(0, (workItems[wiIdx].detail_count || 1) - 1),
|
||||
};
|
||||
}
|
||||
next[selectedProcessIdx] = { ...next[selectedProcessIdx], workItems };
|
||||
return next;
|
||||
});
|
||||
setDirty(true);
|
||||
}, [selectedWorkItemId, ensureCustom, selectedProcessIdx]);
|
||||
|
||||
// 저장
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!currentProcess) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const ok = await ensureCustom();
|
||||
if (!ok) return;
|
||||
|
||||
const res = await saveWIWorkStandard(
|
||||
workInstructionNo,
|
||||
currentProcess.routing_detail_id,
|
||||
currentProcess.workItems
|
||||
);
|
||||
if (res.success) {
|
||||
toast.success("공정작업기준이 저장되었습니다");
|
||||
setDirty(false);
|
||||
await loadData();
|
||||
} else {
|
||||
toast.error("저장에 실패했습니다");
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error("저장 중 오류가 발생했습니다");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [currentProcess, ensureCustom, workInstructionNo, loadData]);
|
||||
|
||||
// 원본으로 초기화
|
||||
const handleReset = useCallback(async () => {
|
||||
if (!confirm("커스터마이징한 내용을 모두 삭제하고 원본으로 되돌리시겠습니까?")) return;
|
||||
try {
|
||||
const res = await resetWIWorkStandard(workInstructionNo);
|
||||
if (res.success) {
|
||||
toast.success("원본으로 초기화되었습니다");
|
||||
await loadData();
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error("초기화에 실패했습니다");
|
||||
}
|
||||
}, [workInstructionNo, loadData]);
|
||||
|
||||
const getDetailTypeLabel = (type: string) =>
|
||||
DETAIL_TYPES.find(d => d.value === type)?.label || type;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={v => { if (!v) onClose(); }}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[1200px] h-[85vh] flex flex-col p-0 gap-0">
|
||||
<DialogHeader className="px-6 py-4 border-b shrink-0">
|
||||
<DialogTitle className="text-base flex items-center gap-2">
|
||||
<ClipboardCheck className="w-4 h-4" />
|
||||
공정작업기준 수정 - {itemName}
|
||||
{routingName && <Badge variant="secondary" className="text-xs ml-2">{routingName}</Badge>}
|
||||
{isCustom && <Badge variant="outline" className="text-xs ml-1 border-amber-300 text-amber-700">커스텀</Badge>}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
작업지시 [{workInstructionNo}]에 대한 공정작업기준을 수정합니다. 원본에 영향을 주지 않습니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : processes.length === 0 ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground">
|
||||
<AlertCircle className="w-10 h-10 mb-3 opacity-30" />
|
||||
<p className="text-sm">라우팅에 등록된 공정이 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* 공정 탭 */}
|
||||
<div className="flex items-center gap-1 px-4 py-2 border-b bg-muted/30 overflow-x-auto shrink-0">
|
||||
{processes.map((proc, idx) => (
|
||||
<Button
|
||||
key={proc.routing_detail_id}
|
||||
variant={selectedProcessIdx === idx ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className={cn("text-xs shrink-0 h-8", selectedProcessIdx === idx && "shadow-sm")}
|
||||
onClick={() => {
|
||||
setSelectedProcessIdx(idx);
|
||||
setSelectedWorkItemId(null);
|
||||
}}
|
||||
>
|
||||
<span className="mr-1.5 font-mono text-[10px] opacity-70">{proc.seq_no}.</span>
|
||||
{proc.process_name}
|
||||
{proc.workItems.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 text-[10px] h-4 px-1">{proc.workItems.length}</Badge>
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 작업 단계 탭 */}
|
||||
<div className="flex items-center gap-1 px-4 py-2 border-b shrink-0">
|
||||
{PHASES.map(phase => {
|
||||
const count = currentProcess?.workItems.filter(wi => wi.work_phase === phase.key).length || 0;
|
||||
return (
|
||||
<Button
|
||||
key={phase.key}
|
||||
variant={selectedPhase === phase.key ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="text-xs h-7"
|
||||
onClick={() => { setSelectedPhase(phase.key); setSelectedWorkItemId(null); }}
|
||||
>
|
||||
{phase.label}
|
||||
{count > 0 && <Badge variant="outline" className="ml-1 text-[10px] h-4 px-1">{count}</Badge>}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 작업항목 + 상세 split */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* 좌측: 작업항목 목록 */}
|
||||
<div className="w-[280px] shrink-0 border-r flex flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/20 shrink-0">
|
||||
<span className="text-xs font-semibold">작업항목</span>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => { setAddItemTitle(""); setAddItemOpen(true); }}>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{currentWorkItems.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground text-center py-6">작업항목이 없습니다</div>
|
||||
) : currentWorkItems.map((wi) => (
|
||||
<div
|
||||
key={wi.id}
|
||||
className={cn(
|
||||
"group rounded-md border p-2.5 cursor-pointer transition-colors",
|
||||
selectedWorkItemId === wi.id ? "border-primary bg-primary/5" : "hover:bg-muted/50"
|
||||
)}
|
||||
onClick={() => setSelectedWorkItemId(wi.id!)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-medium truncate">{wi.title}</div>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
{wi.is_required === "Y" && <Badge variant="destructive" className="text-[9px] h-4 px-1">필수</Badge>}
|
||||
<span className="text-[10px] text-muted-foreground">상세 {wi.details?.length || wi.detail_count || 0}건</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost" size="icon"
|
||||
className="h-5 w-5 opacity-0 group-hover:opacity-100 shrink-0"
|
||||
onClick={e => { e.stopPropagation(); handleDeleteWorkItem(wi.id!); }}
|
||||
>
|
||||
<Trash2 className="w-3 h-3 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측: 상세 목록 */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{!selectedWorkItem ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground">
|
||||
<ChevronRight className="w-8 h-8 mb-2 opacity-20" />
|
||||
<p className="text-xs">좌측에서 작업항목을 선택하세요</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b bg-muted/20 shrink-0">
|
||||
<div>
|
||||
<span className="text-xs font-semibold">{selectedWorkItem.title}</span>
|
||||
<span className="text-[10px] text-muted-foreground ml-2">상세 항목</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-6 text-xs" onClick={() => { setAddDetailContent(""); setAddDetailType("checklist"); setAddDetailOpen(true); }}>
|
||||
<Plus className="w-3 h-3 mr-1" /> 상세 추가
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
{(!selectedWorkItem.details || selectedWorkItem.details.length === 0) ? (
|
||||
<div className="text-xs text-muted-foreground text-center py-8">상세 항목이 없습니다</div>
|
||||
) : selectedWorkItem.details.map((detail, dIdx) => (
|
||||
<div key={detail.id || dIdx} className="group flex items-start gap-2 rounded-md border p-3 hover:bg-muted/30">
|
||||
<GripVertical className="w-3.5 h-3.5 mt-0.5 text-muted-foreground/30 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-[10px] h-4 px-1.5 shrink-0">
|
||||
{getDetailTypeLabel(detail.detail_type || "checklist")}
|
||||
</Badge>
|
||||
{detail.is_required === "Y" && <Badge variant="destructive" className="text-[9px] h-4 px-1">필수</Badge>}
|
||||
</div>
|
||||
<p className="text-xs mt-1 break-all">{detail.content || "-"}</p>
|
||||
{detail.remark && <p className="text-[10px] text-muted-foreground mt-0.5">{detail.remark}</p>}
|
||||
{detail.detail_type === "inspection" && (detail.lower_limit || detail.upper_limit) && (
|
||||
<div className="text-[10px] text-muted-foreground mt-1">
|
||||
범위: {detail.lower_limit || "-"} ~ {detail.upper_limit || "-"} {detail.unit || ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost" size="icon"
|
||||
className="h-5 w-5 opacity-0 group-hover:opacity-100 shrink-0"
|
||||
onClick={() => handleDeleteDetail(detail.id!)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="px-6 py-3 border-t shrink-0 flex items-center justify-between">
|
||||
<div>
|
||||
{isCustom && (
|
||||
<Button variant="outline" size="sm" className="text-xs" onClick={handleReset}>
|
||||
<RotateCcw className="w-3.5 h-3.5 mr-1.5" /> 원본으로 초기화
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={onClose}>닫기</Button>
|
||||
<Button onClick={handleSave} disabled={saving || (!dirty && isCustom)}>
|
||||
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
|
||||
{/* 작업항목 추가 다이얼로그 */}
|
||||
<Dialog open={addItemOpen} onOpenChange={setAddItemOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[400px]" onClick={e => e.stopPropagation()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base">작업항목 추가</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
{PHASES.find(p => p.key === selectedPhase)?.label} 단계에 작업항목을 추가합니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">제목 *</Label>
|
||||
<Input value={addItemTitle} onChange={e => setAddItemTitle(e.target.value)} placeholder="작업항목 제목" className="h-8 text-xs mt-1" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox checked={addItemRequired === "Y"} onCheckedChange={v => setAddItemRequired(v ? "Y" : "N")} />
|
||||
<Label className="text-xs">필수 항목</Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setAddItemOpen(false)}>취소</Button>
|
||||
<Button size="sm" onClick={handleAddWorkItem}>추가</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 상세 추가 다이얼로그 */}
|
||||
<Dialog open={addDetailOpen} onOpenChange={setAddDetailOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[450px]" onClick={e => e.stopPropagation()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base">상세 항목 추가</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
"{selectedWorkItem?.title}"에 상세 항목을 추가합니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">유형</Label>
|
||||
<Select value={addDetailType} onValueChange={setAddDetailType}>
|
||||
<SelectTrigger className="h-8 text-xs mt-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{DETAIL_TYPES.map(dt => (
|
||||
<SelectItem key={dt.value} value={dt.value} className="text-xs">{dt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">내용</Label>
|
||||
<Input value={addDetailContent} onChange={e => setAddDetailContent(e.target.value)} placeholder="상세 내용 입력" className="h-8 text-xs mt-1" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox checked={addDetailRequired === "Y"} onCheckedChange={v => setAddDetailRequired(v ? "Y" : "N")} />
|
||||
<Label className="text-xs">필수 항목</Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setAddDetailOpen(false)}>취소</Button>
|
||||
<Button size="sm" onClick={handleAddDetail}>추가</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,780 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Plus, Trash2, RotateCcw, Save, X, ChevronLeft, ChevronRight, Search, Loader2, Wrench, Pencil, CheckCircle2, ArrowRight, Check, ChevronsUpDown, ClipboardCheck } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
|
||||
import {
|
||||
getWorkInstructionList, previewWorkInstructionNo, saveWorkInstruction, deleteWorkInstructions,
|
||||
getWIItemSource, getWISalesOrderSource, getWIProductionPlanSource, getEquipmentList, getEmployeeList,
|
||||
getRoutingVersions, RoutingVersionData,
|
||||
} from "@/lib/api/workInstruction";
|
||||
import { WorkStandardEditModal } from "./WorkStandardEditModal";
|
||||
|
||||
type SourceType = "production" | "order" | "item";
|
||||
|
||||
const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
|
||||
"일반": { label: "일반", cls: "bg-blue-100 text-blue-800 border-blue-200" },
|
||||
"긴급": { label: "긴급", cls: "bg-red-100 text-red-800 border-red-200" },
|
||||
};
|
||||
const PROGRESS_BADGE: Record<string, { label: string; cls: string }> = {
|
||||
"대기": { label: "대기", cls: "bg-amber-100 text-amber-800" },
|
||||
"진행중": { label: "진행중", cls: "bg-blue-100 text-blue-800" },
|
||||
"완료": { label: "완료", cls: "bg-emerald-100 text-emerald-800" },
|
||||
};
|
||||
|
||||
interface EquipmentOption { id: string; equipment_code: string; equipment_name: string; }
|
||||
interface EmployeeOption { user_id: string; user_name: string; dept_name: string | null; }
|
||||
interface SelectedItem {
|
||||
itemCode: string; itemName: string; spec: string; qty: number; remark: string;
|
||||
sourceType: SourceType; sourceTable: string; sourceId: string | number;
|
||||
}
|
||||
|
||||
export default function WorkInstructionPage() {
|
||||
const [orders, setOrders] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [equipmentOptions, setEquipmentOptions] = useState<EquipmentOption[]>([]);
|
||||
const [employeeOptions, setEmployeeOptions] = useState<EmployeeOption[]>([]);
|
||||
|
||||
// 검색
|
||||
const [searchKeyword, setSearchKeyword] = useState("");
|
||||
const [debouncedKeyword, setDebouncedKeyword] = useState("");
|
||||
const [searchStatus, setSearchStatus] = useState("all");
|
||||
const [searchProgress, setSearchProgress] = useState("all");
|
||||
const [searchDateFrom, setSearchDateFrom] = useState("");
|
||||
const [searchDateTo, setSearchDateTo] = useState("");
|
||||
|
||||
// 1단계: 등록 모달
|
||||
const [isRegModalOpen, setIsRegModalOpen] = useState(false);
|
||||
const [regSourceType, setRegSourceType] = useState<SourceType | "">("");
|
||||
const [regSourceData, setRegSourceData] = useState<any[]>([]);
|
||||
const [regSourceLoading, setRegSourceLoading] = useState(false);
|
||||
const [regKeyword, setRegKeyword] = useState("");
|
||||
const [regCheckedIds, setRegCheckedIds] = useState<Set<string>>(new Set());
|
||||
const [regMergeSameItem, setRegMergeSameItem] = useState(true);
|
||||
const [regPage, setRegPage] = useState(1);
|
||||
const [regPageSize] = useState(20);
|
||||
const [regTotalCount, setRegTotalCount] = useState(0);
|
||||
|
||||
// 2단계: 확인 모달
|
||||
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false);
|
||||
const [confirmItems, setConfirmItems] = useState<SelectedItem[]>([]);
|
||||
const [confirmWiNo, setConfirmWiNo] = useState("");
|
||||
const [confirmStatus, setConfirmStatus] = useState("일반");
|
||||
const [confirmStartDate, setConfirmStartDate] = useState("");
|
||||
const [confirmEndDate, setConfirmEndDate] = useState("");
|
||||
const nv = (v: string) => v || "none";
|
||||
const fromNv = (v: string) => v === "none" ? "" : v;
|
||||
const [confirmEquipmentId, setConfirmEquipmentId] = useState("");
|
||||
const [confirmWorkTeam, setConfirmWorkTeam] = useState("");
|
||||
const [confirmWorker, setConfirmWorker] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 수정 모달
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [editOrder, setEditOrder] = useState<any>(null);
|
||||
const [editItems, setEditItems] = useState<SelectedItem[]>([]);
|
||||
const [editStatus, setEditStatus] = useState("일반");
|
||||
const [editStartDate, setEditStartDate] = useState("");
|
||||
const [editEndDate, setEditEndDate] = useState("");
|
||||
const [editEquipmentId, setEditEquipmentId] = useState("");
|
||||
const [editWorkTeam, setEditWorkTeam] = useState("");
|
||||
const [editWorker, setEditWorker] = useState("");
|
||||
const [editRemark, setEditRemark] = useState("");
|
||||
const [editSaving, setEditSaving] = useState(false);
|
||||
const [addQty, setAddQty] = useState("");
|
||||
const [addEquipment, setAddEquipment] = useState("");
|
||||
const [addWorkTeam, setAddWorkTeam] = useState("");
|
||||
const [addWorker, setAddWorker] = useState("");
|
||||
const [confirmWorkerOpen, setConfirmWorkerOpen] = useState(false);
|
||||
const [editWorkerOpen, setEditWorkerOpen] = useState(false);
|
||||
const [addWorkerOpen, setAddWorkerOpen] = useState(false);
|
||||
|
||||
// 라우팅 관련 상태
|
||||
const [confirmRouting, setConfirmRouting] = useState("");
|
||||
const [confirmRoutingOptions, setConfirmRoutingOptions] = useState<RoutingVersionData[]>([]);
|
||||
const [editRouting, setEditRouting] = useState("");
|
||||
const [editRoutingOptions, setEditRoutingOptions] = useState<RoutingVersionData[]>([]);
|
||||
|
||||
// 공정작업기준 모달 상태
|
||||
const [wsModalOpen, setWsModalOpen] = useState(false);
|
||||
const [wsModalWiNo, setWsModalWiNo] = useState("");
|
||||
const [wsModalRoutingId, setWsModalRoutingId] = useState("");
|
||||
const [wsModalRoutingName, setWsModalRoutingName] = useState("");
|
||||
const [wsModalItemName, setWsModalItemName] = useState("");
|
||||
const [wsModalItemCode, setWsModalItemCode] = useState("");
|
||||
|
||||
useEffect(() => { const t = setTimeout(() => setDebouncedKeyword(searchKeyword), 500); return () => clearTimeout(t); }, [searchKeyword]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
getEquipmentList().then(r => { if (r.success) setEquipmentOptions(r.data || []); });
|
||||
getEmployeeList().then(r => { if (r.success) setEmployeeOptions(r.data || []); });
|
||||
}, []);
|
||||
|
||||
const fetchOrders = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {};
|
||||
if (searchDateFrom) params.dateFrom = searchDateFrom;
|
||||
if (searchDateTo) params.dateTo = searchDateTo;
|
||||
if (searchStatus !== "all") params.status = searchStatus;
|
||||
if (searchProgress !== "all") params.progressStatus = searchProgress;
|
||||
if (debouncedKeyword.trim()) params.keyword = debouncedKeyword.trim();
|
||||
const r = await getWorkInstructionList(params);
|
||||
if (r.success) setOrders(r.data || []);
|
||||
} catch {} finally { setLoading(false); }
|
||||
}, [searchDateFrom, searchDateTo, searchStatus, searchProgress, debouncedKeyword]);
|
||||
|
||||
useEffect(() => { fetchOrders(); }, [fetchOrders]);
|
||||
|
||||
const handleResetSearch = () => {
|
||||
setSearchKeyword(""); setDebouncedKeyword(""); setSearchStatus("all"); setSearchProgress("all");
|
||||
setSearchDateFrom(""); setSearchDateTo("");
|
||||
};
|
||||
|
||||
// ─── 1단계 등록 ───
|
||||
const openRegModal = () => {
|
||||
setRegSourceType("production"); setRegSourceData([]); setRegKeyword(""); setRegCheckedIds(new Set());
|
||||
setRegPage(1); setRegTotalCount(0); setRegMergeSameItem(true); setIsRegModalOpen(true);
|
||||
};
|
||||
|
||||
const fetchRegSource = useCallback(async (pageOverride?: number) => {
|
||||
if (!regSourceType) return;
|
||||
setRegSourceLoading(true);
|
||||
try {
|
||||
const p = pageOverride ?? regPage;
|
||||
const params: any = { page: p, pageSize: regPageSize };
|
||||
if (regKeyword.trim()) params.keyword = regKeyword.trim();
|
||||
let r;
|
||||
switch (regSourceType) {
|
||||
case "production": r = await getWIProductionPlanSource(params); break;
|
||||
case "order": r = await getWISalesOrderSource(params); break;
|
||||
case "item": r = await getWIItemSource(params); break;
|
||||
}
|
||||
if (r?.success) { setRegSourceData(r.data || []); setRegTotalCount(r.totalCount || 0); }
|
||||
} catch {} finally { setRegSourceLoading(false); }
|
||||
}, [regSourceType, regKeyword, regPage, regPageSize]);
|
||||
|
||||
useEffect(() => { if (isRegModalOpen && regSourceType) { setRegPage(1); setRegCheckedIds(new Set()); fetchRegSource(1); } }, [regSourceType]);
|
||||
|
||||
const getRegId = (item: any) => regSourceType === "item" ? (item.item_code || item.id) : String(item.id);
|
||||
const toggleRegItem = (id: string) => { setRegCheckedIds(prev => { const n = new Set(prev); if (n.has(id)) n.delete(id); else n.add(id); return n; }); };
|
||||
const toggleRegAll = () => { if (regCheckedIds.size === regSourceData.length) setRegCheckedIds(new Set()); else setRegCheckedIds(new Set(regSourceData.map(getRegId))); };
|
||||
|
||||
const applyRegistration = () => {
|
||||
if (regCheckedIds.size === 0) { alert("품목을 선택해주세요."); return; }
|
||||
const items: SelectedItem[] = [];
|
||||
for (const item of regSourceData) {
|
||||
if (!regCheckedIds.has(getRegId(item))) continue;
|
||||
if (regSourceType === "item") items.push({ itemCode: item.item_code, itemName: item.item_name || "", spec: item.spec || "", qty: 1, remark: "", sourceType: "item", sourceTable: "item_info", sourceId: item.item_code });
|
||||
else if (regSourceType === "order") items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: item.spec || "", qty: Number(item.qty || 1), remark: "", sourceType: "order", sourceTable: "sales_order_detail", sourceId: item.id });
|
||||
else items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: Number(item.plan_qty || 1), remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id });
|
||||
}
|
||||
|
||||
// 동일품목 합산
|
||||
if (regMergeSameItem) {
|
||||
const merged = new Map<string, SelectedItem>();
|
||||
for (const it of items) {
|
||||
const key = it.itemCode;
|
||||
if (merged.has(key)) { merged.get(key)!.qty += it.qty; }
|
||||
else { merged.set(key, { ...it }); }
|
||||
}
|
||||
setConfirmItems(Array.from(merged.values()));
|
||||
} else {
|
||||
setConfirmItems(items);
|
||||
}
|
||||
|
||||
setConfirmWiNo("불러오는 중...");
|
||||
setConfirmStatus("일반"); setConfirmStartDate(new Date().toISOString().split("T")[0]);
|
||||
setConfirmEndDate(""); setConfirmEquipmentId(""); setConfirmWorkTeam(""); setConfirmWorker("");
|
||||
setConfirmRouting(""); setConfirmRoutingOptions([]);
|
||||
previewWorkInstructionNo().then(r => { if (r.success) setConfirmWiNo(r.instructionNo); else setConfirmWiNo("(자동생성)"); }).catch(() => setConfirmWiNo("(자동생성)"));
|
||||
|
||||
// 첫 번째 품목의 라우팅 로드
|
||||
const firstItem = items.length > 0 ? items[0] : null;
|
||||
if (firstItem) {
|
||||
getRoutingVersions("__new__", firstItem.itemCode).then(r => {
|
||||
if (r.success && r.data) {
|
||||
setConfirmRoutingOptions(r.data);
|
||||
const defaultRouting = r.data.find(rv => rv.is_default);
|
||||
if (defaultRouting) setConfirmRouting(defaultRouting.id);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
setIsRegModalOpen(false); setIsConfirmModalOpen(true);
|
||||
};
|
||||
|
||||
// ─── 2단계 최종 적용 ───
|
||||
const finalizeRegistration = async () => {
|
||||
if (confirmItems.length === 0) { alert("품목이 없습니다."); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
status: confirmStatus, startDate: confirmStartDate, endDate: confirmEndDate,
|
||||
equipmentId: confirmEquipmentId, workTeam: confirmWorkTeam, worker: confirmWorker,
|
||||
routing: confirmRouting || null,
|
||||
items: confirmItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode })),
|
||||
};
|
||||
const r = await saveWorkInstruction(payload);
|
||||
if (r.success) { setIsConfirmModalOpen(false); fetchOrders(); alert("작업지시가 등록되었습니다."); }
|
||||
else alert(r.message || "저장 실패");
|
||||
} catch (e: any) { alert(e.message || "저장 실패"); } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
// ─── 수정 모달 ───
|
||||
const openEditModal = (order: any) => {
|
||||
const wiNo = order.work_instruction_no;
|
||||
const relatedDetails = orders.filter(o => o.work_instruction_no === wiNo);
|
||||
setEditOrder(order); setEditStatus(order.status || "일반");
|
||||
setEditStartDate(order.start_date || ""); setEditEndDate(order.end_date || "");
|
||||
setEditEquipmentId(order.equipment_id || ""); setEditWorkTeam(order.work_team || "");
|
||||
setEditWorker(order.worker || ""); setEditRemark(order.wi_remark || "");
|
||||
setEditItems(relatedDetails.map((d: any) => ({
|
||||
itemCode: d.item_number || d.part_code || "", itemName: d.item_name || "", spec: d.item_spec || "",
|
||||
qty: Number(d.detail_qty || 0), remark: d.detail_remark || "",
|
||||
sourceType: (d.source_table === "sales_order_detail" ? "order" : d.source_table === "production_plan_mng" ? "production" : "item") as SourceType,
|
||||
sourceTable: d.source_table || "item_info", sourceId: d.source_id || "",
|
||||
})));
|
||||
setAddQty(""); setAddEquipment(""); setAddWorkTeam(""); setAddWorker("");
|
||||
setEditRouting(order.routing_version_id || "");
|
||||
setEditRoutingOptions([]);
|
||||
|
||||
// 라우팅 옵션 로드
|
||||
const itemCode = order.item_number || order.part_code || "";
|
||||
if (itemCode) {
|
||||
getRoutingVersions(wiNo, itemCode).then(r => {
|
||||
if (r.success && r.data) setEditRoutingOptions(r.data);
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
const addEditItem = () => {
|
||||
if (!addQty || Number(addQty) <= 0) { alert("수량을 입력해주세요."); return; }
|
||||
setEditItems(prev => [...prev, {
|
||||
itemCode: editOrder?.item_number || "", itemName: editOrder?.item_name || "", spec: editOrder?.item_spec || "",
|
||||
qty: Number(addQty), remark: "", sourceType: "item", sourceTable: "item_info", sourceId: editOrder?.item_number || "",
|
||||
}]);
|
||||
setAddQty("");
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!editOrder || editItems.length === 0) { alert("품목이 없습니다."); return; }
|
||||
setEditSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
id: editOrder.wi_id, status: editStatus, startDate: editStartDate, endDate: editEndDate,
|
||||
equipmentId: editEquipmentId, workTeam: editWorkTeam, worker: editWorker, remark: editRemark,
|
||||
routing: editRouting || null,
|
||||
items: editItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode })),
|
||||
};
|
||||
const r = await saveWorkInstruction(payload);
|
||||
if (r.success) { setIsEditModalOpen(false); fetchOrders(); alert("수정되었습니다."); }
|
||||
else alert(r.message || "저장 실패");
|
||||
} catch (e: any) { alert(e.message || "저장 실패"); } finally { setEditSaving(false); }
|
||||
};
|
||||
|
||||
const handleDelete = async (wiId: string) => {
|
||||
if (!confirm("이 작업지시를 삭제하시겠습니까?")) return;
|
||||
const r = await deleteWorkInstructions([wiId]);
|
||||
if (r.success) { fetchOrders(); } else alert(r.message || "삭제 실패");
|
||||
};
|
||||
|
||||
const getProgress = (o: any) => {
|
||||
const t = Number(o.total_qty || 0), c = Number(o.completed_qty || 0);
|
||||
return t === 0 ? 0 : Math.min(100, Math.round((c / t) * 100));
|
||||
};
|
||||
const getProgressLabel = (o: any) => { const p = getProgress(o); if (o.progress_status) return o.progress_status; if (p >= 100) return "완료"; if (p > 0) return "진행중"; return "대기"; };
|
||||
const totalRegPages = Math.max(1, Math.ceil(regTotalCount / regPageSize));
|
||||
|
||||
const getDisplayNo = (o: any) => {
|
||||
const cnt = Number(o.detail_count || 1);
|
||||
const seq = Number(o.detail_seq || 1);
|
||||
if (cnt <= 1) return o.work_instruction_no || "-";
|
||||
return `${o.work_instruction_no}-${String(seq).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const openWorkStandardModal = (wiNo: string, routingVersionId: string, routingName: string, itemName: string, itemCode: string) => {
|
||||
if (!routingVersionId) { alert("라우팅이 선택되지 않았습니다."); return; }
|
||||
setWsModalWiNo(wiNo);
|
||||
setWsModalRoutingId(routingVersionId);
|
||||
setWsModalRoutingName(routingName);
|
||||
setWsModalItemName(itemName);
|
||||
setWsModalItemCode(itemCode);
|
||||
setWsModalOpen(true);
|
||||
};
|
||||
|
||||
const getWorkerName = (userId: string) => {
|
||||
if (!userId) return "-";
|
||||
const emp = employeeOptions.find(e => e.user_id === userId);
|
||||
return emp ? emp.user_name : userId;
|
||||
};
|
||||
|
||||
const WorkerCombobox = ({ value, onChange, open, onOpenChange, className, triggerClassName }: {
|
||||
value: string; onChange: (v: string) => void; open: boolean; onOpenChange: (v: boolean) => void;
|
||||
className?: string; triggerClassName?: string;
|
||||
}) => (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" aria-expanded={open}
|
||||
className={cn("w-full justify-between font-normal", triggerClassName || "h-9 text-sm")}>
|
||||
{value ? (employeeOptions.find(e => e.user_id === value)?.user_name || value) : "작업자 선택"}
|
||||
<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>
|
||||
<CommandInput placeholder="이름 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs py-4 text-center">사원을 찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem value="__none__" onSelect={() => { onChange(""); onOpenChange(false); }} className="text-xs">
|
||||
<Check className={cn("mr-2 h-3.5 w-3.5", !value ? "opacity-100" : "opacity-0")} />
|
||||
선택 안 함
|
||||
</CommandItem>
|
||||
{employeeOptions.map(emp => (
|
||||
<CommandItem key={emp.user_id} value={`${emp.user_name} ${emp.user_id}`}
|
||||
onSelect={() => { onChange(emp.user_id); onOpenChange(false); }} className="text-xs">
|
||||
<Check className={cn("mr-2 h-3.5 w-3.5", value === emp.user_id ? "opacity-100" : "opacity-0")} />
|
||||
{emp.user_name}{emp.dept_name ? ` (${emp.dept_name})` : ""}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-4 p-4">
|
||||
{/* 검색 */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">작업기간</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-[150px]"><FormDatePicker value={searchDateFrom} onChange={setSearchDateFrom} placeholder="시작일" /></div>
|
||||
<span className="text-muted-foreground">~</span>
|
||||
<div className="w-[150px]"><FormDatePicker value={searchDateTo} onChange={setSearchDateTo} placeholder="종료일" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">검색</Label>
|
||||
<Input placeholder="작업지시번호/품목명" value={searchKeyword} onChange={e => setSearchKeyword(e.target.value)} className="h-9 w-[200px]" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">상태</Label>
|
||||
<Select value={searchStatus} onValueChange={setSearchStatus}>
|
||||
<SelectTrigger className="h-9 w-[120px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="all">전체</SelectItem><SelectItem value="일반">일반</SelectItem><SelectItem value="긴급">긴급</SelectItem></SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">진행현황</Label>
|
||||
<Select value={searchProgress} onValueChange={setSearchProgress}>
|
||||
<SelectTrigger className="h-9 w-[130px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="all">전체</SelectItem><SelectItem value="대기">대기</SelectItem><SelectItem value="진행중">진행중</SelectItem><SelectItem value="완료">완료</SelectItem></SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-2">
|
||||
{loading && <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />}
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={handleResetSearch}><RotateCcw className="w-4 h-4 mr-1.5" /> 초기화</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 메인 테이블 */}
|
||||
<Card className="flex-1 flex flex-col overflow-hidden">
|
||||
<CardContent className="p-0 flex flex-col flex-1 overflow-hidden">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<Wrench className="w-4 h-4" /> 작업지시 목록
|
||||
<Badge variant="secondary" className="text-xs">{new Set(orders.map(o => o.work_instruction_no)).size}건 ({orders.length}행)</Badge>
|
||||
</h3>
|
||||
<Button size="sm" onClick={openRegModal}><Plus className="w-4 h-4 mr-1.5" /> 작업지시 등록</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
<TableRow>
|
||||
<TableHead className="w-[150px]">작업지시번호</TableHead>
|
||||
<TableHead className="w-[70px] text-center">상태</TableHead>
|
||||
<TableHead className="w-[100px] text-center">진행현황</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead className="w-[100px]">규격</TableHead>
|
||||
<TableHead className="w-[80px] text-right">수량</TableHead>
|
||||
<TableHead className="w-[120px]">설비</TableHead>
|
||||
<TableHead className="w-[120px]">라우팅</TableHead>
|
||||
<TableHead className="w-[80px] text-center">작업조</TableHead>
|
||||
<TableHead className="w-[100px]">작업자</TableHead>
|
||||
<TableHead className="w-[100px] text-center">시작일</TableHead>
|
||||
<TableHead className="w-[100px] text-center">완료일</TableHead>
|
||||
<TableHead className="w-[150px] text-center">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow><TableCell colSpan={13} className="text-center py-12"><Loader2 className="w-6 h-6 animate-spin mx-auto text-muted-foreground" /></TableCell></TableRow>
|
||||
) : orders.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={13} className="text-center py-12 text-muted-foreground">작업지시가 없습니다</TableCell></TableRow>
|
||||
) : orders.map((o, rowIdx) => {
|
||||
const pct = getProgress(o);
|
||||
const pLabel = getProgressLabel(o);
|
||||
const pBadge = PROGRESS_BADGE[pLabel] || PROGRESS_BADGE["대기"];
|
||||
const sBadge = STATUS_BADGE[o.status] || STATUS_BADGE["일반"];
|
||||
const isFirstOfGroup = Number(o.detail_seq) === 1;
|
||||
return (
|
||||
<TableRow key={`${o.wi_id}-${o.detail_id}`} className="hover:bg-muted/50">
|
||||
<TableCell className="font-mono text-xs font-medium">{getDisplayNo(o)}</TableCell>
|
||||
<TableCell className="text-center"><Badge variant="outline" className={cn("text-[10px]", sBadge.cls)}>{sBadge.label}</Badge></TableCell>
|
||||
<TableCell className="text-center">
|
||||
{isFirstOfGroup ? (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Badge variant="secondary" className={cn("text-[10px]", pBadge.cls)}>{pBadge.label}</Badge>
|
||||
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className={cn("h-full rounded-full transition-all", pct >= 100 ? "bg-emerald-500" : pct > 0 ? "bg-blue-500" : "bg-gray-300")} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">{pct}%</span>
|
||||
</div>
|
||||
) : <span className="text-[10px] text-muted-foreground">↑</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{o.item_name || o.item_number || "-"}</TableCell>
|
||||
<TableCell className="text-xs">{o.item_spec || "-"}</TableCell>
|
||||
<TableCell className="text-right text-xs font-medium">{Number(o.detail_qty || 0).toLocaleString()}</TableCell>
|
||||
<TableCell className="text-xs">{isFirstOfGroup ? (o.equipment_name || "-") : ""}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{isFirstOfGroup ? (
|
||||
o.routing_version_id ? (
|
||||
<button
|
||||
className="text-primary underline underline-offset-2 hover:text-primary/80 cursor-pointer text-xs text-left"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
openWorkStandardModal(
|
||||
o.work_instruction_no,
|
||||
o.routing_version_id,
|
||||
o.routing_name || "",
|
||||
o.item_name || o.item_number || "",
|
||||
o.item_number || ""
|
||||
);
|
||||
}}
|
||||
>
|
||||
{o.routing_name || "라우팅"} <ClipboardCheck className="w-3 h-3 inline ml-0.5" />
|
||||
</button>
|
||||
) : <span className="text-muted-foreground">-</span>
|
||||
) : ""}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs">{isFirstOfGroup ? (o.work_team || "-") : ""}</TableCell>
|
||||
<TableCell className="text-xs">{isFirstOfGroup ? getWorkerName(o.worker) : ""}</TableCell>
|
||||
<TableCell className="text-center text-xs">{isFirstOfGroup ? (o.start_date || "-") : ""}</TableCell>
|
||||
<TableCell className="text-center text-xs">{isFirstOfGroup ? (o.end_date || "-") : ""}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{isFirstOfGroup && (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs px-2" onClick={() => openEditModal(o)}><Pencil className="w-3 h-3 mr-1" /> 수정</Button>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs px-2 text-destructive border-destructive/30 hover:bg-destructive/10" onClick={() => handleDelete(o.wi_id)}><Trash2 className="w-3 h-3 mr-1" /> 삭제</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── 1단계: 등록 모달 ── */}
|
||||
<Dialog open={isRegModalOpen} onOpenChange={setIsRegModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[1200px] h-[80vh] flex flex-col p-0 gap-0">
|
||||
<DialogHeader className="px-6 py-4 border-b shrink-0">
|
||||
<DialogTitle className="text-base flex items-center gap-2"><Plus className="w-4 h-4" /> 작업지시 등록</DialogTitle>
|
||||
<DialogDescription className="text-xs">근거를 선택하고 품목을 체크한 후 "작업지시 적용" 버튼을 눌러주세요.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6 py-3 border-b bg-muted/30 flex items-center gap-3 flex-wrap shrink-0">
|
||||
<Label className="text-sm font-semibold whitespace-nowrap">근거:</Label>
|
||||
<Select value={regSourceType} onValueChange={v => setRegSourceType(v as SourceType)}>
|
||||
<SelectTrigger className="h-9 w-[160px]"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="production">생산계획</SelectItem><SelectItem value="order">수주</SelectItem><SelectItem value="item">품목정보</SelectItem></SelectContent>
|
||||
</Select>
|
||||
{regSourceType && (<>
|
||||
<Input placeholder="검색..." value={regKeyword} onChange={e => setRegKeyword(e.target.value)} className="h-9 w-[220px]"
|
||||
onKeyDown={e => { if (e.key === "Enter") { setRegPage(1); fetchRegSource(1); } }} />
|
||||
<Button size="sm" className="h-9" onClick={() => { setRegPage(1); fetchRegSource(1); }} disabled={regSourceLoading}>
|
||||
{regSourceLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}<span className="ml-1.5">조회</span>
|
||||
</Button>
|
||||
</>)}
|
||||
<div className="flex-1" />
|
||||
<label className="flex items-center gap-1.5 cursor-pointer select-none">
|
||||
<Checkbox checked={regMergeSameItem} onCheckedChange={v => setRegMergeSameItem(!!v)} />
|
||||
<span className="text-sm font-semibold">동일품목 합산</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto px-6 py-4">
|
||||
{!regSourceType ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground text-sm">근거를 선택하고 검색해주세요</div>
|
||||
) : regSourceLoading ? (
|
||||
<div className="flex items-center justify-center h-full"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
|
||||
) : regSourceData.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground text-sm">데이터가 없습니다</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px] text-center"><Checkbox checked={regSourceData.length > 0 && regCheckedIds.size === regSourceData.length} onCheckedChange={toggleRegAll} /></TableHead>
|
||||
{regSourceType === "item" && <><TableHead className="w-[120px]">품목코드</TableHead><TableHead>품목명</TableHead><TableHead className="w-[120px]">규격</TableHead></>}
|
||||
{regSourceType === "order" && <><TableHead className="w-[110px]">수주번호</TableHead><TableHead className="w-[100px]">품번</TableHead><TableHead>품목명</TableHead><TableHead className="w-[100px]">규격</TableHead><TableHead className="w-[80px] text-right">수량</TableHead><TableHead className="w-[100px]">납기일</TableHead></>}
|
||||
{regSourceType === "production" && <><TableHead className="w-[110px]">계획번호</TableHead><TableHead className="w-[100px]">품번</TableHead><TableHead>품목명</TableHead><TableHead className="w-[80px] text-right">계획수량</TableHead><TableHead className="w-[90px]">시작일</TableHead><TableHead className="w-[90px]">완료일</TableHead><TableHead className="w-[100px]">설비</TableHead></>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{regSourceData.map((item, idx) => {
|
||||
const id = getRegId(item);
|
||||
const checked = regCheckedIds.has(id);
|
||||
return (
|
||||
<TableRow key={`${regSourceType}-${id}-${idx}`} className={cn("cursor-pointer hover:bg-muted/50", checked && "bg-primary/5")} onClick={() => toggleRegItem(id)}>
|
||||
<TableCell className="text-center" onClick={e => e.stopPropagation()}><Checkbox checked={checked} onCheckedChange={() => toggleRegItem(id)} /></TableCell>
|
||||
{regSourceType === "item" && <><TableCell className="text-xs font-medium">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-xs">{item.spec || "-"}</TableCell></>}
|
||||
{regSourceType === "order" && <><TableCell className="text-xs">{item.order_no}</TableCell><TableCell className="text-xs">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-xs">{item.spec || "-"}</TableCell><TableCell className="text-right text-xs">{Number(item.qty || 0).toLocaleString()}</TableCell><TableCell className="text-xs">{item.due_date || "-"}</TableCell></>}
|
||||
{regSourceType === "production" && <><TableCell className="text-xs">{item.plan_no}</TableCell><TableCell className="text-xs">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-right text-xs">{Number(item.plan_qty || 0).toLocaleString()}</TableCell><TableCell className="text-xs">{item.start_date ? String(item.start_date).split("T")[0] : "-"}</TableCell><TableCell className="text-xs">{item.end_date ? String(item.end_date).split("T")[0] : "-"}</TableCell><TableCell className="text-xs">{item.equipment_name || "-"}</TableCell></>}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{regTotalCount > 0 && (
|
||||
<div className="px-6 py-2 border-t bg-muted/10 flex items-center justify-between shrink-0">
|
||||
<span className="text-xs text-muted-foreground">총 {regTotalCount}건 (선택: {regCheckedIds.size}건)</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="outline" size="icon" className="h-7 w-7" disabled={regPage <= 1} onClick={() => { const p = regPage - 1; setRegPage(p); fetchRegSource(p); }}><ChevronLeft className="w-3.5 h-3.5" /></Button>
|
||||
<span className="text-xs font-medium px-2">{regPage} / {totalRegPages}</span>
|
||||
<Button variant="outline" size="icon" className="h-7 w-7" disabled={regPage >= totalRegPages} onClick={() => { const p = regPage + 1; setRegPage(p); fetchRegSource(p); }}><ChevronRight className="w-3.5 h-3.5" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="px-6 py-3 border-t shrink-0">
|
||||
<Button variant="outline" onClick={() => setIsRegModalOpen(false)}>취소</Button>
|
||||
<Button onClick={applyRegistration} disabled={regCheckedIds.size === 0}><ArrowRight className="w-4 h-4 mr-1.5" /> 작업지시 적용</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ── 2단계: 확인 모달 ── */}
|
||||
<Dialog open={isConfirmModalOpen} onOpenChange={setIsConfirmModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[1000px] max-h-[90vh] flex flex-col p-0 gap-0">
|
||||
<DialogHeader className="px-6 py-4 border-b shrink-0">
|
||||
<DialogTitle className="text-base flex items-center gap-2"><CheckCircle2 className="w-4 h-4" /> 작업지시 적용 확인</DialogTitle>
|
||||
<DialogDescription className="text-xs">기본 정보를 입력하고 "최종 적용" 버튼을 눌러주세요.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-auto p-6 space-y-5">
|
||||
<div className="bg-muted/30 border rounded-lg p-5">
|
||||
<h4 className="text-sm font-semibold mb-4">작업지시 기본 정보</h4>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<div className="space-y-1.5"><Label className="text-xs">작업지시번호</Label><Input value={confirmWiNo} readOnly className="h-9 bg-muted/50 text-muted-foreground" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">상태</Label>
|
||||
<Select value={confirmStatus} onValueChange={setConfirmStatus}><SelectTrigger className="h-9"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="일반">일반</SelectItem><SelectItem value="긴급">긴급</SelectItem></SelectContent></Select>
|
||||
</div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">시작일</Label><FormDatePicker value={confirmStartDate} onChange={setConfirmStartDate} placeholder="시작일" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">완료예정일</Label><FormDatePicker value={confirmEndDate} onChange={setConfirmEndDate} placeholder="완료예정일" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">설비</Label>
|
||||
<Select value={nv(confirmEquipmentId)} onValueChange={v => setConfirmEquipmentId(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="설비 선택" /></SelectTrigger><SelectContent><SelectItem value="none">선택 안 함</SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select>
|
||||
</div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">작업조</Label>
|
||||
<Select value={nv(confirmWorkTeam)} onValueChange={v => setConfirmWorkTeam(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none">선택 안 함</SelectItem><SelectItem value="주간">주간</SelectItem><SelectItem value="야간">야간</SelectItem></SelectContent></Select>
|
||||
</div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">작업자</Label>
|
||||
<WorkerCombobox value={confirmWorker} onChange={setConfirmWorker} open={confirmWorkerOpen} onOpenChange={setConfirmWorkerOpen} />
|
||||
</div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">라우팅</Label>
|
||||
<Select value={nv(confirmRouting)} onValueChange={v => setConfirmRouting(fromNv(v))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="라우팅 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안 함</SelectItem>
|
||||
{confirmRoutingOptions.map(rv => (
|
||||
<SelectItem key={rv.id} value={rv.id}>
|
||||
{rv.version_name || "라우팅"} {rv.is_default ? "(기본)" : ""} - {rv.processes.length}공정
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded-lg p-5">
|
||||
<h4 className="text-sm font-semibold mb-3">품목 목록</h4>
|
||||
<div className="max-h-[300px] overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
<TableRow><TableHead className="w-[60px]">순번</TableHead><TableHead className="w-[120px]">품목코드</TableHead><TableHead>품목명</TableHead><TableHead className="w-[100px]">규격</TableHead><TableHead className="w-[100px]">수량</TableHead><TableHead>비고</TableHead></TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{confirmItems.map((item, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell className="text-xs text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="text-xs font-medium">{item.itemCode}</TableCell>
|
||||
<TableCell className="text-sm">{item.itemName || item.itemCode}</TableCell>
|
||||
<TableCell className="text-xs">{item.spec || "-"}</TableCell>
|
||||
<TableCell><Input type="number" className="h-7 text-xs w-20" value={item.qty} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
|
||||
<TableCell><Input className="h-7 text-xs" value={item.remark} placeholder="비고" onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="px-6 py-3 border-t shrink-0">
|
||||
<Button variant="outline" onClick={() => { setIsConfirmModalOpen(false); setIsRegModalOpen(true); }}><ChevronLeft className="w-4 h-4 mr-1" /> 이전</Button>
|
||||
<Button variant="outline" onClick={() => setIsConfirmModalOpen(false)}>취소</Button>
|
||||
<Button onClick={finalizeRegistration} disabled={saving}>{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <CheckCircle2 className="w-4 h-4 mr-1.5" />} 최종 적용</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ── 수정 모달 ── */}
|
||||
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[1100px] max-h-[90vh] flex flex-col p-0 gap-0">
|
||||
<DialogHeader className="px-6 py-4 border-b shrink-0">
|
||||
<DialogTitle className="text-base flex items-center gap-2"><Wrench className="w-4 h-4" /> 작업지시 관리 - {editOrder?.work_instruction_no}</DialogTitle>
|
||||
<DialogDescription className="text-xs">품목을 추가/삭제하고 정보를 수정하세요.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-auto p-6 space-y-5">
|
||||
<div className="bg-muted/30 border rounded-lg p-5">
|
||||
<h4 className="text-sm font-semibold mb-4">기본 정보</h4>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<div className="space-y-1.5"><Label className="text-xs">상태</Label><Select value={editStatus} onValueChange={setEditStatus}><SelectTrigger className="h-9"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="일반">일반</SelectItem><SelectItem value="긴급">긴급</SelectItem></SelectContent></Select></div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">시작일</Label><FormDatePicker value={editStartDate} onChange={setEditStartDate} placeholder="시작일" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">완료예정일</Label><FormDatePicker value={editEndDate} onChange={setEditEndDate} placeholder="완료예정일" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">설비</Label><Select value={nv(editEquipmentId)} onValueChange={v => setEditEquipmentId(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="설비" /></SelectTrigger><SelectContent><SelectItem value="none">선택 안 함</SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select></div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">작업조</Label><Select value={nv(editWorkTeam)} onValueChange={v => setEditWorkTeam(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none">선택 안 함</SelectItem><SelectItem value="주간">주간</SelectItem><SelectItem value="야간">야간</SelectItem></SelectContent></Select></div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">작업자</Label>
|
||||
<WorkerCombobox value={editWorker} onChange={setEditWorker} open={editWorkerOpen} onOpenChange={setEditWorkerOpen} />
|
||||
</div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">라우팅</Label>
|
||||
<Select value={nv(editRouting)} onValueChange={v => setEditRouting(fromNv(v))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="라우팅 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안 함</SelectItem>
|
||||
{editRoutingOptions.map(rv => (
|
||||
<SelectItem key={rv.id} value={rv.id}>
|
||||
{rv.version_name || "라우팅"} {rv.is_default ? "(기본)" : ""} - {rv.processes.length}공정
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">공정작업기준</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-9 w-full text-xs"
|
||||
disabled={!editRouting}
|
||||
onClick={() => {
|
||||
if (!editOrder || !editRouting) return;
|
||||
const rv = editRoutingOptions.find(r => r.id === editRouting);
|
||||
openWorkStandardModal(
|
||||
editOrder.work_instruction_no,
|
||||
editRouting,
|
||||
rv?.version_name || "",
|
||||
editOrder.item_name || editOrder.item_number || "",
|
||||
editOrder.item_number || ""
|
||||
);
|
||||
}}
|
||||
>
|
||||
<ClipboardCheck className="w-3.5 h-3.5 mr-1.5" /> 작업기준 수정
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2"><Label className="text-xs">비고</Label><Input value={editRemark} onChange={e => setEditRemark(e.target.value)} className="h-9" placeholder="비고" /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 인라인 추가 폼 */}
|
||||
<div className="border rounded-lg p-4 bg-muted/20">
|
||||
<div className="flex items-end gap-3 flex-wrap">
|
||||
<div className="space-y-1"><Label className="text-[11px] text-muted-foreground">수량 <span className="text-destructive">*</span></Label><Input type="number" value={addQty} onChange={e => setAddQty(e.target.value)} className="h-8 w-24 text-xs" placeholder="0" /></div>
|
||||
<div className="space-y-1"><Label className="text-[11px] text-muted-foreground">설비</Label><Select value={nv(addEquipment)} onValueChange={v => setAddEquipment(fromNv(v))}><SelectTrigger className="h-8 w-[160px] text-xs"><SelectValue placeholder="설비 선택" /></SelectTrigger><SelectContent><SelectItem value="none">선택 안 함</SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select></div>
|
||||
<div className="space-y-1"><Label className="text-[11px] text-muted-foreground">작업조</Label><Select value={nv(addWorkTeam)} onValueChange={v => setAddWorkTeam(fromNv(v))}><SelectTrigger className="h-8 w-[100px] text-xs"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none">선택</SelectItem><SelectItem value="주간">주간</SelectItem><SelectItem value="야간">야간</SelectItem></SelectContent></Select></div>
|
||||
<div className="space-y-1"><Label className="text-[11px] text-muted-foreground">작업자</Label>
|
||||
<div className="w-[150px]"><WorkerCombobox value={addWorker} onChange={setAddWorker} open={addWorkerOpen} onOpenChange={setAddWorkerOpen} triggerClassName="h-8 text-xs" /></div>
|
||||
</div>
|
||||
<Button size="sm" className="h-8" onClick={addEditItem}><Plus className="w-3 h-3 mr-1" /> 추가</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품목 테이블 */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between p-3 bg-muted/20 border-b">
|
||||
<span className="text-sm font-semibold">작업지시 항목</span>
|
||||
<span className="text-xs text-muted-foreground">{editItems.length}건</span>
|
||||
</div>
|
||||
<div className="max-h-[280px] overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
<TableRow><TableHead className="w-[60px]">순번</TableHead><TableHead className="w-[120px]">품목코드</TableHead><TableHead className="w-[100px] text-right">수량</TableHead><TableHead>비고</TableHead><TableHead className="w-[60px]" /></TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{editItems.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">품목이 없습니다</TableCell></TableRow>
|
||||
) : editItems.map((item, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell className="text-xs text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="text-xs font-medium">{item.itemCode}</TableCell>
|
||||
<TableCell className="text-right"><Input type="number" className="h-7 text-xs w-20 ml-auto" value={item.qty} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
|
||||
<TableCell><Input className="h-7 text-xs" value={item.remark} placeholder="비고" onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
|
||||
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{editItems.length > 0 && (
|
||||
<div className="p-3 border-t bg-muted/20 flex items-center justify-between">
|
||||
<span className="text-sm font-semibold">총 수량</span>
|
||||
<span className="text-lg font-bold text-primary">{editItems.reduce((s, i) => s + i.qty, 0).toLocaleString()} EA</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="px-6 py-3 border-t shrink-0">
|
||||
<Button variant="outline" onClick={() => setIsEditModalOpen(false)}>취소</Button>
|
||||
<Button onClick={saveEdit} disabled={editSaving}>{editSaving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 공정작업기준 수정 모달 */}
|
||||
<WorkStandardEditModal
|
||||
open={wsModalOpen}
|
||||
onClose={() => setWsModalOpen(false)}
|
||||
workInstructionNo={wsModalWiNo}
|
||||
routingVersionId={wsModalRoutingId}
|
||||
routingName={wsModalRoutingName}
|
||||
itemName={wsModalItemName}
|
||||
itemCode={wsModalItemCode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,826 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import { Plus, Trash2, RotateCcw, Save, X, ChevronDown, ChevronRight, ChevronLeft, Truck, Search, Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
|
||||
import {
|
||||
getShippingOrderList,
|
||||
saveShippingOrder,
|
||||
deleteShippingOrders,
|
||||
previewShippingOrderNo,
|
||||
getShipmentPlanSource,
|
||||
getSalesOrderSource,
|
||||
getItemSource,
|
||||
} from "@/lib/api/shipping";
|
||||
|
||||
type DataSourceType = "shipmentPlan" | "salesOrder" | "itemInfo";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "all", label: "전체" },
|
||||
{ value: "READY", label: "준비중" },
|
||||
{ value: "IN_PROGRESS", label: "진행중" },
|
||||
{ value: "COMPLETED", label: "완료" },
|
||||
];
|
||||
|
||||
const getStatusLabel = (s: string) => STATUS_OPTIONS.find(o => o.value === s)?.label || s;
|
||||
|
||||
const getStatusColor = (s: string) => {
|
||||
switch (s) {
|
||||
case "READY": return "bg-amber-100 text-amber-800 border-amber-200";
|
||||
case "IN_PROGRESS": return "bg-blue-100 text-blue-800 border-blue-200";
|
||||
case "COMPLETED": return "bg-emerald-100 text-emerald-800 border-emerald-200";
|
||||
default: return "bg-gray-100 text-gray-800 border-gray-200";
|
||||
}
|
||||
};
|
||||
|
||||
const getSourceBadge = (s: string) => {
|
||||
switch (s) {
|
||||
case "shipmentPlan": return { label: "출하계획", cls: "bg-blue-100 text-blue-700" };
|
||||
case "salesOrder": return { label: "수주", cls: "bg-emerald-100 text-emerald-700" };
|
||||
case "itemInfo": return { label: "품목", cls: "bg-purple-100 text-purple-700" };
|
||||
default: return { label: s, cls: "bg-gray-100 text-gray-700" };
|
||||
}
|
||||
};
|
||||
|
||||
interface SelectedItem {
|
||||
id: string | number;
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
spec: string;
|
||||
material: string;
|
||||
customer: string;
|
||||
planQty: number;
|
||||
orderQty: number;
|
||||
sourceType: DataSourceType;
|
||||
shipmentPlanId?: number;
|
||||
salesOrderId?: number;
|
||||
detailId?: string;
|
||||
partnerCode?: string;
|
||||
}
|
||||
|
||||
export default function ShippingOrderPage() {
|
||||
const [orders, setOrders] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [checkedIds, setCheckedIds] = useState<number[]>([]);
|
||||
|
||||
// 검색
|
||||
const [searchKeyword, setSearchKeyword] = useState("");
|
||||
const [searchCustomer, setSearchCustomer] = useState("");
|
||||
const [debouncedKeyword, setDebouncedKeyword] = useState("");
|
||||
const [debouncedCustomer, setDebouncedCustomer] = useState("");
|
||||
const [searchStatus, setSearchStatus] = useState("all");
|
||||
const [searchDateFrom, setSearchDateFrom] = useState("");
|
||||
const [searchDateTo, setSearchDateTo] = useState("");
|
||||
|
||||
// 모달
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const [editId, setEditId] = useState<number | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 모달 폼
|
||||
const [formOrderNumber, setFormOrderNumber] = useState("");
|
||||
const [formOrderDate, setFormOrderDate] = useState("");
|
||||
const [formCustomer, setFormCustomer] = useState("");
|
||||
const [formPartnerId, setFormPartnerId] = useState("");
|
||||
const [formStatus, setFormStatus] = useState("READY");
|
||||
const [formCarrier, setFormCarrier] = useState("");
|
||||
const [formVehicle, setFormVehicle] = useState("");
|
||||
const [formDriver, setFormDriver] = useState("");
|
||||
const [formDriverPhone, setFormDriverPhone] = useState("");
|
||||
const [formArrival, setFormArrival] = useState("");
|
||||
const [formAddress, setFormAddress] = useState("");
|
||||
const [formMemo, setFormMemo] = useState("");
|
||||
const [isTransportCollapsed, setIsTransportCollapsed] = useState(false);
|
||||
|
||||
// 모달 왼쪽 패널
|
||||
const [dataSource, setDataSource] = useState<DataSourceType>("shipmentPlan");
|
||||
const [sourceKeyword, setSourceKeyword] = useState("");
|
||||
const [sourceData, setSourceData] = useState<any[]>([]);
|
||||
const [sourceLoading, setSourceLoading] = useState(false);
|
||||
const [selectedItems, setSelectedItems] = useState<SelectedItem[]>([]);
|
||||
const [sourcePage, setSourcePage] = useState(1);
|
||||
const [sourcePageSize] = useState(20);
|
||||
const [sourceTotalCount, setSourceTotalCount] = useState(0);
|
||||
|
||||
// 텍스트 입력 debounce (500ms)
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedKeyword(searchKeyword), 500);
|
||||
return () => clearTimeout(t);
|
||||
}, [searchKeyword]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedCustomer(searchCustomer), 500);
|
||||
return () => clearTimeout(t);
|
||||
}, [searchCustomer]);
|
||||
|
||||
// 초기 날짜
|
||||
useEffect(() => {
|
||||
const today = new Date();
|
||||
const from = new Date(today);
|
||||
from.setMonth(from.getMonth() - 1);
|
||||
setSearchDateFrom(from.toISOString().split("T")[0]);
|
||||
setSearchDateTo(today.toISOString().split("T")[0]);
|
||||
}, []);
|
||||
|
||||
// 데이터 조회
|
||||
const fetchOrders = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {};
|
||||
if (searchDateFrom) params.dateFrom = searchDateFrom;
|
||||
if (searchDateTo) params.dateTo = searchDateTo;
|
||||
if (searchStatus !== "all") params.status = searchStatus;
|
||||
if (debouncedCustomer.trim()) params.customer = debouncedCustomer.trim();
|
||||
if (debouncedKeyword.trim()) params.keyword = debouncedKeyword.trim();
|
||||
|
||||
const result = await getShippingOrderList(params);
|
||||
if (result.success) setOrders(result.data || []);
|
||||
} catch (err) {
|
||||
console.error("출하지시 조회 실패:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchDateFrom, searchDateTo, searchStatus, debouncedCustomer, debouncedKeyword]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchDateFrom && searchDateTo) fetchOrders();
|
||||
}, [fetchOrders]);
|
||||
|
||||
// 소스 데이터 조회
|
||||
const fetchSourceData = useCallback(async (pageOverride?: number) => {
|
||||
setSourceLoading(true);
|
||||
try {
|
||||
const currentPage = pageOverride ?? sourcePage;
|
||||
const params: any = { page: currentPage, pageSize: sourcePageSize };
|
||||
if (sourceKeyword.trim()) params.keyword = sourceKeyword.trim();
|
||||
|
||||
let result;
|
||||
switch (dataSource) {
|
||||
case "shipmentPlan":
|
||||
result = await getShipmentPlanSource(params);
|
||||
break;
|
||||
case "salesOrder":
|
||||
result = await getSalesOrderSource(params);
|
||||
break;
|
||||
case "itemInfo":
|
||||
result = await getItemSource(params);
|
||||
break;
|
||||
}
|
||||
if (result?.success) {
|
||||
setSourceData(result.data || []);
|
||||
setSourceTotalCount(result.totalCount || 0);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("소스 데이터 조회 실패:", err);
|
||||
} finally {
|
||||
setSourceLoading(false);
|
||||
}
|
||||
}, [dataSource, sourceKeyword, sourcePage, sourcePageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isModalOpen) {
|
||||
setSourcePage(1);
|
||||
fetchSourceData(1);
|
||||
}
|
||||
}, [isModalOpen, dataSource]);
|
||||
|
||||
// 핸들러
|
||||
const handleResetSearch = () => {
|
||||
setSearchKeyword("");
|
||||
setSearchCustomer("");
|
||||
setDebouncedKeyword("");
|
||||
setDebouncedCustomer("");
|
||||
setSearchStatus("all");
|
||||
const today = new Date();
|
||||
const from = new Date(today);
|
||||
from.setMonth(from.getMonth() - 1);
|
||||
setSearchDateFrom(from.toISOString().split("T")[0]);
|
||||
setSearchDateTo(today.toISOString().split("T")[0]);
|
||||
};
|
||||
|
||||
const handleCheckAll = (checked: boolean) => {
|
||||
setCheckedIds(checked ? orders.map((o: any) => o.id) : []);
|
||||
};
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
if (checkedIds.length === 0) return;
|
||||
if (!confirm(`선택한 ${checkedIds.length}개의 출하지시를 삭제하시겠습니까?`)) return;
|
||||
try {
|
||||
const result = await deleteShippingOrders(checkedIds);
|
||||
if (result.success) {
|
||||
setCheckedIds([]);
|
||||
fetchOrders();
|
||||
alert("삭제되었습니다.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
alert(err.message || "삭제 실패");
|
||||
}
|
||||
};
|
||||
|
||||
// 모달 열기
|
||||
const openModal = (order?: any) => {
|
||||
if (order) {
|
||||
setIsEditMode(true);
|
||||
setEditId(order.id);
|
||||
setFormOrderNumber(order.instruction_no || "");
|
||||
setFormOrderDate(order.instruction_date ? order.instruction_date.split("T")[0] : "");
|
||||
setFormCustomer(order.customer_name || "");
|
||||
setFormPartnerId(order.partner_id || "");
|
||||
setFormStatus(order.status || "READY");
|
||||
setFormCarrier(order.carrier_name || "");
|
||||
setFormVehicle(order.vehicle_no || "");
|
||||
setFormDriver(order.driver_name || "");
|
||||
setFormDriverPhone(order.driver_contact || "");
|
||||
setFormArrival(order.arrival_time ? new Date(order.arrival_time).toLocaleString("sv-SE", { timeZone: "Asia/Seoul" }).replace(" ", "T").slice(0, 16) : "");
|
||||
setFormAddress(order.delivery_address || "");
|
||||
setFormMemo(order.memo || "");
|
||||
|
||||
const items = order.items || [];
|
||||
setSelectedItems(items.filter((it: any) => it.id).map((it: any) => {
|
||||
const srcType = it.source_type || "shipmentPlan";
|
||||
// 소스 데이터와 매칭할 수 있도록 원래 소스 id를 사용
|
||||
let sourceId: string | number = it.id;
|
||||
if (srcType === "shipmentPlan" && it.shipment_plan_id) sourceId = it.shipment_plan_id;
|
||||
else if (srcType === "salesOrder" && it.detail_id) sourceId = it.detail_id;
|
||||
else if (srcType === "itemInfo") sourceId = it.item_code || "";
|
||||
|
||||
return {
|
||||
id: sourceId,
|
||||
itemCode: it.item_code || "",
|
||||
itemName: it.item_name || "",
|
||||
spec: it.spec || "",
|
||||
material: it.material || "",
|
||||
customer: order.customer_name || "",
|
||||
planQty: Number(it.plan_qty || 0),
|
||||
orderQty: Number(it.order_qty || 0),
|
||||
sourceType: srcType,
|
||||
shipmentPlanId: it.shipment_plan_id,
|
||||
salesOrderId: it.sales_order_id,
|
||||
detailId: it.detail_id,
|
||||
partnerCode: order.partner_id,
|
||||
};
|
||||
}));
|
||||
} else {
|
||||
setIsEditMode(false);
|
||||
setEditId(null);
|
||||
setFormOrderNumber("불러오는 중...");
|
||||
setFormOrderDate(new Date().toISOString().split("T")[0]);
|
||||
previewShippingOrderNo().then(r => {
|
||||
if (r.success) setFormOrderNumber(r.instructionNo);
|
||||
else setFormOrderNumber("(자동생성)");
|
||||
}).catch(() => setFormOrderNumber("(자동생성)"));
|
||||
setFormCustomer("");
|
||||
setFormPartnerId("");
|
||||
setFormStatus("READY");
|
||||
setFormCarrier("");
|
||||
setFormVehicle("");
|
||||
setFormDriver("");
|
||||
setFormDriverPhone("");
|
||||
setFormArrival("");
|
||||
setFormAddress("");
|
||||
setFormMemo("");
|
||||
setSelectedItems([]);
|
||||
}
|
||||
setDataSource("shipmentPlan");
|
||||
setSourceKeyword("");
|
||||
setSourceData([]);
|
||||
setIsTransportCollapsed(false);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 소스 아이템 선택 토글
|
||||
const toggleSourceItem = (item: any) => {
|
||||
const key = dataSource === "shipmentPlan" ? item.id
|
||||
: dataSource === "salesOrder" ? item.id
|
||||
: item.item_code;
|
||||
|
||||
const exists = selectedItems.findIndex(s => {
|
||||
// 같은 소스 타입에서 id 매칭
|
||||
if (s.sourceType === dataSource) {
|
||||
if (dataSource === "itemInfo") return s.itemCode === key;
|
||||
return String(s.id) === String(key);
|
||||
}
|
||||
// 다른 소스 타입이라도 원래 소스 id로 매칭
|
||||
if (dataSource === "shipmentPlan" && s.shipmentPlanId) return String(s.shipmentPlanId) === String(item.id);
|
||||
if (dataSource === "salesOrder" && s.detailId) return String(s.detailId) === String(item.id);
|
||||
return false;
|
||||
});
|
||||
|
||||
if (exists > -1) {
|
||||
setSelectedItems(prev => prev.filter((_, i) => i !== exists));
|
||||
} else {
|
||||
const newItem: SelectedItem = {
|
||||
id: key,
|
||||
itemCode: item.item_code || "",
|
||||
itemName: item.item_name || "",
|
||||
spec: item.spec || "",
|
||||
material: item.material || "",
|
||||
customer: item.customer_name || "",
|
||||
planQty: Number(item.plan_qty || item.qty || item.balance_qty || 0),
|
||||
orderQty: Number(item.plan_qty || item.balance_qty || item.qty || 1),
|
||||
sourceType: dataSource,
|
||||
shipmentPlanId: dataSource === "shipmentPlan" ? item.id : undefined,
|
||||
salesOrderId: dataSource === "salesOrder" ? (item.master_id || undefined) : undefined,
|
||||
detailId: dataSource === "salesOrder" ? item.id : (dataSource === "shipmentPlan" ? item.detail_id : undefined),
|
||||
partnerCode: item.partner_code || "",
|
||||
};
|
||||
setSelectedItems(prev => [...prev, newItem]);
|
||||
|
||||
if (!formCustomer && item.customer_name) {
|
||||
setFormCustomer(item.customer_name);
|
||||
setFormPartnerId(item.partner_code || "");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removeSelectedItem = (idx: number) => {
|
||||
setSelectedItems(prev => prev.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const updateOrderQty = (idx: number, val: number) => {
|
||||
setSelectedItems(prev => prev.map((item, i) => i === idx ? { ...item, orderQty: val } : item));
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
if (!formOrderDate) { alert("출하지시일을 입력해주세요."); return; }
|
||||
if (selectedItems.length === 0) { alert("품목을 선택해주세요."); return; }
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
id: isEditMode ? editId : undefined,
|
||||
instructionDate: formOrderDate,
|
||||
partnerId: formPartnerId || formCustomer,
|
||||
status: formStatus,
|
||||
memo: formMemo,
|
||||
carrierName: formCarrier,
|
||||
vehicleNo: formVehicle,
|
||||
driverName: formDriver,
|
||||
driverContact: formDriverPhone,
|
||||
arrivalTime: formArrival ? `${formArrival}+09:00` : null,
|
||||
deliveryAddress: formAddress,
|
||||
items: selectedItems.map(item => ({
|
||||
itemCode: item.itemCode,
|
||||
itemName: item.itemName,
|
||||
spec: item.spec,
|
||||
material: item.material,
|
||||
orderQty: item.orderQty,
|
||||
planQty: item.planQty,
|
||||
shipQty: 0,
|
||||
sourceType: item.sourceType,
|
||||
shipmentPlanId: item.shipmentPlanId,
|
||||
salesOrderId: item.salesOrderId,
|
||||
detailId: item.detailId,
|
||||
})),
|
||||
};
|
||||
|
||||
const result = await saveShippingOrder(payload);
|
||||
if (result.success) {
|
||||
setIsModalOpen(false);
|
||||
fetchOrders();
|
||||
alert(isEditMode ? "출하지시가 수정되었습니다." : "출하지시가 등록되었습니다.");
|
||||
} else {
|
||||
alert(result.message || "저장 실패");
|
||||
}
|
||||
} catch (err: any) {
|
||||
alert(err.message || "저장 중 오류 발생");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (d: string) => d ? d.split("T")[0] : "-";
|
||||
|
||||
const dataSourceTitle: Record<DataSourceType, string> = {
|
||||
shipmentPlan: "출하계획 목록",
|
||||
salesOrder: "수주정보 목록",
|
||||
itemInfo: "품목정보 목록",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-4rem)] bg-muted/30 p-4 gap-4">
|
||||
{/* 검색 */}
|
||||
<Card className="shrink-0">
|
||||
<CardContent className="p-4 flex flex-wrap items-end gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">출하지시번호</Label>
|
||||
<Input placeholder="검색" className="w-[160px] h-9" value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">거래처</Label>
|
||||
<Input placeholder="거래처 검색" className="w-[140px] h-9" value={searchCustomer}
|
||||
onChange={(e) => setSearchCustomer(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">상태</Label>
|
||||
<Select value={searchStatus} onValueChange={setSearchStatus}>
|
||||
<SelectTrigger className="w-[110px] h-9"><SelectValue placeholder="전체" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">출하일자</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-[160px]">
|
||||
<FormDatePicker value={searchDateFrom} onChange={setSearchDateFrom} placeholder="시작일" />
|
||||
</div>
|
||||
<span className="text-muted-foreground">~</span>
|
||||
<div className="w-[160px]">
|
||||
<FormDatePicker value={searchDateTo} onChange={setSearchDateTo} placeholder="종료일" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-2">
|
||||
{loading && <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />}
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={handleResetSearch}>
|
||||
<RotateCcw className="w-4 h-4 mr-2" /> 초기화
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 메인 테이블 */}
|
||||
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm flex flex-col">
|
||||
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
|
||||
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||
<Truck className="w-5 h-5" /> 출하지시 관리
|
||||
<Badge variant="secondary" className="font-normal">{orders.length}건</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={() => openModal()}>
|
||||
<Plus className="w-4 h-4 mr-1.5" /> 출하지시 등록
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" disabled={checkedIds.length === 0} onClick={handleDeleteSelected}>
|
||||
<Trash2 className="w-4 h-4 mr-1.5" /> 선택삭제 {checkedIds.length > 0 && `(${checkedIds.length})`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px] text-center">
|
||||
<Checkbox checked={orders.length > 0 && checkedIds.length === orders.length} onCheckedChange={handleCheckAll} />
|
||||
</TableHead>
|
||||
<TableHead className="w-[140px]">출하지시번호</TableHead>
|
||||
<TableHead className="w-[100px] text-center">출하일자</TableHead>
|
||||
<TableHead className="w-[120px]">거래처명</TableHead>
|
||||
<TableHead className="w-[100px]">운송업체</TableHead>
|
||||
<TableHead className="w-[90px]">차량번호</TableHead>
|
||||
<TableHead className="w-[80px]">기사명</TableHead>
|
||||
<TableHead className="w-[80px] text-center">상태</TableHead>
|
||||
<TableHead className="w-[100px]">품번</TableHead>
|
||||
<TableHead className="w-[130px]">품명</TableHead>
|
||||
<TableHead className="w-[70px] text-right">수량</TableHead>
|
||||
<TableHead className="w-[80px] text-center">소스</TableHead>
|
||||
<TableHead>비고</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{orders.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={13} className="h-40 text-center text-muted-foreground">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Truck className="w-12 h-12 text-muted-foreground/30" />
|
||||
<div className="font-medium">등록된 출하지시가 없습니다</div>
|
||||
<div className="text-sm">출하지시 등록 버튼으로 등록하세요</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
orders.map((order: any) => {
|
||||
const items = Array.isArray(order.items) ? order.items.filter((it: any) => it.id) : [];
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<TableRow key={order.id} className="cursor-pointer hover:bg-muted/50" onClick={() => openModal(order)}>
|
||||
<TableCell className="text-center" onClick={e => e.stopPropagation()}>
|
||||
<Checkbox checked={checkedIds.includes(order.id)} onCheckedChange={(c) => {
|
||||
if (c) setCheckedIds(p => [...p, order.id]);
|
||||
else setCheckedIds(p => p.filter(i => i !== order.id));
|
||||
}} />
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{order.instruction_no}</TableCell>
|
||||
<TableCell className="text-center">{formatDate(order.instruction_date)}</TableCell>
|
||||
<TableCell>{order.customer_name || "-"}</TableCell>
|
||||
<TableCell>{order.carrier_name || "-"}</TableCell>
|
||||
<TableCell>{order.vehicle_no || "-"}</TableCell>
|
||||
<TableCell>{order.driver_name || "-"}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={cn("px-2 py-1 rounded-full text-[11px] font-medium border", getStatusColor(order.status))}>{getStatusLabel(order.status)}</span>
|
||||
</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell className="text-right">0</TableCell>
|
||||
<TableCell className="text-center">-</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground truncate max-w-[100px]">{order.memo || "-"}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
return items.map((item: any, itemIdx: number) => (
|
||||
<TableRow key={`${order.id}-${item.id}`} className="cursor-pointer hover:bg-muted/50" onClick={() => openModal(order)}>
|
||||
<TableCell className="text-center" onClick={e => e.stopPropagation()}>
|
||||
{itemIdx === 0 && <Checkbox checked={checkedIds.includes(order.id)} onCheckedChange={(c) => {
|
||||
if (c) setCheckedIds(p => [...p, order.id]);
|
||||
else setCheckedIds(p => p.filter(i => i !== order.id));
|
||||
}} />}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{itemIdx === 0 ? order.instruction_no : ""}</TableCell>
|
||||
<TableCell className="text-center">{itemIdx === 0 ? formatDate(order.instruction_date) : ""}</TableCell>
|
||||
<TableCell>{itemIdx === 0 ? (order.customer_name || "-") : ""}</TableCell>
|
||||
<TableCell>{itemIdx === 0 ? (order.carrier_name || "-") : ""}</TableCell>
|
||||
<TableCell>{itemIdx === 0 ? (order.vehicle_no || "-") : ""}</TableCell>
|
||||
<TableCell>{itemIdx === 0 ? (order.driver_name || "-") : ""}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{itemIdx === 0 && <span className={cn("px-2 py-1 rounded-full text-[11px] font-medium border", getStatusColor(order.status))}>{getStatusLabel(order.status)}</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{item.item_code}</TableCell>
|
||||
<TableCell className="font-medium text-sm">{item.item_name}</TableCell>
|
||||
<TableCell className="text-right">{Number(item.order_qty || 0).toLocaleString()}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{(() => { const b = getSourceBadge(item.source_type || ""); return <span className={cn("px-2 py-0.5 rounded-full text-[10px]", b.cls)}>{b.label}</span>; })()}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground truncate max-w-[100px]">{itemIdx === 0 ? (order.memo || "-") : ""}</TableCell>
|
||||
</TableRow>
|
||||
));
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 등록/수정 모달 */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-[90vw] max-h-[90vh] w-[1400px] p-0 flex flex-col overflow-hidden">
|
||||
<DialogHeader className="p-5 pb-4 border-b bg-primary text-primary-foreground shrink-0">
|
||||
<DialogTitle className="text-lg">{isEditMode ? "출하지시 수정" : "출하지시 등록"}</DialogTitle>
|
||||
<DialogDescription className="text-primary-foreground/70">
|
||||
{isEditMode ? "출하지시 정보를 수정합니다." : "왼쪽에서 데이터를 선택하고 오른쪽에서 출하지시 정보를 입력하세요."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
{/* 왼쪽: 데이터 소스 */}
|
||||
<ResizablePanel defaultSize={55} minSize={30}>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="p-3 border-b bg-muted/30 flex flex-wrap items-center gap-2 shrink-0">
|
||||
<Select value={dataSource} onValueChange={(v) => setDataSource(v as DataSourceType)}>
|
||||
<SelectTrigger className="w-[130px] h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="shipmentPlan">출하계획</SelectItem>
|
||||
<SelectItem value="salesOrder">수주정보</SelectItem>
|
||||
<SelectItem value="itemInfo">품목정보</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input placeholder="품번, 품명 검색" className="flex-1 h-8 text-xs min-w-[120px]"
|
||||
value={sourceKeyword} onChange={(e) => setSourceKeyword(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { setSourcePage(1); fetchSourceData(1); }}} />
|
||||
<Button size="sm" className="h-8 text-xs" onClick={() => { setSourcePage(1); fetchSourceData(1); }} disabled={sourceLoading}>
|
||||
{sourceLoading ? <Loader2 className="w-3 h-3 animate-spin" /> : <Search className="w-3 h-3" />}
|
||||
<span className="ml-1">조회</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-2 flex items-center justify-between border-b shrink-0">
|
||||
<div className="text-sm font-medium">
|
||||
{dataSourceTitle[dataSource]}
|
||||
<span className="text-muted-foreground ml-2 font-normal">
|
||||
선택: <span className="text-primary font-semibold">{selectedItems.length}</span>개
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{sourceLoading ? (
|
||||
<div className="flex items-center justify-center py-12"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
|
||||
) : sourceData.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<div className="text-sm">조회 버튼을 눌러 데이터를 불러오세요</div>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px] text-center">선택</TableHead>
|
||||
<TableHead className="w-[100px]">품번</TableHead>
|
||||
<TableHead>품명</TableHead>
|
||||
<TableHead className="w-[100px]">규격</TableHead>
|
||||
<TableHead className="w-[100px]">거래처</TableHead>
|
||||
<TableHead className="w-[70px] text-right">수량</TableHead>
|
||||
{dataSource === "shipmentPlan" && <TableHead className="w-[70px] text-center">상태</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sourceData.map((item: any, idx: number) => {
|
||||
const itemId = dataSource === "itemInfo" ? item.item_code : item.id;
|
||||
const isSelected = selectedItems.some(s => {
|
||||
// 같은 소스 타입에서 id 매칭
|
||||
if (s.sourceType === dataSource) {
|
||||
if (dataSource === "itemInfo") return s.itemCode === itemId;
|
||||
return String(s.id) === String(itemId);
|
||||
}
|
||||
// 다른 소스 타입이라도 같은 품번이면 중복 방지
|
||||
if (dataSource === "shipmentPlan" && s.shipmentPlanId) return String(s.shipmentPlanId) === String(item.id);
|
||||
if (dataSource === "salesOrder" && s.detailId) return String(s.detailId) === String(item.id);
|
||||
return false;
|
||||
});
|
||||
return (
|
||||
<TableRow key={`${dataSource}-${itemId}-${idx}`} className={cn("cursor-pointer hover:bg-muted/50", isSelected && "bg-primary/5")} onClick={() => toggleSourceItem(item)}>
|
||||
<TableCell className="text-center" onClick={e => e.stopPropagation()}>
|
||||
<Checkbox checked={isSelected} onCheckedChange={() => toggleSourceItem(item)} />
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{item.item_code || "-"}</TableCell>
|
||||
<TableCell className="text-sm font-medium">{item.item_name || "-"}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{item.spec || "-"}</TableCell>
|
||||
<TableCell className="text-xs">{item.customer_name || "-"}</TableCell>
|
||||
<TableCell className="text-right text-xs">{Number(item.plan_qty || item.qty || item.balance_qty || 0).toLocaleString()}</TableCell>
|
||||
{dataSource === "shipmentPlan" && (
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="secondary" className="text-[10px]">{getStatusLabel(item.status)}</Badge>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 페이징 */}
|
||||
{sourceTotalCount > 0 && (
|
||||
<div className="px-4 py-2 border-t bg-muted/10 flex items-center justify-between shrink-0">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
총 {sourceTotalCount}건 중 {(sourcePage - 1) * sourcePageSize + 1}-{Math.min(sourcePage * sourcePageSize, sourceTotalCount)}건
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="outline" size="icon" className="h-7 w-7" disabled={sourcePage <= 1}
|
||||
onClick={() => { const p = sourcePage - 1; setSourcePage(p); fetchSourceData(p); }}>
|
||||
<ChevronLeft className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<span className="text-xs font-medium px-2">{sourcePage} / {Math.max(1, Math.ceil(sourceTotalCount / sourcePageSize))}</span>
|
||||
<Button variant="outline" size="icon" className="h-7 w-7" disabled={sourcePage >= Math.ceil(sourceTotalCount / sourcePageSize)}
|
||||
onClick={() => { const p = sourcePage + 1; setSourcePage(p); fetchSourceData(p); }}>
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* 오른쪽: 폼 */}
|
||||
<ResizablePanel defaultSize={45} minSize={30}>
|
||||
<div className="flex flex-col h-full overflow-auto p-5 bg-muted/20 gap-5">
|
||||
{/* 기본 정보 */}
|
||||
<div className="bg-background border rounded-lg p-5 shrink-0">
|
||||
<h3 className="text-sm font-semibold mb-4">기본 정보</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">출하지시번호</Label>
|
||||
<Input value={formOrderNumber} readOnly className="h-9 bg-muted/50 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">출하지시일 <span className="text-destructive">*</span></Label>
|
||||
<FormDatePicker value={formOrderDate} onChange={setFormOrderDate} placeholder="날짜 선택" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">거래처</Label>
|
||||
<Input value={formCustomer} readOnly placeholder="품목 선택 시 자동" className="h-9 bg-muted/50" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">상태</Label>
|
||||
<Select value={formStatus} onValueChange={setFormStatus}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="READY">준비중</SelectItem>
|
||||
<SelectItem value="IN_PROGRESS">진행중</SelectItem>
|
||||
<SelectItem value="COMPLETED">완료</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 운송 정보 */}
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg overflow-hidden shrink-0">
|
||||
<button className="w-full px-5 py-3 flex items-center justify-between text-left" onClick={() => setIsTransportCollapsed(!isTransportCollapsed)}>
|
||||
<h3 className="text-sm font-semibold text-amber-900 flex items-center gap-2">
|
||||
<Truck className="w-4 h-4" /> 운송 정보 <span className="text-[11px] font-normal text-muted-foreground">(선택사항)</span>
|
||||
</h3>
|
||||
{isTransportCollapsed ? <ChevronRight className="w-4 h-4 text-amber-700" /> : <ChevronDown className="w-4 h-4 text-amber-700" />}
|
||||
</button>
|
||||
{!isTransportCollapsed && (
|
||||
<div className="px-5 pb-4 grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1.5"><Label className="text-xs">운송업체</Label><Input value={formCarrier} onChange={(e) => setFormCarrier(e.target.value)} className="h-9" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">차량번호</Label><Input value={formVehicle} onChange={(e) => setFormVehicle(e.target.value)} className="h-9" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">기사명</Label><Input value={formDriver} onChange={(e) => setFormDriver(e.target.value)} className="h-9" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">연락처</Label><Input value={formDriverPhone} onChange={(e) => setFormDriverPhone(e.target.value)} className="h-9" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">도착예정일시</Label><FormDatePicker value={formArrival} onChange={setFormArrival} placeholder="도착예정일시" includeTime /></div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">배송지</Label><Input value={formAddress} onChange={(e) => setFormAddress(e.target.value)} className="h-9" /></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 선택된 품목 */}
|
||||
<div className="bg-background border rounded-lg p-5 flex-1 flex flex-col min-h-[200px]">
|
||||
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
선택된 품목 <Badge variant="default" className="text-[10px]">{selectedItems.length}</Badge>
|
||||
</h3>
|
||||
<div className="flex-1 overflow-auto min-h-0">
|
||||
{selectedItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<div className="text-sm">왼쪽에서 데이터를 선택하세요</div>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px] text-center">소스</TableHead>
|
||||
<TableHead className="w-[90px]">품번</TableHead>
|
||||
<TableHead>품명</TableHead>
|
||||
<TableHead className="w-[90px] text-center">출하수량</TableHead>
|
||||
<TableHead className="w-[70px] text-right">계획수량</TableHead>
|
||||
<TableHead className="w-[40px] text-center">삭제</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selectedItems.map((item, idx) => {
|
||||
const b = getSourceBadge(item.sourceType);
|
||||
return (
|
||||
<TableRow key={`${item.sourceType}-${item.id}-${idx}`}>
|
||||
<TableCell className="text-center">
|
||||
<span className={cn("px-1.5 py-0.5 rounded text-[10px]", b.cls)}>{b.label.charAt(0)}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{item.itemCode}</TableCell>
|
||||
<TableCell className="text-sm font-medium">{item.itemName}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Input type="number" value={item.orderQty} onChange={(e) => updateOrderQty(idx, parseInt(e.target.value) || 0)}
|
||||
min={1} className="h-7 w-[70px] text-xs text-right mx-auto" />
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs">{item.planQty ? item.planQty.toLocaleString() : "-"}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => removeSelectedItem(idx)}>
|
||||
<X className="w-3.5 h-3.5 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메모 */}
|
||||
<div className="bg-background border rounded-lg p-5 shrink-0">
|
||||
<h3 className="text-sm font-semibold mb-3">메모</h3>
|
||||
<Textarea value={formMemo} onChange={(e) => setFormMemo(e.target.value)} placeholder="출하지시 관련 메모" rows={2} className="resize-y" />
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="p-4 border-t bg-muted/30 shrink-0">
|
||||
<Button variant="outline" onClick={() => setIsModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,557 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useMemo, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import { Search, Download, X, Save, Ban, RotateCcw, Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getShipmentPlanList,
|
||||
updateShipmentPlan,
|
||||
type ShipmentPlanListItem,
|
||||
} from "@/lib/api/shipping";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "all", label: "전체" },
|
||||
{ value: "READY", label: "준비" },
|
||||
{ value: "CONFIRMED", label: "확정" },
|
||||
{ value: "SHIPPING", label: "출하중" },
|
||||
{ value: "COMPLETED", label: "완료" },
|
||||
{ value: "CANCEL_REQUEST", label: "취소요청" },
|
||||
{ value: "CANCELLED", label: "취소완료" },
|
||||
];
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
const found = STATUS_OPTIONS.find(o => o.value === status);
|
||||
return found?.label || status;
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "READY": return "bg-blue-100 text-blue-800 border-blue-200";
|
||||
case "CONFIRMED": return "bg-indigo-100 text-indigo-800 border-indigo-200";
|
||||
case "SHIPPING": return "bg-amber-100 text-amber-800 border-amber-200";
|
||||
case "COMPLETED": return "bg-emerald-100 text-emerald-800 border-emerald-200";
|
||||
case "CANCEL_REQUEST": return "bg-rose-100 text-rose-800 border-rose-200";
|
||||
case "CANCELLED": return "bg-slate-100 text-slate-800 border-slate-200";
|
||||
default: return "bg-gray-100 text-gray-800 border-gray-200";
|
||||
}
|
||||
};
|
||||
|
||||
export default function ShippingPlanPage() {
|
||||
const [data, setData] = useState<ShipmentPlanListItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [checkedIds, setCheckedIds] = useState<number[]>([]);
|
||||
|
||||
// 검색
|
||||
const [searchDateFrom, setSearchDateFrom] = useState("");
|
||||
const [searchDateTo, setSearchDateTo] = useState("");
|
||||
const [searchStatus, setSearchStatus] = useState("all");
|
||||
const [searchCustomer, setSearchCustomer] = useState("");
|
||||
const [searchKeyword, setSearchKeyword] = useState("");
|
||||
|
||||
// 상세 패널 편집
|
||||
const [editPlanQty, setEditPlanQty] = useState("");
|
||||
const [editPlanDate, setEditPlanDate] = useState("");
|
||||
const [editMemo, setEditMemo] = useState("");
|
||||
const [isDetailChanged, setIsDetailChanged] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 날짜 초기화
|
||||
useEffect(() => {
|
||||
const today = new Date();
|
||||
const threeMonthsAgo = new Date(today);
|
||||
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
|
||||
const oneMonthLater = new Date(today);
|
||||
oneMonthLater.setMonth(oneMonthLater.getMonth() + 1);
|
||||
|
||||
setSearchDateFrom(threeMonthsAgo.toISOString().split("T")[0]);
|
||||
setSearchDateTo(oneMonthLater.toISOString().split("T")[0]);
|
||||
}, []);
|
||||
|
||||
// 데이터 조회
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {};
|
||||
if (searchDateFrom) params.dateFrom = searchDateFrom;
|
||||
if (searchDateTo) params.dateTo = searchDateTo;
|
||||
if (searchStatus !== "all") params.status = searchStatus;
|
||||
if (searchCustomer.trim()) params.customer = searchCustomer.trim();
|
||||
if (searchKeyword.trim()) params.keyword = searchKeyword.trim();
|
||||
|
||||
const result = await getShipmentPlanList(params);
|
||||
if (result.success) {
|
||||
setData(result.data || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("출하계획 조회 실패:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchDateFrom, searchDateTo, searchStatus, searchCustomer, searchKeyword]);
|
||||
|
||||
// 초기 로드 + 검색 시 자동 조회
|
||||
useEffect(() => {
|
||||
if (searchDateFrom && searchDateTo) {
|
||||
fetchData();
|
||||
}
|
||||
}, [searchDateFrom, searchDateTo]);
|
||||
|
||||
const handleSearch = () => fetchData();
|
||||
|
||||
const handleResetSearch = () => {
|
||||
setSearchStatus("all");
|
||||
setSearchCustomer("");
|
||||
setSearchKeyword("");
|
||||
const today = new Date();
|
||||
const threeMonthsAgo = new Date(today);
|
||||
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
|
||||
const oneMonthLater = new Date(today);
|
||||
oneMonthLater.setMonth(oneMonthLater.getMonth() + 1);
|
||||
setSearchDateFrom(threeMonthsAgo.toISOString().split("T")[0]);
|
||||
setSearchDateTo(oneMonthLater.toISOString().split("T")[0]);
|
||||
};
|
||||
|
||||
const selectedPlan = useMemo(() => data.find(p => p.id === selectedId), [data, selectedId]);
|
||||
|
||||
const groupedData = useMemo(() => {
|
||||
const orderMap = new Map<string, ShipmentPlanListItem[]>();
|
||||
const orderKeys: string[] = [];
|
||||
data.forEach(plan => {
|
||||
const key = plan.order_no || `_no_order_${plan.id}`;
|
||||
if (!orderMap.has(key)) {
|
||||
orderMap.set(key, []);
|
||||
orderKeys.push(key);
|
||||
}
|
||||
orderMap.get(key)!.push(plan);
|
||||
});
|
||||
return orderKeys.map(key => ({
|
||||
orderNo: key,
|
||||
plans: orderMap.get(key)!,
|
||||
}));
|
||||
}, [data]);
|
||||
|
||||
const handleRowClick = (plan: ShipmentPlanListItem) => {
|
||||
if (isDetailChanged && selectedId !== plan.id) {
|
||||
if (!confirm("변경사항이 있습니다. 저장하지 않고 이동하시겠습니까?")) return;
|
||||
}
|
||||
setSelectedId(plan.id);
|
||||
setEditPlanQty(String(Number(plan.plan_qty)));
|
||||
setEditPlanDate(plan.plan_date ? plan.plan_date.split("T")[0] : "");
|
||||
setEditMemo(plan.memo || "");
|
||||
setIsDetailChanged(false);
|
||||
};
|
||||
|
||||
const handleCheckAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setCheckedIds(data.filter(p => p.status !== "CANCELLED").map(p => p.id));
|
||||
} else {
|
||||
setCheckedIds([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheck = (id: number, checked: boolean) => {
|
||||
if (checked) {
|
||||
setCheckedIds(prev => [...prev, id]);
|
||||
} else {
|
||||
setCheckedIds(prev => prev.filter(i => i !== id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveDetail = async () => {
|
||||
if (!selectedId || !selectedPlan) return;
|
||||
|
||||
const qty = Number(editPlanQty);
|
||||
if (qty <= 0) {
|
||||
alert("계획수량은 0보다 커야 합니다.");
|
||||
return;
|
||||
}
|
||||
if (!editPlanDate) {
|
||||
alert("출하계획일을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await updateShipmentPlan(selectedId, {
|
||||
planQty: qty,
|
||||
planDate: editPlanDate,
|
||||
memo: editMemo,
|
||||
});
|
||||
if (result.success) {
|
||||
setIsDetailChanged(false);
|
||||
alert("저장되었습니다.");
|
||||
fetchData();
|
||||
} else {
|
||||
alert(result.message || "저장 실패");
|
||||
}
|
||||
} catch (err: any) {
|
||||
alert(err.message || "저장 중 오류 발생");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return "-";
|
||||
return dateStr.split("T")[0];
|
||||
};
|
||||
|
||||
const formatNumber = (val: string | number) => {
|
||||
const num = Number(val);
|
||||
return isNaN(num) ? "0" : num.toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-4rem)] bg-muted/30 p-4 gap-4">
|
||||
{/* 검색 영역 */}
|
||||
<Card className="shrink-0">
|
||||
<CardContent className="p-4 flex flex-wrap items-end gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">출하계획일</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
className="w-[140px] h-9"
|
||||
value={searchDateFrom}
|
||||
onChange={(e) => setSearchDateFrom(e.target.value)}
|
||||
/>
|
||||
<span className="text-muted-foreground">~</span>
|
||||
<Input
|
||||
type="date"
|
||||
className="w-[140px] h-9"
|
||||
value={searchDateTo}
|
||||
onChange={(e) => setSearchDateTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">상태</Label>
|
||||
<Select value={searchStatus} onValueChange={setSearchStatus}>
|
||||
<SelectTrigger className="w-[120px] h-9">
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map(o => (
|
||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">거래처</Label>
|
||||
<Input
|
||||
placeholder="거래처 검색"
|
||||
className="w-[150px] h-9"
|
||||
value={searchCustomer}
|
||||
onChange={(e) => setSearchCustomer(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">수주번호/품목</Label>
|
||||
<Input
|
||||
placeholder="수주번호 / 품목 검색"
|
||||
className="w-[220px] h-9"
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" className="h-9" onClick={handleSearch} disabled={loading}>
|
||||
{loading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Search className="w-4 h-4 mr-2" />}
|
||||
조회
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={handleResetSearch}>
|
||||
<RotateCcw className="w-4 h-4 mr-2" /> 초기화
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 테이블 + 상세 패널 */}
|
||||
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
<ResizablePanel defaultSize={selectedId ? 65 : 100} minSize={30}>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
|
||||
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||
출하계획 목록
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{data.length}건
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px] text-center">
|
||||
<Checkbox
|
||||
checked={data.length > 0 && checkedIds.length === data.filter(p => p.status !== "CANCELLED").length}
|
||||
onCheckedChange={handleCheckAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-[160px]">수주번호</TableHead>
|
||||
<TableHead className="w-[100px] text-center">납기일</TableHead>
|
||||
<TableHead className="w-[120px]">거래처</TableHead>
|
||||
<TableHead className="w-[100px]">품목코드</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead className="w-[80px] text-right">수주수량</TableHead>
|
||||
<TableHead className="w-[80px] text-right">계획수량</TableHead>
|
||||
<TableHead className="w-[100px] text-center">출하계획일</TableHead>
|
||||
<TableHead className="w-[80px] text-center">상태</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groupedData.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="h-32 text-center text-muted-foreground">
|
||||
출하계획이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
groupedData.map((group) =>
|
||||
group.plans.map((plan, planIdx) => (
|
||||
<TableRow
|
||||
key={plan.id}
|
||||
className={cn(
|
||||
"cursor-pointer hover:bg-muted/50 transition-colors",
|
||||
selectedId === plan.id && "bg-primary/5",
|
||||
plan.status === "CANCELLED" && "opacity-60 bg-slate-50",
|
||||
planIdx === 0 && "border-t-2 border-t-border"
|
||||
)}
|
||||
onClick={() => handleRowClick(plan)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={e => e.stopPropagation()}>
|
||||
{planIdx === 0 && (
|
||||
<Checkbox
|
||||
checked={group.plans.every(p => checkedIds.includes(p.id))}
|
||||
onCheckedChange={(c) => {
|
||||
if (c) {
|
||||
setCheckedIds(prev => [...new Set([...prev, ...group.plans.filter(p => p.status !== "CANCELLED").map(p => p.id)])]);
|
||||
} else {
|
||||
setCheckedIds(prev => prev.filter(id => !group.plans.some(p => p.id === id)));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{planIdx === 0 ? (plan.order_no || "-") : ""}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{planIdx === 0 ? formatDate(plan.due_date) : ""}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{planIdx === 0 ? (plan.customer_name || "-") : ""}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-xs">{plan.part_code || "-"}</TableCell>
|
||||
<TableCell className="font-medium">{plan.part_name || "-"}</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(plan.order_qty)}</TableCell>
|
||||
<TableCell className="text-right font-semibold text-primary">{formatNumber(plan.plan_qty)}</TableCell>
|
||||
<TableCell className="text-center">{formatDate(plan.plan_date)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={cn("px-2 py-1 rounded-full text-[11px] font-medium border", getStatusColor(plan.status))}>
|
||||
{getStatusLabel(plan.status)}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
{/* 상세 패널 */}
|
||||
{selectedId && selectedPlan && (
|
||||
<>
|
||||
<ResizableHandle withHandle />
|
||||
<ResizablePanel defaultSize={35} minSize={25}>
|
||||
<div className="flex flex-col h-full bg-card">
|
||||
<div className="flex items-center justify-between p-3 border-b shrink-0">
|
||||
<span className="font-semibold text-sm">
|
||||
{selectedPlan.shipment_plan_no || `#${selectedPlan.id}`}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveDetail}
|
||||
disabled={!isDetailChanged || saving}
|
||||
className={cn(isDetailChanged ? "bg-primary" : "bg-muted text-muted-foreground")}
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
|
||||
저장
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setSelectedId(null)}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4 space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold mb-3">기본 정보</h3>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block mb-1">상태</span>
|
||||
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium border inline-block", getStatusColor(selectedPlan.status))}>
|
||||
{getStatusLabel(selectedPlan.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block mb-1">수주번호</span>
|
||||
<span>{selectedPlan.order_no || "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block mb-1">거래처</span>
|
||||
<span>{selectedPlan.customer_name || "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block mb-1">등록일</span>
|
||||
<span>{formatDate(selectedPlan.created_date)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block mb-1">납기일</span>
|
||||
<span>{formatDate(selectedPlan.due_date)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 품목 정보 */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold mb-3">품목 정보</h3>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3 text-sm bg-muted/30 p-3 rounded-md border border-border/50">
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block mb-1">품목코드</span>
|
||||
<span>{selectedPlan.part_code || "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block mb-1">품목명</span>
|
||||
<span className="font-medium">{selectedPlan.part_name || "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block mb-1">규격</span>
|
||||
<span>{selectedPlan.spec || "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block mb-1">재질</span>
|
||||
<span>{selectedPlan.material || "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 수량 정보 */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold mb-3">수량 정보</h3>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block mb-1">수주수량</span>
|
||||
<span>{formatNumber(selectedPlan.order_qty)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs block mb-1">계획수량</Label>
|
||||
<Input
|
||||
type="number"
|
||||
className="h-8"
|
||||
value={editPlanQty}
|
||||
onChange={(e) => { setEditPlanQty(e.target.value); setIsDetailChanged(true); }}
|
||||
disabled={selectedPlan.status === "CANCELLED" || selectedPlan.status === "COMPLETED"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block mb-1">출하수량</span>
|
||||
<span>{formatNumber(selectedPlan.shipped_qty)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block mb-1">잔여수량</span>
|
||||
<span className={cn("font-semibold",
|
||||
(Number(selectedPlan.plan_qty) - Number(selectedPlan.shipped_qty)) > 0
|
||||
? "text-destructive"
|
||||
: "text-emerald-600"
|
||||
)}>
|
||||
{formatNumber(Number(selectedPlan.plan_qty) - Number(selectedPlan.shipped_qty))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 출하 정보 */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold mb-3">출하 정보</h3>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-4 text-sm">
|
||||
<div className="col-span-2">
|
||||
<Label className="text-muted-foreground text-xs block mb-1">출하계획일</Label>
|
||||
<Input
|
||||
type="date"
|
||||
className="h-8"
|
||||
value={editPlanDate}
|
||||
onChange={(e) => { setEditPlanDate(e.target.value); setIsDetailChanged(true); }}
|
||||
disabled={selectedPlan.status === "CANCELLED" || selectedPlan.status === "COMPLETED"}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-muted-foreground text-xs block mb-1">비고</Label>
|
||||
<Textarea
|
||||
className="min-h-[80px] resize-y"
|
||||
value={editMemo}
|
||||
onChange={(e) => { setEditMemo(e.target.value); setIsDetailChanged(true); }}
|
||||
disabled={selectedPlan.status === "CANCELLED" || selectedPlan.status === "COMPLETED"}
|
||||
placeholder="비고 입력"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 등록자 정보 */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold mb-3">등록 정보</h3>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3 text-sm text-muted-foreground">
|
||||
<div>
|
||||
<span className="text-xs block mb-1">등록자</span>
|
||||
<span className="text-foreground">{selectedPlan.created_by || "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs block mb-1">등록일시</span>
|
||||
<span className="text-foreground">{selectedPlan.created_date ? new Date(selectedPlan.created_date).toLocaleString("ko-KR") : "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -306,16 +306,126 @@ select {
|
|||
}
|
||||
}
|
||||
|
||||
/* ===== Sonner 토스트 애니메이션 완전 제거 ===== */
|
||||
[data-sonner-toaster] [data-sonner-toast] {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
/* ===== Sonner Toast - B안 (하단 중앙 스낵바) ===== */
|
||||
|
||||
/* 기본 토스트: 다크 배경 스낵바 */
|
||||
[data-sonner-toaster] [data-sonner-toast].sonner-toast-snackbar {
|
||||
--normal-bg: hsl(222 30% 16%);
|
||||
--normal-text: hsl(210 20% 92%);
|
||||
--normal-border: hsl(222 20% 24%);
|
||||
|
||||
background: var(--normal-bg);
|
||||
color: var(--normal-text);
|
||||
border: 1px solid var(--normal-border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
gap: 10px;
|
||||
}
|
||||
[data-sonner-toaster] [data-sonner-toast][data-mounted="true"] {
|
||||
animation: none !important;
|
||||
|
||||
/* 다크모드 토스트 */
|
||||
.dark [data-sonner-toaster] [data-sonner-toast].sonner-toast-snackbar {
|
||||
--normal-bg: hsl(220 25% 14%);
|
||||
--normal-border: hsl(220 20% 22%);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* 성공 토스트 - 좌측 초록 바 */
|
||||
[data-sonner-toaster] [data-sonner-toast].sonner-toast-success {
|
||||
--success-bg: hsl(222 30% 16%);
|
||||
--success-text: hsl(210 20% 92%);
|
||||
--success-border: hsl(222 20% 24%);
|
||||
|
||||
background: var(--success-bg) !important;
|
||||
color: var(--success-text) !important;
|
||||
border: 1px solid var(--success-border) !important;
|
||||
border-left: 3px solid hsl(142 76% 42%) !important;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.dark [data-sonner-toaster] [data-sonner-toast].sonner-toast-success {
|
||||
--success-bg: hsl(220 25% 14%) !important;
|
||||
--success-border: hsl(220 20% 22%) !important;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* 에러 토스트 - 좌측 빨간 바 + 약간 붉은 배경 */
|
||||
[data-sonner-toaster] [data-sonner-toast].sonner-toast-error {
|
||||
--error-bg: hsl(0 30% 14%);
|
||||
--error-text: hsl(0 20% 92%);
|
||||
--error-border: hsl(0 20% 22%);
|
||||
|
||||
background: var(--error-bg) !important;
|
||||
color: var(--error-text) !important;
|
||||
border: 1px solid var(--error-border) !important;
|
||||
border-left: 3px solid hsl(0 72% 55%) !important;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.dark [data-sonner-toaster] [data-sonner-toast].sonner-toast-error {
|
||||
--error-bg: hsl(0 25% 10%) !important;
|
||||
--error-border: hsl(0 20% 18%) !important;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* 경고 토스트 - 좌측 노란 바 */
|
||||
[data-sonner-toaster] [data-sonner-toast].sonner-toast-warning {
|
||||
background: hsl(222 30% 16%) !important;
|
||||
color: hsl(210 20% 92%) !important;
|
||||
border: 1px solid hsl(222 20% 24%) !important;
|
||||
border-left: 3px solid hsl(38 92% 50%) !important;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* info 토스트 - 좌측 파란 바 */
|
||||
[data-sonner-toaster] [data-sonner-toast].sonner-toast-info {
|
||||
background: hsl(222 30% 16%) !important;
|
||||
color: hsl(210 20% 92%) !important;
|
||||
border: 1px solid hsl(222 20% 24%) !important;
|
||||
border-left: 3px solid hsl(217 91% 60%) !important;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* 토스트 내부 설명 텍스트 */
|
||||
[data-sonner-toaster] [data-sonner-toast] [data-description] {
|
||||
color: hsl(210 15% 70%) !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
/* 토스트 닫기 버튼: 토스트 안쪽 우측 상단 배치 */
|
||||
[data-sonner-toaster] [data-sonner-toast] [data-close-button] {
|
||||
position: absolute !important;
|
||||
top: 50% !important;
|
||||
right: 8px !important;
|
||||
left: auto !important;
|
||||
transform: translateY(-50%) !important;
|
||||
width: 20px !important;
|
||||
height: 20px !important;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
border-radius: 4px !important;
|
||||
color: hsl(210 15% 55%) !important;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.15s, background 0.15s;
|
||||
}
|
||||
[data-sonner-toaster] [data-sonner-toast] [data-close-button]:hover {
|
||||
background: hsl(220 20% 24%) !important;
|
||||
color: hsl(210 20% 85%) !important;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 토스트 액션 버튼 */
|
||||
[data-sonner-toaster] [data-sonner-toast] [data-button] {
|
||||
color: hsl(217 91% 68%) !important;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 애니메이션 제어: 부드러운 슬라이드 업만 허용, 나머지 제거 */
|
||||
[data-sonner-toaster] [data-sonner-toast][data-removed="true"] {
|
||||
animation: none !important;
|
||||
}
|
||||
|
|
@ -398,6 +508,16 @@ select {
|
|||
font-family: "Gaegu", cursive;
|
||||
}
|
||||
|
||||
/* ===== 전체 폰트 사이즈 16px 통일 (버튼 제외) ===== */
|
||||
body *:not(button, [role="button"]) {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
body button *,
|
||||
body [role="button"] * {
|
||||
font-size: inherit !important;
|
||||
}
|
||||
|
||||
/* ===== Component-Specific Overrides ===== */
|
||||
/* 필요시 특정 컴포넌트에 대한 스타일 오버라이드를 여기에 추가 */
|
||||
/* 예: Calendar, Table 등의 미세 조정 */
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import "./globals.css";
|
|||
import { ThemeProvider } from "@/components/providers/ThemeProvider";
|
||||
import { QueryProvider } from "@/providers/QueryProvider";
|
||||
import { RegistryProvider } from "./registry-provider";
|
||||
import { Toaster } from "sonner";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
|
|
@ -45,7 +45,7 @@ export default function RootLayout({
|
|||
<ThemeProvider>
|
||||
<QueryProvider>
|
||||
<RegistryProvider>{children}</RegistryProvider>
|
||||
<Toaster position="top-right" />
|
||||
<Toaster />
|
||||
</QueryProvider>
|
||||
</ThemeProvider>
|
||||
{/* Portal 컨테이너 */}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
Activity,
|
||||
Settings
|
||||
} from "lucide-react";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { BatchConfig } from "@/lib/api/batch";
|
||||
|
||||
interface BatchCardProps {
|
||||
|
|
@ -78,7 +79,7 @@ export default function BatchCard({
|
|||
생성일
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{new Date(batch.created_date).toLocaleDateString('ko-KR')}
|
||||
{batch.created_date ? new Date(batch.created_date).toLocaleDateString('ko-KR') : '-'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -118,7 +119,7 @@ export default function BatchCard({
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onExecute(batch.id)}
|
||||
onClick={() => batch.id != null && onExecute(batch.id)}
|
||||
disabled={isExecuting}
|
||||
className="h-9 flex-1 gap-2 text-sm"
|
||||
>
|
||||
|
|
@ -134,7 +135,7 @@ export default function BatchCard({
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onToggleStatus(batch.id, batch.is_active)}
|
||||
onClick={() => batch.id != null && onToggleStatus(batch.id, batch.is_active || 'N')}
|
||||
className="h-9 flex-1 gap-2 text-sm"
|
||||
>
|
||||
{isActive ? (
|
||||
|
|
@ -149,7 +150,7 @@ export default function BatchCard({
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onEdit(batch.id)}
|
||||
onClick={() => batch.id != null && onEdit(batch.id)}
|
||||
className="h-9 flex-1 gap-2 text-sm"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
|
|
@ -160,7 +161,7 @@ export default function BatchCard({
|
|||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => onDelete(batch.id, batch.batch_name)}
|
||||
onClick={() => batch.id != null && onDelete(batch.id, batch.batch_name)}
|
||||
className="h-9 flex-1 gap-2 text-sm"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -22,14 +22,13 @@ import { cn } from "@/lib/utils";
|
|||
import type { ColumnTypeInfo, TableInfo, SecondLevelMenu } from "./types";
|
||||
import { INPUT_TYPE_COLORS } from "./types";
|
||||
import type { ReferenceTableColumn } from "@/lib/api/entityJoin";
|
||||
import type { NumberingRuleConfig } from "@/types/numbering-rule";
|
||||
|
||||
export interface ColumnDetailPanelProps {
|
||||
column: ColumnTypeInfo | null;
|
||||
tables: TableInfo[];
|
||||
referenceTableColumns: Record<string, ReferenceTableColumn[]>;
|
||||
secondLevelMenus: SecondLevelMenu[];
|
||||
numberingRules: NumberingRuleConfig[];
|
||||
numberingRules: any[];
|
||||
onColumnChange: (field: keyof ColumnTypeInfo, value: unknown) => void;
|
||||
onClose: () => void;
|
||||
onLoadReferenceColumns?: (tableName: string) => void;
|
||||
|
|
@ -53,7 +52,6 @@ export function ColumnDetailPanel({
|
|||
const [advancedOpen, setAdvancedOpen] = React.useState(false);
|
||||
const [entityTableOpen, setEntityTableOpen] = React.useState(false);
|
||||
const [entityColumnOpen, setEntityColumnOpen] = React.useState(false);
|
||||
const [numberingOpen, setNumberingOpen] = React.useState(false);
|
||||
|
||||
const typeConf = column ? INPUT_TYPE_COLORS[column.inputType || "text"] : null;
|
||||
const refColumns = column?.referenceTable
|
||||
|
|
@ -404,53 +402,10 @@ export function ColumnDetailPanel({
|
|||
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
||||
<Label className="text-sm font-medium">채번 규칙</Label>
|
||||
</div>
|
||||
<Popover open={numberingOpen} onOpenChange={setNumberingOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="h-9 w-full justify-between text-xs">
|
||||
{column.numberingRuleId
|
||||
? numberingRules.find((r) => r.ruleId === column.numberingRuleId)?.ruleName ?? column.numberingRuleId
|
||||
: "규칙 선택..."}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[280px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="규칙 검색..." className="h-8 text-xs" />
|
||||
<CommandList className="max-h-[200px]">
|
||||
<CommandEmpty className="py-2 text-center text-xs">규칙을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="none"
|
||||
onSelect={() => {
|
||||
onColumnChange("numberingRuleId", undefined);
|
||||
setNumberingOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check className={cn("mr-2 h-3 w-3", !column.numberingRuleId ? "opacity-100" : "opacity-0")} />
|
||||
선택 안함
|
||||
</CommandItem>
|
||||
{numberingRules.map((r) => (
|
||||
<CommandItem
|
||||
key={r.ruleId}
|
||||
value={`${r.ruleName} ${r.ruleId}`}
|
||||
onSelect={() => {
|
||||
onColumnChange("numberingRuleId", r.ruleId);
|
||||
setNumberingOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-3 w-3", column.numberingRuleId === r.ruleId ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
{r.ruleName}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="rounded-md border border-border bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
|
||||
채번 규칙은 옵션설정 > 채번설정에서 관리합니다.
|
||||
타입을 저장하면 자동으로 채번 목록에 표시됩니다.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -17,12 +11,39 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { MoreHorizontal, Trash2, Copy, Plus, Search, Network, Calendar } from "lucide-react";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Network,
|
||||
RefreshCw,
|
||||
Pencil,
|
||||
Copy,
|
||||
Trash2,
|
||||
LayoutGrid,
|
||||
List,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView";
|
||||
import { getNodePaletteItem } from "@/components/dataflow/node-editor/sidebar/nodePaletteConfig";
|
||||
|
||||
interface TopologyNode {
|
||||
id: string;
|
||||
type: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface FlowSummary {
|
||||
nodeCount: number;
|
||||
edgeCount: number;
|
||||
nodeTypes: Record<string, number>;
|
||||
topology: {
|
||||
nodes: TopologyNode[];
|
||||
edges: [string, string][];
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface NodeFlow {
|
||||
flowId: number;
|
||||
|
|
@ -30,18 +51,205 @@ interface NodeFlow {
|
|||
flowDescription: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
summary: FlowSummary;
|
||||
}
|
||||
|
||||
interface DataFlowListProps {
|
||||
onLoadFlow: (flowId: number | null) => void;
|
||||
}
|
||||
|
||||
const CATEGORY_COLORS: Record<string, { text: string; bg: string; border: string }> = {
|
||||
source: { text: "text-teal-400", bg: "bg-teal-500/10", border: "border-teal-500/20" },
|
||||
transform: { text: "text-violet-400", bg: "bg-violet-500/10", border: "border-violet-500/20" },
|
||||
action: { text: "text-emerald-400", bg: "bg-emerald-500/10", border: "border-emerald-500/20" },
|
||||
external: { text: "text-pink-400", bg: "bg-pink-500/10", border: "border-pink-500/20" },
|
||||
utility: { text: "text-zinc-400", bg: "bg-zinc-500/10", border: "border-zinc-500/20" },
|
||||
};
|
||||
|
||||
function getNodeCategoryColor(nodeType: string) {
|
||||
const item = getNodePaletteItem(nodeType);
|
||||
const cat = item?.category || "utility";
|
||||
return CATEGORY_COLORS[cat] || CATEGORY_COLORS.utility;
|
||||
}
|
||||
|
||||
function getNodeLabel(nodeType: string) {
|
||||
const item = getNodePaletteItem(nodeType);
|
||||
return item?.label || nodeType;
|
||||
}
|
||||
|
||||
function getNodeColor(nodeType: string): string {
|
||||
const item = getNodePaletteItem(nodeType);
|
||||
return item?.color || "#6B7280";
|
||||
}
|
||||
|
||||
function relativeTime(dateStr: string): string {
|
||||
const now = Date.now();
|
||||
const d = new Date(dateStr).getTime();
|
||||
const diff = now - d;
|
||||
const min = Math.floor(diff / 60000);
|
||||
if (min < 1) return "방금 전";
|
||||
if (min < 60) return `${min}분 전`;
|
||||
const h = Math.floor(min / 60);
|
||||
if (h < 24) return `${h}시간 전`;
|
||||
const day = Math.floor(h / 24);
|
||||
if (day < 30) return `${day}일 전`;
|
||||
const month = Math.floor(day / 30);
|
||||
return `${month}개월 전`;
|
||||
}
|
||||
|
||||
function MiniTopology({ topology }: { topology: FlowSummary["topology"] }) {
|
||||
if (!topology || topology.nodes.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<span className="font-mono text-[10px] text-zinc-600">빈 캔버스</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const W = 340;
|
||||
const H = 88;
|
||||
const padX = 40;
|
||||
const padY = 18;
|
||||
|
||||
const nodeMap = new Map(topology.nodes.map((n) => [n.id, n]));
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} fill="none" className="h-full w-full">
|
||||
{topology.edges.map(([src, tgt], i) => {
|
||||
const s = nodeMap.get(src);
|
||||
const t = nodeMap.get(tgt);
|
||||
if (!s || !t) return null;
|
||||
const sx = padX + s.x * (W - padX * 2);
|
||||
const sy = padY + s.y * (H - padY * 2);
|
||||
const tx = padX + t.x * (W - padX * 2);
|
||||
const ty = padY + t.y * (H - padY * 2);
|
||||
const mx = (sx + tx) / 2;
|
||||
const my = (sy + ty) / 2 - 8;
|
||||
return (
|
||||
<path
|
||||
key={`e-${i}`}
|
||||
d={`M${sx} ${sy}Q${mx} ${my} ${tx} ${ty}`}
|
||||
stroke="rgba(108,92,231,0.25)"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{topology.nodes.map((n) => {
|
||||
const cx = padX + n.x * (W - padX * 2);
|
||||
const cy = padY + n.y * (H - padY * 2);
|
||||
const color = getNodeColor(n.type);
|
||||
return (
|
||||
<g key={n.id}>
|
||||
<circle cx={cx} cy={cy} r="5" fill={`${color}20`} stroke={color} strokeWidth="1.5" />
|
||||
<circle cx={cx} cy={cy} r="2" fill={color} />
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FlowCard({
|
||||
flow,
|
||||
onOpen,
|
||||
onCopy,
|
||||
onDelete,
|
||||
}: {
|
||||
flow: NodeFlow;
|
||||
onOpen: () => void;
|
||||
onCopy: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const chips = useMemo(() => {
|
||||
const entries = Object.entries(flow.summary?.nodeTypes || {});
|
||||
return entries.slice(0, 4).map(([type, count]) => {
|
||||
const colors = getNodeCategoryColor(type);
|
||||
const label = getNodeLabel(type);
|
||||
return { type, count, label, colors };
|
||||
});
|
||||
}, [flow.summary?.nodeTypes]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group relative cursor-pointer overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900/80 transition-all duration-200 hover:-translate-y-0.5 hover:border-violet-500/50 hover:shadow-lg hover:shadow-violet-500/5"
|
||||
onClick={onOpen}
|
||||
>
|
||||
{/* 미니 토폴로지 */}
|
||||
<div className="relative h-[88px] overflow-hidden border-b border-zinc-800/60 bg-gradient-to-b from-violet-500/[0.03] to-transparent">
|
||||
<MiniTopology topology={flow.summary?.topology} />
|
||||
</div>
|
||||
|
||||
{/* 카드 바디 */}
|
||||
<div className="px-4 pb-3 pt-3.5">
|
||||
<h3 className="mb-1 truncate text-sm font-semibold tracking-tight text-zinc-100">
|
||||
{flow.flowName}
|
||||
</h3>
|
||||
<p className="mb-3 line-clamp-2 min-h-[2.5rem] text-[11px] leading-relaxed text-zinc-500">
|
||||
{flow.flowDescription || "설명이 아직 없어요"}
|
||||
</p>
|
||||
|
||||
{/* 노드 타입 칩 */}
|
||||
{chips.length > 0 && (
|
||||
<div className="mb-3 flex flex-wrap gap-1.5">
|
||||
{chips.map(({ type, count, label, colors }) => (
|
||||
<span
|
||||
key={type}
|
||||
className={`inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 font-mono text-[10px] font-semibold ${colors.text} ${colors.bg} ${colors.border}`}
|
||||
>
|
||||
{label} {count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 카드 푸터 */}
|
||||
<div className="flex items-center justify-between border-t border-zinc-800/40 px-4 py-2.5">
|
||||
<span className="font-mono text-[11px] text-zinc-600">
|
||||
수정 {relativeTime(flow.updatedAt)}
|
||||
</span>
|
||||
<div className="flex gap-0.5">
|
||||
<button
|
||||
className="flex h-7 w-7 items-center justify-center rounded-lg text-zinc-500 transition-colors hover:bg-violet-500/10 hover:text-violet-400"
|
||||
title="편집"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpen();
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className="flex h-7 w-7 items-center justify-center rounded-lg text-zinc-500 transition-colors hover:bg-violet-500/10 hover:text-violet-400"
|
||||
title="복사"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCopy();
|
||||
}}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className="flex h-7 w-7 items-center justify-center rounded-lg text-zinc-500 transition-colors hover:bg-pink-500/10 hover:text-pink-400"
|
||||
title="삭제"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DataFlowList({ onLoadFlow }: DataFlowListProps) {
|
||||
const { user } = useAuth();
|
||||
const [flows, setFlows] = useState<NodeFlow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [selectedFlow, setSelectedFlow] = useState<NodeFlow | null>(null);
|
||||
|
||||
|
|
@ -49,7 +257,6 @@ export default function DataFlowList({ onLoadFlow }: DataFlowListProps) {
|
|||
try {
|
||||
setLoading(true);
|
||||
const response = await apiClient.get("/dataflow/node-flows");
|
||||
|
||||
if (response.data.success) {
|
||||
setFlows(response.data.data);
|
||||
} else {
|
||||
|
|
@ -57,7 +264,9 @@ export default function DataFlowList({ onLoadFlow }: DataFlowListProps) {
|
|||
}
|
||||
} catch (error) {
|
||||
console.error("플로우 목록 조회 실패", error);
|
||||
showErrorToast("플로우 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." });
|
||||
showErrorToast("플로우 목록을 불러오는 데 실패했어요", error, {
|
||||
guidance: "네트워크 연결을 확인해 주세요.",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -75,30 +284,26 @@ export default function DataFlowList({ onLoadFlow }: DataFlowListProps) {
|
|||
const handleCopy = async (flow: NodeFlow) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const response = await apiClient.get(`/dataflow/node-flows/${flow.flowId}`);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "플로우 조회 실패");
|
||||
}
|
||||
|
||||
const originalFlow = response.data.data;
|
||||
if (!response.data.success) throw new Error(response.data.message || "플로우 조회 실패");
|
||||
|
||||
const copyResponse = await apiClient.post("/dataflow/node-flows", {
|
||||
flowName: `${flow.flowName} (복사본)`,
|
||||
flowDescription: flow.flowDescription,
|
||||
flowData: originalFlow.flowData,
|
||||
flowData: response.data.data.flowData,
|
||||
});
|
||||
|
||||
if (copyResponse.data.success) {
|
||||
toast.success(`플로우가 성공적으로 복사되었습니다`);
|
||||
toast.success("플로우를 복사했어요");
|
||||
await loadFlows();
|
||||
} else {
|
||||
throw new Error(copyResponse.data.message || "플로우 복사 실패");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("플로우 복사 실패:", error);
|
||||
showErrorToast("플로우 복사에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
|
||||
showErrorToast("플로우 복사에 실패했어요", error, {
|
||||
guidance: "잠시 후 다시 시도해 주세요.",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -106,20 +311,20 @@ export default function DataFlowList({ onLoadFlow }: DataFlowListProps) {
|
|||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!selectedFlow) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await apiClient.delete(`/dataflow/node-flows/${selectedFlow.flowId}`);
|
||||
|
||||
if (response.data.success) {
|
||||
toast.success(`플로우가 삭제되었습니다: ${selectedFlow.flowName}`);
|
||||
toast.success(`"${selectedFlow.flowName}" 플로우를 삭제했어요`);
|
||||
await loadFlows();
|
||||
} else {
|
||||
throw new Error(response.data.message || "플로우 삭제 실패");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("플로우 삭제 실패:", error);
|
||||
showErrorToast("플로우 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
|
||||
showErrorToast("플로우 삭제에 실패했어요", error, {
|
||||
guidance: "잠시 후 다시 시도해 주세요.",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setShowDeleteModal(false);
|
||||
|
|
@ -127,170 +332,241 @@ export default function DataFlowList({ onLoadFlow }: DataFlowListProps) {
|
|||
}
|
||||
};
|
||||
|
||||
const filteredFlows = flows.filter(
|
||||
(flow) =>
|
||||
flow.flowName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
flow.flowDescription.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
const filteredFlows = useMemo(
|
||||
() =>
|
||||
flows.filter(
|
||||
(f) =>
|
||||
f.flowName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(f.flowDescription || "").toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
),
|
||||
[flows, searchTerm],
|
||||
);
|
||||
|
||||
// DropdownMenu 렌더러 (테이블 + 카드 공통)
|
||||
const renderDropdownMenu = (flow: NodeFlow) => (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onLoadFlow(flow.flowId)}>
|
||||
<Network className="mr-2 h-4 w-4" />
|
||||
불러오기
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleCopy(flow)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
복사
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleDelete(flow)} className="text-destructive">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
삭제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
const stats = useMemo(() => {
|
||||
let totalNodes = 0;
|
||||
let totalEdges = 0;
|
||||
flows.forEach((f) => {
|
||||
totalNodes += f.summary?.nodeCount || 0;
|
||||
totalEdges += f.summary?.edgeCount || 0;
|
||||
});
|
||||
return { total: flows.length, totalNodes, totalEdges };
|
||||
}, [flows]);
|
||||
|
||||
const columns: RDVColumn<NodeFlow>[] = [
|
||||
{
|
||||
key: "flowName",
|
||||
label: "플로우명",
|
||||
render: (_val, flow) => (
|
||||
<div className="flex items-center font-medium">
|
||||
<Network className="mr-2 h-4 w-4 text-primary" />
|
||||
{flow.flowName}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "flowDescription",
|
||||
label: "설명",
|
||||
render: (_val, flow) => (
|
||||
<span className="text-muted-foreground">{flow.flowDescription || "설명 없음"}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
label: "생성일",
|
||||
render: (_val, flow) => (
|
||||
<span className="flex items-center text-muted-foreground">
|
||||
<Calendar className="mr-1 h-3 w-3" />
|
||||
{new Date(flow.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "updatedAt",
|
||||
label: "최근 수정",
|
||||
hideOnMobile: true,
|
||||
render: (_val, flow) => (
|
||||
<span className="flex items-center text-muted-foreground">
|
||||
<Calendar className="mr-1 h-3 w-3" />
|
||||
{new Date(flow.updatedAt).toLocaleDateString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const cardFields: RDVCardField<NodeFlow>[] = [
|
||||
{
|
||||
label: "생성일",
|
||||
render: (flow) => new Date(flow.createdAt).toLocaleDateString(),
|
||||
},
|
||||
{
|
||||
label: "최근 수정",
|
||||
render: (flow) => new Date(flow.updatedAt).toLocaleDateString(),
|
||||
},
|
||||
];
|
||||
if (loading && flows.length === 0) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 검색 및 액션 영역 */}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div className="w-full sm:w-[400px]">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="플로우명, 설명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="bg-gradient-to-r from-zinc-100 to-violet-300 bg-clip-text text-2xl font-bold tracking-tight text-transparent sm:text-3xl">
|
||||
제어 관리
|
||||
</h1>
|
||||
<p className="mt-1 text-xs text-zinc-500 sm:text-sm">
|
||||
노드 기반 데이터 플로우를 시각적으로 설계하고 관리해요
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
총 <span className="font-semibold text-foreground">{filteredFlows.length}</span> 건
|
||||
</div>
|
||||
<Button onClick={() => onLoadFlow(null)} className="h-10 gap-2 text-sm font-medium">
|
||||
<Plus className="h-4 w-4" />
|
||||
새 플로우 생성
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadFlows}
|
||||
disabled={loading}
|
||||
className="gap-1.5 border-zinc-700 bg-zinc-900 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200"
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onLoadFlow(null)}
|
||||
className="gap-1.5 bg-violet-600 font-semibold text-white shadow-lg shadow-violet-600/25 hover:bg-violet-500"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
새 플로우
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 빈 상태: 커스텀 Empty UI */}
|
||||
{!loading && filteredFlows.length === 0 ? (
|
||||
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||
<Network className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">플로우가 없습니다</h3>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">
|
||||
새 플로우를 생성하여 노드 기반 데이터 제어를 설계해보세요.
|
||||
</p>
|
||||
<Button onClick={() => onLoadFlow(null)} className="mt-4 h-10 gap-2 text-sm font-medium">
|
||||
{/* 통계 스트립 */}
|
||||
<div className="flex flex-wrap items-center gap-5 border-b border-zinc-800/60 pb-4">
|
||||
<div className="flex items-center gap-1.5 text-xs text-zinc-500">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-violet-500" />
|
||||
전체{" "}
|
||||
<strong className="font-mono font-bold text-zinc-200">{stats.total}</strong>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-zinc-500">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-zinc-600" />
|
||||
총 노드{" "}
|
||||
<strong className="font-mono font-bold text-zinc-300">{stats.totalNodes}</strong>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-zinc-500">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-zinc-600" />
|
||||
총 연결{" "}
|
||||
<strong className="font-mono font-bold text-zinc-300">{stats.totalEdges}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 툴바 */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative min-w-[200px] flex-1 sm:max-w-[360px]">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-600" />
|
||||
<Input
|
||||
placeholder="플로우 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-10 border-zinc-700 bg-zinc-900 pl-10 text-sm text-zinc-200 placeholder:text-zinc-600 focus-visible:ring-violet-500/40"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-0.5 rounded-lg border border-zinc-700 bg-zinc-900 p-0.5">
|
||||
<button
|
||||
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
viewMode === "grid"
|
||||
? "bg-violet-500/10 text-violet-400"
|
||||
: "text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
onClick={() => setViewMode("grid")}
|
||||
>
|
||||
<LayoutGrid className="h-3.5 w-3.5" />
|
||||
그리드
|
||||
</button>
|
||||
<button
|
||||
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
viewMode === "list"
|
||||
? "bg-violet-500/10 text-violet-400"
|
||||
: "text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
onClick={() => setViewMode("list")}
|
||||
>
|
||||
<List className="h-3.5 w-3.5" />
|
||||
리스트
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 컨텐츠 */}
|
||||
{filteredFlows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-zinc-700 px-6 py-20 text-center">
|
||||
<div className="mb-5 flex h-20 w-20 items-center justify-center rounded-2xl border border-violet-500/15 bg-violet-500/[0.08]">
|
||||
<Network className="h-9 w-9 text-violet-400" />
|
||||
</div>
|
||||
<h2 className="mb-2 text-lg font-bold text-zinc-200">
|
||||
{searchTerm ? "검색 결과가 없어요" : "아직 플로우가 없어요"}
|
||||
</h2>
|
||||
<p className="mb-6 max-w-sm text-sm leading-relaxed text-zinc-500">
|
||||
{searchTerm
|
||||
? `"${searchTerm}"에 해당하는 플로우를 찾지 못했어요. 다른 키워드로 검색해 보세요.`
|
||||
: "노드를 연결해서 데이터 처리 파이프라인을 만들어 보세요. 코드 없이 드래그 앤 드롭만으로 설계할 수 있어요."}
|
||||
</p>
|
||||
{!searchTerm && (
|
||||
<Button
|
||||
onClick={() => onLoadFlow(null)}
|
||||
className="gap-2 bg-violet-600 px-5 font-semibold text-white shadow-lg shadow-violet-600/25 hover:bg-violet-500"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
새 플로우 생성
|
||||
첫 번째 플로우 만들기
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : viewMode === "grid" ? (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredFlows.map((flow) => (
|
||||
<FlowCard
|
||||
key={flow.flowId}
|
||||
flow={flow}
|
||||
onOpen={() => onLoadFlow(flow.flowId)}
|
||||
onCopy={() => handleCopy(flow)}
|
||||
onDelete={() => handleDelete(flow)}
|
||||
/>
|
||||
))}
|
||||
{/* 새 플로우 만들기 카드 */}
|
||||
<div
|
||||
className="group flex min-h-[260px] cursor-pointer flex-col items-center justify-center rounded-xl border border-dashed border-zinc-700 transition-all duration-200 hover:border-violet-500/50 hover:bg-violet-500/[0.04]"
|
||||
onClick={() => onLoadFlow(null)}
|
||||
>
|
||||
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-xl bg-violet-500/[0.08]">
|
||||
<Plus className="h-6 w-6 text-violet-400" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-zinc-400 group-hover:text-zinc-200">
|
||||
새 플로우 만들기
|
||||
</span>
|
||||
<span className="mt-1 text-[11px] text-zinc-600">빈 캔버스에서 시작해요</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveDataView<NodeFlow>
|
||||
data={filteredFlows}
|
||||
columns={columns}
|
||||
keyExtractor={(flow) => String(flow.flowId)}
|
||||
isLoading={loading}
|
||||
skeletonCount={5}
|
||||
cardTitle={(flow) => (
|
||||
<span className="flex items-center">
|
||||
<Network className="mr-2 h-4 w-4 text-primary" />
|
||||
{flow.flowName}
|
||||
</span>
|
||||
)}
|
||||
cardSubtitle={(flow) => flow.flowDescription || "설명 없음"}
|
||||
cardHeaderRight={renderDropdownMenu}
|
||||
cardFields={cardFields}
|
||||
actionsLabel="작업"
|
||||
actionsWidth="80px"
|
||||
renderActions={renderDropdownMenu}
|
||||
onRowClick={(flow) => onLoadFlow(flow.flowId)}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
{filteredFlows.map((flow) => (
|
||||
<div
|
||||
key={flow.flowId}
|
||||
className="group flex cursor-pointer items-center gap-4 rounded-lg border border-zinc-800 bg-zinc-900/80 px-4 py-3 transition-all hover:border-violet-500/40 hover:bg-zinc-900"
|
||||
onClick={() => onLoadFlow(flow.flowId)}
|
||||
>
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-violet-500/10">
|
||||
<Network className="h-5 w-5 text-violet-400" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate text-sm font-semibold text-zinc-100">
|
||||
{flow.flowName}
|
||||
</h3>
|
||||
<p className="truncate text-xs text-zinc-500">
|
||||
{flow.flowDescription || "설명이 아직 없어요"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="hidden items-center gap-1.5 lg:flex">
|
||||
{Object.entries(flow.summary?.nodeTypes || {})
|
||||
.slice(0, 3)
|
||||
.map(([type, count]) => {
|
||||
const colors = getNodeCategoryColor(type);
|
||||
return (
|
||||
<span
|
||||
key={type}
|
||||
className={`inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 font-mono text-[10px] font-semibold ${colors.text} ${colors.bg} ${colors.border}`}
|
||||
>
|
||||
{getNodeLabel(type)} {count}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className="hidden font-mono text-[11px] text-zinc-600 sm:block">
|
||||
{relativeTime(flow.updatedAt)}
|
||||
</span>
|
||||
<div className="flex gap-0.5" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
className="flex h-7 w-7 items-center justify-center rounded-lg text-zinc-500 transition-colors hover:bg-violet-500/10 hover:text-violet-400"
|
||||
title="복사"
|
||||
onClick={() => handleCopy(flow)}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className="flex h-7 w-7 items-center justify-center rounded-lg text-zinc-500 transition-colors hover:bg-pink-500/10 hover:text-pink-400"
|
||||
title="삭제"
|
||||
onClick={() => handleDelete(flow)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 삭제 확인 모달 */}
|
||||
<Dialog open={showDeleteModal} onOpenChange={setShowDeleteModal}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">플로우 삭제</DialogTitle>
|
||||
<DialogTitle className="text-base sm:text-lg">플로우를 삭제할까요?</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
“{selectedFlow?.flowName}” 플로우를 완전히 삭제하시겠습니까?
|
||||
“{selectedFlow?.flowName}” 플로우가 완전히 삭제돼요.
|
||||
<br />
|
||||
<span className="font-medium text-destructive">
|
||||
이 작업은 되돌릴 수 없으며, 모든 플로우 정보가 영구적으로 삭제됩니다.
|
||||
<span className="text-destructive font-medium">
|
||||
이 작업은 되돌릴 수 없으며, 모든 노드와 연결 정보가 함께 삭제돼요.
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,218 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { NODE_PALETTE, NODE_CATEGORIES } from "./sidebar/nodePaletteConfig";
|
||||
import type { NodePaletteItem } from "@/types/node-editor";
|
||||
|
||||
const TOSS_CATEGORY_LABELS: Record<string, string> = {
|
||||
source: "데이터를 가져와요",
|
||||
transform: "데이터를 가공해요",
|
||||
action: "데이터를 저장해요",
|
||||
external: "외부로 연결해요",
|
||||
utility: "도구",
|
||||
};
|
||||
|
||||
const TOSS_NODE_DESCRIPTIONS: Record<string, string> = {
|
||||
tableSource: "내부 데이터베이스에서 데이터를 읽어와요",
|
||||
externalDBSource: "외부 데이터베이스에 연결해서 데이터를 가져와요",
|
||||
restAPISource: "REST API를 호출해서 데이터를 받아와요",
|
||||
condition: "조건에 따라 데이터 흐름을 나눠요",
|
||||
dataTransform: "데이터를 원하는 형태로 바꿔요",
|
||||
aggregate: "합계, 평균 등 집계 연산을 수행해요",
|
||||
formulaTransform: "수식을 이용해서 새로운 값을 계산해요",
|
||||
insertAction: "데이터를 테이블에 새로 추가해요",
|
||||
updateAction: "기존 데이터를 수정해요",
|
||||
deleteAction: "데이터를 삭제해요",
|
||||
upsertAction: "있으면 수정하고, 없으면 새로 추가해요",
|
||||
emailAction: "이메일을 자동으로 보내요",
|
||||
scriptAction: "외부 스크립트를 실행해요",
|
||||
httpRequestAction: "HTTP 요청을 보내요",
|
||||
procedureCallAction: "DB 프로시저를 호출해요",
|
||||
comment: "메모를 남겨요",
|
||||
};
|
||||
|
||||
interface CommandPaletteProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSelectNode: (nodeType: string) => void;
|
||||
}
|
||||
|
||||
export function CommandPalette({ isOpen, onClose, onSelectNode }: CommandPaletteProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [focusIndex, setFocusIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!query.trim()) return NODE_PALETTE;
|
||||
const q = query.toLowerCase();
|
||||
return NODE_PALETTE.filter(
|
||||
(item) =>
|
||||
item.label.toLowerCase().includes(q) ||
|
||||
item.description.toLowerCase().includes(q) ||
|
||||
item.type.toLowerCase().includes(q) ||
|
||||
(TOSS_NODE_DESCRIPTIONS[item.type] || "").toLowerCase().includes(q),
|
||||
);
|
||||
}, [query]);
|
||||
|
||||
const groupedItems = useMemo(() => {
|
||||
const groups: { category: string; label: string; items: NodePaletteItem[] }[] = [];
|
||||
for (const cat of NODE_CATEGORIES) {
|
||||
const items = filteredItems.filter((i) => i.category === cat.id);
|
||||
if (items.length > 0) {
|
||||
groups.push({
|
||||
category: cat.id,
|
||||
label: TOSS_CATEGORY_LABELS[cat.id] || cat.label,
|
||||
items,
|
||||
});
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}, [filteredItems]);
|
||||
|
||||
const flatItems = useMemo(() => groupedItems.flatMap((g) => g.items), [groupedItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setQuery("");
|
||||
setFocusIndex(0);
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
setFocusIndex(0);
|
||||
}, [query]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setFocusIndex((i) => Math.min(i + 1, flatItems.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setFocusIndex((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === "Enter" && flatItems[focusIndex]) {
|
||||
onSelectNode(flatItems[focusIndex].type);
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[flatItems, focusIndex, onClose, onSelectNode],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const focused = listRef.current?.querySelector('[data-focused="true"]');
|
||||
focused?.scrollIntoView({ block: "nearest" });
|
||||
}, [focusIndex]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-start justify-center pt-[15vh]">
|
||||
{/* backdrop */}
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* palette */}
|
||||
<div className="relative w-full max-w-[520px] overflow-hidden rounded-xl border border-zinc-700 bg-zinc-900 shadow-2xl shadow-black/50">
|
||||
{/* 검색 */}
|
||||
<div className="flex items-center gap-3 border-b border-zinc-800 px-4 py-3">
|
||||
<Search className="h-4 w-4 flex-shrink-0 text-zinc-500" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="어떤 노드를 추가할까요?"
|
||||
className="flex-1 bg-transparent text-sm text-zinc-200 outline-none placeholder:text-zinc-600"
|
||||
/>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-zinc-500 transition-colors hover:text-zinc-300"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 목록 */}
|
||||
<div ref={listRef} className="max-h-[360px] overflow-y-auto p-2">
|
||||
{groupedItems.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-zinc-500">
|
||||
“{query}”에 해당하는 노드를 찾지 못했어요
|
||||
</div>
|
||||
) : (
|
||||
groupedItems.map((group) => {
|
||||
let groupStartIdx = 0;
|
||||
for (const g of groupedItems) {
|
||||
if (g.category === group.category) break;
|
||||
groupStartIdx += g.items.length;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={group.category} className="mb-1">
|
||||
<div className="px-2 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
{group.label}
|
||||
</div>
|
||||
{group.items.map((item, idx) => {
|
||||
const globalIdx = groupStartIdx + idx;
|
||||
const isFocused = globalIdx === focusIndex;
|
||||
return (
|
||||
<button
|
||||
key={item.type}
|
||||
data-focused={isFocused}
|
||||
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors ${
|
||||
isFocused ? "bg-violet-500/15 text-zinc-100" : "text-zinc-300 hover:bg-zinc-800"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectNode(item.type);
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() => setFocusIndex(globalIdx)}
|
||||
>
|
||||
<div
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">{item.label}</div>
|
||||
<div className="truncate text-[11px] text-zinc-500">
|
||||
{TOSS_NODE_DESCRIPTIONS[item.type] || item.description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 하단 힌트 */}
|
||||
<div className="flex items-center gap-4 border-t border-zinc-800 px-4 py-2 text-[11px] text-zinc-600">
|
||||
<span>
|
||||
<kbd className="rounded border border-zinc-700 bg-zinc-800 px-1 py-0.5 font-mono text-[10px]">
|
||||
Enter
|
||||
</kbd>{" "}
|
||||
선택
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="rounded border border-zinc-700 bg-zinc-800 px-1 py-0.5 font-mono text-[10px]">
|
||||
Esc
|
||||
</kbd>{" "}
|
||||
닫기
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="rounded border border-zinc-700 bg-zinc-800 px-1 py-0.5 font-mono text-[10px]">
|
||||
↑↓
|
||||
</kbd>{" "}
|
||||
이동
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { getNodePaletteItem } from "./sidebar/nodePaletteConfig";
|
||||
|
||||
export function FlowBreadcrumb() {
|
||||
const { flowName, nodes, selectedNodes } = useFlowEditorStore();
|
||||
|
||||
const selectedNode =
|
||||
selectedNodes.length === 1
|
||||
? nodes.find((n) => n.id === selectedNodes[0])
|
||||
: null;
|
||||
|
||||
const nodeInfo = selectedNode
|
||||
? getNodePaletteItem(selectedNode.type as string)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-zinc-500">데이터 경로</span>
|
||||
<ChevronRight className="h-3 w-3 text-zinc-600" />
|
||||
<span className="font-medium text-zinc-300">{flowName || "새 플로우"}</span>
|
||||
{selectedNode && (
|
||||
<>
|
||||
<ChevronRight className="h-3 w-3 text-zinc-600" />
|
||||
<span className="flex items-center gap-1.5">
|
||||
{nodeInfo && (
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: nodeInfo.color }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-violet-400">
|
||||
{(selectedNode.data as any)?.displayName || nodeInfo?.label || selectedNode.type}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,20 +2,32 @@
|
|||
|
||||
/**
|
||||
* 노드 기반 플로우 에디터 메인 컴포넌트
|
||||
* - 100% 캔버스 + Command Palette (/ 키) + Slide-over 속성 패널
|
||||
*/
|
||||
|
||||
import { useCallback, useRef, useEffect, useState, useMemo } from "react";
|
||||
import ReactFlow, { Background, Controls, MiniMap, Panel, ReactFlowProvider, useReactFlow } from "reactflow";
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
Panel,
|
||||
ReactFlowProvider,
|
||||
useReactFlow,
|
||||
} from "reactflow";
|
||||
import "reactflow/dist/style.css";
|
||||
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { NodePalette } from "./sidebar/NodePalette";
|
||||
import { LeftV2Toolbar, ToolbarButton } from "@/components/screen/toolbar/LeftV2Toolbar";
|
||||
import { Boxes, Settings } from "lucide-react";
|
||||
import { PropertiesPanel } from "./panels/PropertiesPanel";
|
||||
import { CommandPalette } from "./CommandPalette";
|
||||
import { SlideOverSheet } from "./SlideOverSheet";
|
||||
import { FlowBreadcrumb } from "./FlowBreadcrumb";
|
||||
import { NodeContextMenu } from "./NodeContextMenu";
|
||||
import { ValidationNotification } from "./ValidationNotification";
|
||||
import { FlowToolbar } from "./FlowToolbar";
|
||||
import { getNodePaletteItem } from "./sidebar/nodePaletteConfig";
|
||||
import { Pencil, Copy, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { TableSourceNode } from "./nodes/TableSourceNode";
|
||||
import { ExternalDBSourceNode } from "./nodes/ExternalDBSourceNode";
|
||||
import { ConditionNode } from "./nodes/ConditionNode";
|
||||
|
|
@ -36,70 +48,116 @@ import { ProcedureCallActionNode } from "./nodes/ProcedureCallActionNode";
|
|||
import { validateFlow } from "@/lib/utils/flowValidation";
|
||||
import type { FlowValidation } from "@/lib/utils/flowValidation";
|
||||
|
||||
// 노드 타입들
|
||||
const nodeTypes = {
|
||||
// 데이터 소스
|
||||
tableSource: TableSourceNode,
|
||||
externalDBSource: ExternalDBSourceNode,
|
||||
restAPISource: RestAPISourceNode,
|
||||
// 변환/조건
|
||||
condition: ConditionNode,
|
||||
dataTransform: DataTransformNode,
|
||||
aggregate: AggregateNode,
|
||||
formulaTransform: FormulaTransformNode,
|
||||
// 데이터 액션
|
||||
insertAction: InsertActionNode,
|
||||
updateAction: UpdateActionNode,
|
||||
deleteAction: DeleteActionNode,
|
||||
upsertAction: UpsertActionNode,
|
||||
// 외부 연동 액션
|
||||
emailAction: EmailActionNode,
|
||||
scriptAction: ScriptActionNode,
|
||||
httpRequestAction: HttpRequestActionNode,
|
||||
procedureCallAction: ProcedureCallActionNode,
|
||||
// 유틸리티
|
||||
comment: CommentNode,
|
||||
log: LogNode,
|
||||
};
|
||||
|
||||
/**
|
||||
* FlowEditor 내부 컴포넌트
|
||||
*/
|
||||
interface FlowEditorInnerProps {
|
||||
initialFlowId?: number | null;
|
||||
/** 임베디드 모드에서 저장 완료 시 호출되는 콜백 */
|
||||
onSaveComplete?: (flowId: number, flowName: string) => void;
|
||||
/** 임베디드 모드 여부 */
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
// 플로우 에디터 툴바 버튼 설정
|
||||
const flowToolbarButtons: ToolbarButton[] = [
|
||||
{
|
||||
id: "nodes",
|
||||
label: "노드",
|
||||
icon: <Boxes className="h-5 w-5" />,
|
||||
shortcut: "N",
|
||||
group: "source",
|
||||
panelWidth: 300,
|
||||
},
|
||||
{
|
||||
id: "properties",
|
||||
label: "속성",
|
||||
icon: <Settings className="h-5 w-5" />,
|
||||
shortcut: "P",
|
||||
group: "editor",
|
||||
panelWidth: 350,
|
||||
},
|
||||
];
|
||||
function getDefaultNodeData(type: string): Record<string, any> {
|
||||
const paletteItem = getNodePaletteItem(type);
|
||||
const base: Record<string, any> = {
|
||||
displayName: paletteItem?.label || `새 ${type} 노드`,
|
||||
};
|
||||
|
||||
function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: FlowEditorInnerProps) {
|
||||
if (type === "restAPISource") {
|
||||
Object.assign(base, {
|
||||
method: "GET",
|
||||
url: "",
|
||||
headers: {},
|
||||
timeout: 30000,
|
||||
responseFields: [],
|
||||
responseMapping: "",
|
||||
});
|
||||
}
|
||||
|
||||
if (["insertAction", "updateAction", "deleteAction", "upsertAction"].includes(type)) {
|
||||
Object.assign(base, {
|
||||
targetType: "internal",
|
||||
fieldMappings: [],
|
||||
options: {},
|
||||
});
|
||||
if (type === "updateAction" || type === "deleteAction") {
|
||||
base.whereConditions = [];
|
||||
}
|
||||
if (type === "upsertAction") {
|
||||
base.conflictKeys = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "emailAction") {
|
||||
Object.assign(base, {
|
||||
displayName: "메일 발송",
|
||||
smtpConfig: { host: "", port: 587, secure: false },
|
||||
from: "",
|
||||
to: "",
|
||||
subject: "",
|
||||
body: "",
|
||||
bodyType: "text",
|
||||
});
|
||||
}
|
||||
|
||||
if (type === "scriptAction") {
|
||||
Object.assign(base, {
|
||||
displayName: "스크립트 실행",
|
||||
scriptType: "python",
|
||||
executionMode: "inline",
|
||||
inlineScript: "",
|
||||
inputMethod: "stdin",
|
||||
inputFormat: "json",
|
||||
outputHandling: { captureStdout: true, captureStderr: true, parseOutput: "text" },
|
||||
});
|
||||
}
|
||||
|
||||
if (type === "httpRequestAction") {
|
||||
Object.assign(base, {
|
||||
displayName: "HTTP 요청",
|
||||
url: "",
|
||||
method: "GET",
|
||||
bodyType: "none",
|
||||
authentication: { type: "none" },
|
||||
options: { timeout: 30000, followRedirects: true },
|
||||
});
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
function FlowEditorInner({
|
||||
initialFlowId,
|
||||
onSaveComplete,
|
||||
embedded = false,
|
||||
}: FlowEditorInnerProps) {
|
||||
const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
||||
const { screenToFlowPosition, setCenter } = useReactFlow();
|
||||
const { screenToFlowPosition, setCenter, getViewport } = useReactFlow();
|
||||
|
||||
// 패널 표시 상태
|
||||
const [showNodesPanel, setShowNodesPanel] = useState(true);
|
||||
const [showPropertiesPanelLocal, setShowPropertiesPanelLocal] = useState(false);
|
||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
||||
const [slideOverOpen, setSlideOverOpen] = useState(false);
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
nodeId: string;
|
||||
} | null>(null);
|
||||
|
||||
const {
|
||||
nodes,
|
||||
|
|
@ -117,12 +175,11 @@ function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: Fl
|
|||
loadFlow,
|
||||
} = useFlowEditorStore();
|
||||
|
||||
// 🆕 실시간 플로우 검증
|
||||
const validations = useMemo<FlowValidation[]>(() => {
|
||||
return validateFlow(nodes, edges);
|
||||
}, [nodes, edges]);
|
||||
const validations = useMemo<FlowValidation[]>(
|
||||
() => validateFlow(nodes, edges),
|
||||
[nodes, edges],
|
||||
);
|
||||
|
||||
// 🆕 노드 클릭 핸들러 (검증 패널에서 사용)
|
||||
const handleValidationNodeClick = useCallback(
|
||||
(nodeId: string) => {
|
||||
const node = nodes.find((n) => n.id === nodeId);
|
||||
|
|
@ -137,23 +194,27 @@ function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: Fl
|
|||
[nodes, selectNodes, setCenter],
|
||||
);
|
||||
|
||||
// 속성 패널 상태 동기화
|
||||
// 노드 선택 시 속성 패널 열기
|
||||
useEffect(() => {
|
||||
if (selectedNodes.length > 0 && !showPropertiesPanelLocal) {
|
||||
setShowPropertiesPanelLocal(true);
|
||||
if (selectedNodes.length > 0) {
|
||||
setSlideOverOpen(true);
|
||||
}
|
||||
}, [selectedNodes, showPropertiesPanelLocal]);
|
||||
}, [selectedNodes]);
|
||||
|
||||
// 초기 플로우 로드
|
||||
// 플로우 로드
|
||||
useEffect(() => {
|
||||
const fetchAndLoadFlow = async () => {
|
||||
if (initialFlowId) {
|
||||
try {
|
||||
const response = await apiClient.get(`/dataflow/node-flows/${initialFlowId}`);
|
||||
|
||||
const response = await apiClient.get(
|
||||
`/dataflow/node-flows/${initialFlowId}`,
|
||||
);
|
||||
if (response.data.success && response.data.data) {
|
||||
const flow = response.data.data;
|
||||
const flowData = typeof flow.flowData === "string" ? JSON.parse(flow.flowData) : flow.flowData;
|
||||
const flowData =
|
||||
typeof flow.flowData === "string"
|
||||
? JSON.parse(flow.flowData)
|
||||
: flow.flowData;
|
||||
|
||||
loadFlow(
|
||||
flow.flowId,
|
||||
|
|
@ -162,73 +223,174 @@ function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: Fl
|
|||
flowData.nodes || [],
|
||||
flowData.edges || [],
|
||||
);
|
||||
|
||||
// 🆕 플로우 로드 후 첫 번째 노드 자동 선택
|
||||
if (flowData.nodes && flowData.nodes.length > 0) {
|
||||
const firstNode = flowData.nodes[0];
|
||||
selectNodes([firstNode.id]);
|
||||
setShowPropertiesPanelLocal(true);
|
||||
console.log("✅ 첫 번째 노드 자동 선택:", firstNode.id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("플로우 로드 실패:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchAndLoadFlow();
|
||||
}, [initialFlowId, loadFlow, selectNodes]);
|
||||
}, [initialFlowId, loadFlow]);
|
||||
|
||||
/**
|
||||
* 노드 선택 변경 핸들러
|
||||
*/
|
||||
const onSelectionChange = useCallback(
|
||||
({ nodes: selectedNodes }: { nodes: any[] }) => {
|
||||
const selectedIds = selectedNodes.map((node) => node.id);
|
||||
({ nodes: selected }: { nodes: any[] }) => {
|
||||
const selectedIds = selected.map((n) => n.id);
|
||||
selectNodes(selectedIds);
|
||||
console.log("🔍 선택된 노드:", selectedIds);
|
||||
},
|
||||
[selectNodes],
|
||||
);
|
||||
|
||||
/**
|
||||
* 키보드 이벤트 핸들러 (Delete/Backspace 키로 노드 삭제, Ctrl+Z/Y로 Undo/Redo)
|
||||
*/
|
||||
// 더블클릭으로 속성 패널 열기
|
||||
const onNodeDoubleClick = useCallback(
|
||||
(_event: React.MouseEvent, node: any) => {
|
||||
selectNodes([node.id]);
|
||||
setSlideOverOpen(true);
|
||||
},
|
||||
[selectNodes],
|
||||
);
|
||||
|
||||
// 우클릭 컨텍스트 메뉴
|
||||
const onNodeContextMenu = useCallback(
|
||||
(event: React.MouseEvent, node: any) => {
|
||||
event.preventDefault();
|
||||
selectNodes([node.id]);
|
||||
setContextMenu({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
nodeId: node.id,
|
||||
});
|
||||
},
|
||||
[selectNodes],
|
||||
);
|
||||
|
||||
// 캔버스 우클릭 → 커맨드 팔레트
|
||||
const onPaneContextMenu = useCallback(
|
||||
(event: React.MouseEvent | MouseEvent) => {
|
||||
event.preventDefault();
|
||||
setCommandPaletteOpen(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 컨텍스트 메뉴 아이템 생성
|
||||
const getContextMenuItems = useCallback(
|
||||
(nodeId: string) => {
|
||||
const node = nodes.find((n) => n.id === nodeId);
|
||||
const nodeName = (node?.data as any)?.displayName || "노드";
|
||||
return [
|
||||
{
|
||||
label: "속성 편집",
|
||||
icon: <Pencil className="h-3.5 w-3.5" />,
|
||||
onClick: () => {
|
||||
selectNodes([nodeId]);
|
||||
setSlideOverOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "복제",
|
||||
icon: <Copy className="h-3.5 w-3.5" />,
|
||||
onClick: () => {
|
||||
if (!node) return;
|
||||
const newNode: any = {
|
||||
id: `node_${Date.now()}`,
|
||||
type: node.type,
|
||||
position: {
|
||||
x: node.position.x + 40,
|
||||
y: node.position.y + 40,
|
||||
},
|
||||
data: { ...(node.data as any) },
|
||||
};
|
||||
addNode(newNode);
|
||||
selectNodes([newNode.id]);
|
||||
toast.success(`"${nodeName}" 노드를 복제했어요`);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "삭제",
|
||||
icon: <Trash2 className="h-3.5 w-3.5" />,
|
||||
onClick: () => {
|
||||
removeNodes([nodeId]);
|
||||
toast.success(`"${nodeName}" 노드를 삭제했어요`);
|
||||
},
|
||||
danger: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
[nodes, selectNodes, addNode, removeNodes],
|
||||
);
|
||||
|
||||
// "/" 키로 커맨드 팔레트 열기, Esc로 속성 패널 닫기 등
|
||||
const onKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
// Undo: Ctrl+Z (Windows/Linux) or Cmd+Z (Mac)
|
||||
const target = event.target as HTMLElement;
|
||||
const isInput =
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.isContentEditable;
|
||||
|
||||
if (!isInput && event.key === "/" && !event.ctrlKey && !event.metaKey) {
|
||||
event.preventDefault();
|
||||
setCommandPaletteOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "z" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
console.log("⏪ Undo");
|
||||
undo();
|
||||
return;
|
||||
}
|
||||
|
||||
// Redo: Ctrl+Y (Windows/Linux) or Cmd+Shift+Z (Mac) or Ctrl+Shift+Z
|
||||
if (
|
||||
((event.ctrlKey || event.metaKey) && event.key === "y") ||
|
||||
((event.ctrlKey || event.metaKey) && event.shiftKey && event.key === "z")
|
||||
) {
|
||||
event.preventDefault();
|
||||
console.log("⏩ Redo");
|
||||
redo();
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete: Delete/Backspace 키로 노드 삭제
|
||||
if ((event.key === "Delete" || event.key === "Backspace") && selectedNodes.length > 0) {
|
||||
if (
|
||||
(event.key === "Delete" || event.key === "Backspace") &&
|
||||
selectedNodes.length > 0 &&
|
||||
!isInput
|
||||
) {
|
||||
event.preventDefault();
|
||||
console.log("🗑️ 선택된 노드 삭제:", selectedNodes);
|
||||
removeNodes(selectedNodes);
|
||||
}
|
||||
},
|
||||
[selectedNodes, removeNodes, undo, redo],
|
||||
);
|
||||
|
||||
/**
|
||||
* 드래그 앤 드롭 핸들러
|
||||
*/
|
||||
// 커맨드 팔레트에서 노드 선택 시 뷰포트 중앙에 배치
|
||||
const handleCommandSelect = useCallback(
|
||||
(nodeType: string) => {
|
||||
const viewport = getViewport();
|
||||
const wrapper = reactFlowWrapper.current;
|
||||
if (!wrapper) return;
|
||||
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const centerX = rect.width / 2;
|
||||
const centerY = rect.height / 2;
|
||||
|
||||
const position = screenToFlowPosition({
|
||||
x: rect.left + centerX,
|
||||
y: rect.top + centerY,
|
||||
});
|
||||
|
||||
const newNode: any = {
|
||||
id: `node_${Date.now()}`,
|
||||
type: nodeType,
|
||||
position,
|
||||
data: getDefaultNodeData(nodeType),
|
||||
};
|
||||
|
||||
addNode(newNode);
|
||||
selectNodes([newNode.id]);
|
||||
},
|
||||
[screenToFlowPosition, addNode, selectNodes, getViewport],
|
||||
);
|
||||
|
||||
// 기존 드래그 앤 드롭 (하위 호환)
|
||||
const onDragOver = useCallback((event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
|
|
@ -237,7 +399,6 @@ function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: Fl
|
|||
const onDrop = useCallback(
|
||||
(event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const type = event.dataTransfer.getData("application/reactflow");
|
||||
if (!type) return;
|
||||
|
||||
|
|
@ -246,84 +407,11 @@ function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: Fl
|
|||
y: event.clientY,
|
||||
});
|
||||
|
||||
// 🔥 노드 타입별 기본 데이터 설정
|
||||
const defaultData: any = {
|
||||
displayName: `새 ${type} 노드`,
|
||||
};
|
||||
|
||||
// REST API 소스 노드의 경우
|
||||
if (type === "restAPISource") {
|
||||
defaultData.method = "GET";
|
||||
defaultData.url = "";
|
||||
defaultData.headers = {};
|
||||
defaultData.timeout = 30000;
|
||||
defaultData.responseFields = []; // 빈 배열로 초기화
|
||||
defaultData.responseMapping = "";
|
||||
}
|
||||
|
||||
// 데이터 액션 노드의 경우 targetType 기본값 설정
|
||||
if (["insertAction", "updateAction", "deleteAction", "upsertAction"].includes(type)) {
|
||||
defaultData.targetType = "internal"; // 기본값: 내부 DB
|
||||
defaultData.fieldMappings = [];
|
||||
defaultData.options = {};
|
||||
|
||||
if (type === "updateAction" || type === "deleteAction") {
|
||||
defaultData.whereConditions = [];
|
||||
}
|
||||
|
||||
if (type === "upsertAction") {
|
||||
defaultData.conflictKeys = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 메일 발송 노드
|
||||
if (type === "emailAction") {
|
||||
defaultData.displayName = "메일 발송";
|
||||
defaultData.smtpConfig = {
|
||||
host: "",
|
||||
port: 587,
|
||||
secure: false,
|
||||
};
|
||||
defaultData.from = "";
|
||||
defaultData.to = "";
|
||||
defaultData.subject = "";
|
||||
defaultData.body = "";
|
||||
defaultData.bodyType = "text";
|
||||
}
|
||||
|
||||
// 스크립트 실행 노드
|
||||
if (type === "scriptAction") {
|
||||
defaultData.displayName = "스크립트 실행";
|
||||
defaultData.scriptType = "python";
|
||||
defaultData.executionMode = "inline";
|
||||
defaultData.inlineScript = "";
|
||||
defaultData.inputMethod = "stdin";
|
||||
defaultData.inputFormat = "json";
|
||||
defaultData.outputHandling = {
|
||||
captureStdout: true,
|
||||
captureStderr: true,
|
||||
parseOutput: "text",
|
||||
};
|
||||
}
|
||||
|
||||
// HTTP 요청 노드
|
||||
if (type === "httpRequestAction") {
|
||||
defaultData.displayName = "HTTP 요청";
|
||||
defaultData.url = "";
|
||||
defaultData.method = "GET";
|
||||
defaultData.bodyType = "none";
|
||||
defaultData.authentication = { type: "none" };
|
||||
defaultData.options = {
|
||||
timeout: 30000,
|
||||
followRedirects: true,
|
||||
};
|
||||
}
|
||||
|
||||
const newNode: any = {
|
||||
id: `node_${Date.now()}`,
|
||||
type,
|
||||
position,
|
||||
data: defaultData,
|
||||
data: getDefaultNodeData(type),
|
||||
};
|
||||
|
||||
addNode(newNode);
|
||||
|
|
@ -332,32 +420,17 @@ function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: Fl
|
|||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full" style={{ height: "100%", overflow: "hidden" }}>
|
||||
{/* 좌측 통합 툴바 */}
|
||||
<LeftV2Toolbar
|
||||
buttons={flowToolbarButtons}
|
||||
panelStates={{
|
||||
nodes: { isOpen: showNodesPanel },
|
||||
properties: { isOpen: showPropertiesPanelLocal },
|
||||
}}
|
||||
onTogglePanel={(panelId) => {
|
||||
if (panelId === "nodes") {
|
||||
setShowNodesPanel(!showNodesPanel);
|
||||
} else if (panelId === "properties") {
|
||||
setShowPropertiesPanelLocal(!showPropertiesPanelLocal);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 노드 라이브러리 패널 */}
|
||||
{showNodesPanel && (
|
||||
<div className="h-full w-[300px] border-r bg-white">
|
||||
<NodePalette />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 중앙 캔버스 */}
|
||||
<div className="relative flex-1" ref={reactFlowWrapper} onKeyDown={onKeyDown} tabIndex={0}>
|
||||
<div
|
||||
className="relative flex h-full w-full"
|
||||
style={{ height: "100%", overflow: "hidden" }}
|
||||
>
|
||||
{/* 100% 캔버스 */}
|
||||
<div
|
||||
className="relative flex-1"
|
||||
ref={reactFlowWrapper}
|
||||
onKeyDown={onKeyDown}
|
||||
tabIndex={0}
|
||||
>
|
||||
<ReactFlow
|
||||
nodes={nodes as any}
|
||||
edges={edges as any}
|
||||
|
|
@ -366,74 +439,116 @@ function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: Fl
|
|||
onConnect={onConnect}
|
||||
onNodeDragStart={onNodeDragStart}
|
||||
onSelectionChange={onSelectionChange}
|
||||
onNodeDoubleClick={onNodeDoubleClick}
|
||||
onNodeContextMenu={onNodeContextMenu}
|
||||
onPaneContextMenu={onPaneContextMenu}
|
||||
onPaneClick={() => setContextMenu(null)}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
className="bg-muted"
|
||||
className="bg-zinc-950"
|
||||
deleteKeyCode={["Delete", "Backspace"]}
|
||||
>
|
||||
{/* 배경 그리드 */}
|
||||
<Background gap={16} size={1} color="#E5E7EB" />
|
||||
<Background gap={20} size={1} color="#27272a" />
|
||||
|
||||
{/* 컨트롤 버튼 */}
|
||||
<Controls className="bg-white shadow-md" />
|
||||
|
||||
{/* 미니맵 */}
|
||||
<MiniMap
|
||||
className="bg-white shadow-md"
|
||||
nodeColor={(node) => {
|
||||
// 노드 타입별 색상 (추후 구현)
|
||||
return "#3B82F6";
|
||||
}}
|
||||
maskColor="rgba(0, 0, 0, 0.1)"
|
||||
<Controls
|
||||
className="!rounded-lg !border-zinc-700 !bg-zinc-900 !shadow-lg [&>button]:!border-zinc-700 [&>button]:!bg-zinc-900 [&>button]:!text-zinc-400 [&>button:hover]:!bg-zinc-800 [&>button:hover]:!text-zinc-200"
|
||||
showInteractive={false}
|
||||
/>
|
||||
|
||||
{/* 상단 툴바 */}
|
||||
<MiniMap
|
||||
className="!rounded-lg !border-zinc-700 !bg-zinc-900 !shadow-lg"
|
||||
nodeColor={(node) => {
|
||||
const item = getNodePaletteItem(node.type || "");
|
||||
return item?.color || "#6B7280";
|
||||
}}
|
||||
maskColor="rgba(0, 0, 0, 0.6)"
|
||||
/>
|
||||
|
||||
{/* Breadcrumb (좌상단) */}
|
||||
<Panel position="top-left" className="pointer-events-auto">
|
||||
<div className="rounded-lg border border-zinc-700/60 bg-zinc-900/90 px-3 py-2 backdrop-blur-sm">
|
||||
<FlowBreadcrumb />
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* 플로팅 툴바 (상단 중앙) */}
|
||||
<Panel position="top-center" className="pointer-events-auto">
|
||||
<FlowToolbar validations={validations} onSaveComplete={onSaveComplete} />
|
||||
<FlowToolbar
|
||||
validations={validations}
|
||||
onSaveComplete={onSaveComplete}
|
||||
onOpenCommandPalette={() => setCommandPaletteOpen(true)}
|
||||
/>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
{/* 우측 속성 패널 */}
|
||||
{showPropertiesPanelLocal && selectedNodes.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "350px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
className="border-l bg-white"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</div>
|
||||
{/* Slide-over 속성 패널 */}
|
||||
<SlideOverSheet
|
||||
isOpen={slideOverOpen && selectedNodes.length > 0}
|
||||
onClose={() => setSlideOverOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Command Palette */}
|
||||
<CommandPalette
|
||||
isOpen={commandPaletteOpen}
|
||||
onClose={() => setCommandPaletteOpen(false)}
|
||||
onSelectNode={handleCommandSelect}
|
||||
/>
|
||||
|
||||
{/* 노드 우클릭 컨텍스트 메뉴 */}
|
||||
{contextMenu && (
|
||||
<NodeContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
items={getContextMenuItems(contextMenu.nodeId)}
|
||||
onClose={() => setContextMenu(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 검증 알림 (우측 상단 플로팅) */}
|
||||
<ValidationNotification validations={validations} onNodeClick={handleValidationNodeClick} />
|
||||
{/* 검증 알림 */}
|
||||
<ValidationNotification
|
||||
validations={validations}
|
||||
onNodeClick={handleValidationNodeClick}
|
||||
/>
|
||||
|
||||
{/* 빈 캔버스 힌트 */}
|
||||
{nodes.length === 0 && !commandPaletteOpen && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="mb-2 text-sm text-zinc-500">
|
||||
캔버스가 비어 있어요
|
||||
</p>
|
||||
<p className="text-xs text-zinc-600">
|
||||
<kbd className="rounded border border-zinc-700 bg-zinc-800 px-1.5 py-0.5 font-mono text-[11px]">
|
||||
/
|
||||
</kbd>{" "}
|
||||
키를 눌러서 노드를 추가해 보세요
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* FlowEditor 메인 컴포넌트 (Provider로 감싸기)
|
||||
*/
|
||||
interface FlowEditorProps {
|
||||
initialFlowId?: number | null;
|
||||
/** 임베디드 모드에서 저장 완료 시 호출되는 콜백 */
|
||||
onSaveComplete?: (flowId: number, flowName: string) => void;
|
||||
/** 임베디드 모드 여부 (헤더 표시 여부 등) */
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export function FlowEditor({ initialFlowId, onSaveComplete, embedded = false }: FlowEditorProps = {}) {
|
||||
export function FlowEditor({
|
||||
initialFlowId,
|
||||
onSaveComplete,
|
||||
embedded = false,
|
||||
}: FlowEditorProps = {}) {
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<ReactFlowProvider>
|
||||
<FlowEditorInner
|
||||
initialFlowId={initialFlowId}
|
||||
<FlowEditorInner
|
||||
initialFlowId={initialFlowId}
|
||||
onSaveComplete={onSaveComplete}
|
||||
embedded={embedded}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 플로우 에디터 상단 툴바
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Save, Undo2, Redo2, ZoomIn, ZoomOut, Download, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Save,
|
||||
Undo2,
|
||||
Redo2,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Maximize2,
|
||||
Download,
|
||||
Trash2,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { useReactFlow } from "reactflow";
|
||||
|
|
@ -17,11 +22,15 @@ import { useToast } from "@/hooks/use-toast";
|
|||
|
||||
interface FlowToolbarProps {
|
||||
validations?: FlowValidation[];
|
||||
/** 임베디드 모드에서 저장 완료 시 호출되는 콜백 */
|
||||
onSaveComplete?: (flowId: number, flowName: string) => void;
|
||||
onOpenCommandPalette?: () => void;
|
||||
}
|
||||
|
||||
export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarProps) {
|
||||
export function FlowToolbar({
|
||||
validations = [],
|
||||
onSaveComplete,
|
||||
onOpenCommandPalette,
|
||||
}: FlowToolbarProps) {
|
||||
const { toast } = useToast();
|
||||
const { zoomIn, zoomOut, fitView } = useReactFlow();
|
||||
const {
|
||||
|
|
@ -42,9 +51,7 @@ export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarPro
|
|||
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
|
||||
// Ctrl+S 단축키: 플로우 저장
|
||||
const handleSaveRef = useRef<() => void>();
|
||||
|
||||
useEffect(() => {
|
||||
handleSaveRef.current = handleSave;
|
||||
});
|
||||
|
|
@ -53,28 +60,20 @@ export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarPro
|
|||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
if (!isSaving) {
|
||||
handleSaveRef.current?.();
|
||||
}
|
||||
if (!isSaving) handleSaveRef.current?.();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isSaving]);
|
||||
|
||||
const handleSave = async () => {
|
||||
// 검증 수행
|
||||
const currentValidations = validations.length > 0 ? validations : validateFlow(nodes, edges);
|
||||
const summary = summarizeValidations(currentValidations);
|
||||
|
||||
// 오류나 경고가 있으면 다이얼로그 표시
|
||||
const currentValidations =
|
||||
validations.length > 0 ? validations : validateFlow(nodes, edges);
|
||||
if (currentValidations.length > 0) {
|
||||
setShowSaveDialog(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 문제 없으면 바로 저장
|
||||
await performSave();
|
||||
};
|
||||
|
||||
|
|
@ -82,27 +81,22 @@ export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarPro
|
|||
const result = await saveFlow();
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: "저장 완료",
|
||||
description: `${result.message}\nFlow ID: ${result.flowId}`,
|
||||
title: "저장했어요",
|
||||
description: `플로우가 안전하게 저장됐어요`,
|
||||
variant: "default",
|
||||
});
|
||||
|
||||
// 임베디드 모드에서 저장 완료 콜백 호출
|
||||
if (onSaveComplete && result.flowId) {
|
||||
onSaveComplete(result.flowId, flowName);
|
||||
}
|
||||
|
||||
// 부모 창이 있으면 postMessage로 알림 (새 창에서 열린 경우)
|
||||
if (window.opener && result.flowId) {
|
||||
window.opener.postMessage({
|
||||
type: "FLOW_SAVED",
|
||||
flowId: result.flowId,
|
||||
flowName: flowName,
|
||||
}, "*");
|
||||
window.opener.postMessage(
|
||||
{ type: "FLOW_SAVED", flowId: result.flowId, flowName },
|
||||
"*",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
toast({
|
||||
title: "저장 실패",
|
||||
title: "저장에 실패했어요",
|
||||
description: result.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
|
|
@ -120,102 +114,128 @@ export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarPro
|
|||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast({
|
||||
title: "✅ 내보내기 완료",
|
||||
description: "JSON 파일로 저장되었습니다.",
|
||||
title: "내보내기 완료",
|
||||
description: "JSON 파일로 저장했어요",
|
||||
variant: "default",
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (selectedNodes.length === 0) {
|
||||
toast({
|
||||
title: "⚠️ 선택된 노드 없음",
|
||||
description: "삭제할 노드를 선택해주세요.",
|
||||
variant: "default",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirm(`선택된 ${selectedNodes.length}개 노드를 삭제하시겠습니까?`)) {
|
||||
removeNodes(selectedNodes);
|
||||
toast({
|
||||
title: "✅ 노드 삭제 완료",
|
||||
description: `${selectedNodes.length}개 노드가 삭제되었습니다.`,
|
||||
variant: "default",
|
||||
});
|
||||
}
|
||||
if (selectedNodes.length === 0) return;
|
||||
removeNodes(selectedNodes);
|
||||
toast({
|
||||
title: "노드를 삭제했어요",
|
||||
description: `${selectedNodes.length}개 노드가 삭제됐어요`,
|
||||
variant: "default",
|
||||
});
|
||||
};
|
||||
|
||||
const ToolBtn = ({
|
||||
onClick,
|
||||
disabled,
|
||||
title,
|
||||
danger,
|
||||
children,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
title: string;
|
||||
danger?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg transition-colors disabled:opacity-30 ${
|
||||
danger
|
||||
? "text-pink-400 hover:bg-pink-500/15"
|
||||
: "text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-background p-2 shadow-md">
|
||||
<div className="flex items-center gap-1 rounded-xl border border-zinc-700 bg-zinc-900/95 px-2 py-1.5 shadow-lg shadow-black/30 backdrop-blur-sm">
|
||||
{/* 노드 추가 */}
|
||||
{onOpenCommandPalette && (
|
||||
<>
|
||||
<button
|
||||
onClick={onOpenCommandPalette}
|
||||
title="노드 추가 (/)"
|
||||
className="flex h-8 items-center gap-1.5 rounded-lg bg-violet-600/20 px-2.5 text-violet-400 transition-colors hover:bg-violet-600/30"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
<span className="text-xs font-medium">추가</span>
|
||||
</button>
|
||||
<div className="mx-0.5 h-5 w-px bg-zinc-700" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 플로우 이름 */}
|
||||
<Input
|
||||
value={flowName}
|
||||
onChange={(e) => setFlowName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// 입력 필드에서 키 이벤트가 FlowEditor로 전파되지 않도록 방지
|
||||
// FlowEditor의 Backspace/Delete 키로 노드가 삭제되는 것을 막음
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="h-8 w-[200px] text-sm"
|
||||
placeholder="플로우 이름"
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className="h-7 w-[160px] border-none bg-transparent px-2 text-xs font-medium text-zinc-200 placeholder:text-zinc-600 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
placeholder="플로우 이름을 입력해요"
|
||||
/>
|
||||
|
||||
<div className="h-6 w-px bg-border" />
|
||||
<div className="mx-0.5 h-5 w-px bg-zinc-700" />
|
||||
|
||||
{/* 실행 취소/다시 실행 */}
|
||||
<Button variant="ghost" size="sm" title="실행 취소 (Ctrl+Z)" disabled={!canUndo()} onClick={undo}>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" title="다시 실행 (Ctrl+Y)" disabled={!canRedo()} onClick={redo}>
|
||||
<Redo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{/* Undo / Redo */}
|
||||
<ToolBtn onClick={undo} disabled={!canUndo()} title="실행 취소 (Ctrl+Z)">
|
||||
<Undo2 className="h-3.5 w-3.5" />
|
||||
</ToolBtn>
|
||||
<ToolBtn onClick={redo} disabled={!canRedo()} title="다시 실행 (Ctrl+Y)">
|
||||
<Redo2 className="h-3.5 w-3.5" />
|
||||
</ToolBtn>
|
||||
|
||||
<div className="h-6 w-px bg-border" />
|
||||
{/* 삭제 */}
|
||||
{selectedNodes.length > 0 && (
|
||||
<>
|
||||
<div className="mx-0.5 h-5 w-px bg-zinc-700" />
|
||||
<ToolBtn onClick={handleDelete} title={`${selectedNodes.length}개 삭제`} danger>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</ToolBtn>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
disabled={selectedNodes.length === 0}
|
||||
title={selectedNodes.length > 0 ? `${selectedNodes.length}개 노드 삭제` : "삭제할 노드를 선택하세요"}
|
||||
className="gap-1 text-destructive hover:bg-destructive/10 hover:text-destructive disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{selectedNodes.length > 0 && <span className="text-xs">({selectedNodes.length})</span>}
|
||||
</Button>
|
||||
<div className="mx-0.5 h-5 w-px bg-zinc-700" />
|
||||
|
||||
<div className="h-6 w-px bg-border" />
|
||||
{/* 줌 */}
|
||||
<ToolBtn onClick={() => zoomIn()} title="확대">
|
||||
<ZoomIn className="h-3.5 w-3.5" />
|
||||
</ToolBtn>
|
||||
<ToolBtn onClick={() => zoomOut()} title="축소">
|
||||
<ZoomOut className="h-3.5 w-3.5" />
|
||||
</ToolBtn>
|
||||
<ToolBtn onClick={() => fitView()} title="전체 보기">
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
</ToolBtn>
|
||||
|
||||
{/* 줌 컨트롤 */}
|
||||
<Button variant="ghost" size="sm" onClick={() => zoomIn()} title="확대">
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => zoomOut()} title="축소">
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => fitView()} title="전체 보기">
|
||||
<span className="text-xs">전체</span>
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-border" />
|
||||
<div className="mx-0.5 h-5 w-px bg-zinc-700" />
|
||||
|
||||
{/* 저장 */}
|
||||
<Button variant="outline" size="sm" onClick={handleSave} disabled={isSaving} className="gap-1">
|
||||
<Save className="h-4 w-4" />
|
||||
<span className="text-xs">{isSaving ? "저장 중..." : "저장"}</span>
|
||||
</Button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
title="저장 (Ctrl+S)"
|
||||
className="flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-zinc-300 transition-colors hover:bg-zinc-700 hover:text-zinc-100 disabled:opacity-40"
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
<span className="text-xs font-medium">{isSaving ? "저장 중..." : "저장"}</span>
|
||||
</button>
|
||||
|
||||
{/* 내보내기 */}
|
||||
<Button variant="outline" size="sm" onClick={handleExport} className="gap-1">
|
||||
<Download className="h-4 w-4" />
|
||||
<span className="text-xs">JSON</span>
|
||||
</Button>
|
||||
{/* JSON 내보내기 */}
|
||||
<ToolBtn onClick={handleExport} title="JSON 내보내기">
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</ToolBtn>
|
||||
</div>
|
||||
|
||||
{/* 저장 확인 다이얼로그 */}
|
||||
<SaveConfirmDialog
|
||||
open={showSaveDialog}
|
||||
validations={validations.length > 0 ? validations : validateFlow(nodes, edges)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Pencil, Copy, Trash2, Scissors } from "lucide-react";
|
||||
|
||||
interface ContextMenuItem {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface NodeContextMenuProps {
|
||||
x: number;
|
||||
y: number;
|
||||
items: ContextMenuItem[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function NodeContextMenu({ x, y, items, onClose }: NodeContextMenuProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
document.addEventListener("keydown", handleEsc);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClick);
|
||||
document.removeEventListener("keydown", handleEsc);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="fixed z-[200] min-w-[160px] rounded-lg border border-zinc-700 bg-zinc-900 py-1 shadow-xl shadow-black/40"
|
||||
style={{ left: x, top: y }}
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => {
|
||||
item.onClick();
|
||||
onClose();
|
||||
}}
|
||||
disabled={item.disabled}
|
||||
className={`flex w-full items-center gap-2.5 px-3 py-2 text-left text-xs transition-colors disabled:opacity-30 ${
|
||||
item.danger
|
||||
? "text-pink-400 hover:bg-pink-500/10"
|
||||
: "text-zinc-300 hover:bg-zinc-800"
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
"use client";
|
||||
|
||||
import { X, Info } from "lucide-react";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { PropertiesPanel } from "./panels/PropertiesPanel";
|
||||
import { getNodePaletteItem } from "./sidebar/nodePaletteConfig";
|
||||
|
||||
const TOSS_NODE_HINTS: Record<string, string> = {
|
||||
tableSource: "어떤 테이블에서 데이터를 가져올지 선택해 주세요",
|
||||
externalDBSource: "외부 데이터베이스 연결 정보를 입력해 주세요",
|
||||
restAPISource: "호출할 API의 URL과 방식을 설정해 주세요",
|
||||
condition: "어떤 조건으로 데이터를 분기할지 설정해 주세요",
|
||||
dataTransform: "데이터를 어떻게 변환할지 규칙을 정해 주세요",
|
||||
aggregate: "어떤 기준으로 집계할지 설정해 주세요",
|
||||
formulaTransform: "계산에 사용할 수식을 입력해 주세요",
|
||||
insertAction: "데이터를 저장할 테이블과 필드를 매핑해 주세요",
|
||||
updateAction: "수정할 조건과 필드를 설정해 주세요",
|
||||
deleteAction: "삭제 조건을 설정해 주세요",
|
||||
upsertAction: "저장/수정 조건과 필드를 설정해 주세요",
|
||||
emailAction: "메일 서버와 발송 정보를 설정해 주세요",
|
||||
scriptAction: "실행할 스크립트 내용을 입력해 주세요",
|
||||
httpRequestAction: "요청 URL과 방식을 설정해 주세요",
|
||||
procedureCallAction: "호출할 프로시저 정보를 입력해 주세요",
|
||||
comment: "메모 내용을 자유롭게 작성해 주세요",
|
||||
};
|
||||
|
||||
interface SlideOverSheetProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SlideOverSheet({ isOpen, onClose }: SlideOverSheetProps) {
|
||||
const { nodes, selectedNodes } = useFlowEditorStore();
|
||||
|
||||
const selectedNode =
|
||||
selectedNodes.length === 1
|
||||
? nodes.find((n) => n.id === selectedNodes[0])
|
||||
: null;
|
||||
|
||||
const nodeInfo = selectedNode
|
||||
? getNodePaletteItem(selectedNode.type as string)
|
||||
: null;
|
||||
|
||||
const hint = selectedNode
|
||||
? TOSS_NODE_HINTS[(selectedNode.type as string)] || "이 노드의 속성을 설정해 주세요"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute right-0 top-0 z-40 flex h-full w-[380px] flex-col border-l border-zinc-700 bg-zinc-900 shadow-2xl shadow-black/40 transition-transform duration-300 ease-out ${
|
||||
isOpen ? "translate-x-0" : "translate-x-full"
|
||||
}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex flex-shrink-0 items-center justify-between border-b border-zinc-800 px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{nodeInfo && (
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: nodeInfo.color }}
|
||||
/>
|
||||
)}
|
||||
<h3 className="truncate text-sm font-semibold text-zinc-200">
|
||||
{nodeInfo?.label || "속성"}
|
||||
</h3>
|
||||
</div>
|
||||
{selectedNode && (
|
||||
<p className="mt-0.5 truncate text-[11px] text-zinc-500">
|
||||
{(selectedNode.data as any)?.displayName || "이름 없음"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="ml-2 flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg text-zinc-500 transition-colors hover:bg-zinc-800 hover:text-zinc-300"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 힌트 배너 */}
|
||||
{hint && (
|
||||
<div className="flex items-start gap-2 border-b border-zinc-800 bg-violet-500/[0.06] px-4 py-2.5">
|
||||
<Info className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-violet-400" />
|
||||
<p className="text-[11px] leading-relaxed text-violet-300/80">{hint}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 속성 패널 내용 (라이트 배경으로 폼 가독성 유지) */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto bg-white dark:bg-zinc-800">
|
||||
<PropertiesPanel />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,107 +1,40 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 집계 노드 (Aggregate Node)
|
||||
* SUM, COUNT, AVG, MIN, MAX 등 집계 연산을 수행
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Calculator, Layers } from "lucide-react";
|
||||
import type { AggregateNodeData, AggregateFunction } from "@/types/node-editor";
|
||||
|
||||
// 집계 함수별 아이콘/라벨
|
||||
const AGGREGATE_FUNCTION_LABELS: Record<AggregateFunction, string> = {
|
||||
SUM: "합계",
|
||||
COUNT: "개수",
|
||||
AVG: "평균",
|
||||
MIN: "최소",
|
||||
MAX: "최대",
|
||||
FIRST: "첫번째",
|
||||
LAST: "마지막",
|
||||
};
|
||||
import { NodeProps } from "reactflow";
|
||||
import { BarChart3 } from "lucide-react";
|
||||
import { CompactNodeShell } from "./CompactNodeShell";
|
||||
import type { AggregateNodeData } from "@/types/node-editor";
|
||||
|
||||
export const AggregateNode = memo(({ data, selected }: NodeProps<AggregateNodeData>) => {
|
||||
const groupByCount = data.groupByFields?.length || 0;
|
||||
const aggregationCount = data.aggregations?.length || 0;
|
||||
const opCount = data.operations?.length || 0;
|
||||
const groupCount = data.groupByFields?.length || 0;
|
||||
const summary = opCount > 0
|
||||
? `${opCount}개 연산${groupCount > 0 ? `, ${groupCount}개 그룹` : ""}`
|
||||
: "집계 연산을 설정해 주세요";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[280px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-purple-500 shadow-lg" : "border-border"
|
||||
}`}
|
||||
<CompactNodeShell
|
||||
color="#A855F7"
|
||||
label={data.displayName || "집계"}
|
||||
summary={summary}
|
||||
icon={<BarChart3 className="h-3.5 w-3.5" />}
|
||||
selected={selected}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-purple-600 px-3 py-2 text-white">
|
||||
<Calculator className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || "집계"}</div>
|
||||
<div className="text-xs opacity-80">
|
||||
{groupByCount > 0 ? `${groupByCount}개 그룹` : "전체"} / {aggregationCount}개 집계
|
||||
</div>
|
||||
{opCount > 0 && (
|
||||
<div className="space-y-0.5">
|
||||
{data.operations!.slice(0, 3).map((op: any, i: number) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<span className="rounded bg-violet-500/20 px-1 py-0.5 font-mono text-[9px] font-semibold text-violet-400">
|
||||
{op.function || op.operation}
|
||||
</span>
|
||||
<span>{op.field || op.sourceField}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3 space-y-3">
|
||||
{/* 그룹 기준 */}
|
||||
{groupByCount > 0 && (
|
||||
<div className="rounded bg-purple-50 p-2">
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<Layers className="h-3 w-3 text-purple-600" />
|
||||
<span className="text-xs font-medium text-purple-700">그룹 기준</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{data.groupByFields.slice(0, 3).map((field, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center rounded bg-purple-100 px-2 py-0.5 text-xs text-purple-700"
|
||||
>
|
||||
{field.fieldLabel || field.field}
|
||||
</span>
|
||||
))}
|
||||
{data.groupByFields.length > 3 && (
|
||||
<span className="text-xs text-purple-500">+{data.groupByFields.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 집계 연산 */}
|
||||
{aggregationCount > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{data.aggregations.slice(0, 4).map((agg, idx) => (
|
||||
<div key={agg.id || idx} className="rounded bg-muted p-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="rounded bg-purple-600 px-1.5 py-0.5 text-xs font-medium text-white">
|
||||
{AGGREGATE_FUNCTION_LABELS[agg.function] || agg.function}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{agg.outputFieldLabel || agg.outputField}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{agg.sourceFieldLabel || agg.sourceField}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{data.aggregations.length > 4 && (
|
||||
<div className="text-xs text-muted-foreground/70 text-center">
|
||||
... 외 {data.aggregations.length - 4}개
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4 text-center text-xs text-muted-foreground/70">집계 연산 없음</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !bg-purple-500" />
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-purple-500" />
|
||||
</div>
|
||||
)}
|
||||
</CompactNodeShell>
|
||||
);
|
||||
});
|
||||
|
||||
AggregateNode.displayName = "AggregateNode";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,29 +1,21 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 주석 노드 - 플로우 설명용
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { NodeProps } from "reactflow";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import type { CommentNodeData } from "@/types/node-editor";
|
||||
import { CompactNodeShell } from "./CompactNodeShell";
|
||||
|
||||
export const CommentNode = memo(({ data, selected }: NodeProps<CommentNodeData>) => {
|
||||
export const CommentNode = memo(({ data, selected }: NodeProps<any>) => {
|
||||
return (
|
||||
<div
|
||||
className={`max-w-[350px] min-w-[200px] rounded-lg border-2 border-dashed bg-amber-50 shadow-sm transition-all ${
|
||||
selected ? "border-yellow-500 shadow-md" : "border-amber-300"
|
||||
}`}
|
||||
>
|
||||
<div className="p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4 text-amber-600" />
|
||||
<span className="text-xs font-semibold text-yellow-800">메모</span>
|
||||
</div>
|
||||
<div className="text-sm whitespace-pre-wrap text-foreground">{data.content || "메모를 입력하세요..."}</div>
|
||||
</div>
|
||||
</div>
|
||||
<CompactNodeShell
|
||||
color="#6B7280"
|
||||
label="메모"
|
||||
summary={data.comment || data.text || "메모를 작성해 주세요"}
|
||||
icon={<MessageSquare className="h-3.5 w-3.5" />}
|
||||
selected={selected}
|
||||
hasInput={false}
|
||||
hasOutput={false}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 콤팩트 노드 공통 쉘
|
||||
* 다크 캔버스에서 사용하는 공통 노드 래퍼
|
||||
*/
|
||||
|
||||
import { memo, ReactNode } from "react";
|
||||
import { Handle, Position } from "reactflow";
|
||||
|
||||
interface CompactNodeShellProps {
|
||||
color: string;
|
||||
label: string;
|
||||
summary?: string;
|
||||
icon: ReactNode;
|
||||
selected?: boolean;
|
||||
children?: ReactNode;
|
||||
hasInput?: boolean;
|
||||
hasOutput?: boolean;
|
||||
inputHandleId?: string;
|
||||
outputHandleId?: string;
|
||||
/** 커스텀 출력 핸들(ConditionNode 등)을 사용할 경우 true */
|
||||
customOutputHandles?: boolean;
|
||||
/** 커스텀 입력 핸들을 사용할 경우 true */
|
||||
customInputHandles?: boolean;
|
||||
minWidth?: string;
|
||||
}
|
||||
|
||||
export const CompactNodeShell = memo(
|
||||
({
|
||||
color,
|
||||
label,
|
||||
summary,
|
||||
icon,
|
||||
selected = false,
|
||||
children,
|
||||
hasInput = true,
|
||||
hasOutput = true,
|
||||
inputHandleId,
|
||||
outputHandleId,
|
||||
customOutputHandles = false,
|
||||
customInputHandles = false,
|
||||
minWidth = "260px",
|
||||
}: CompactNodeShellProps) => {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border bg-zinc-900 shadow-lg transition-all ${
|
||||
selected
|
||||
? "border-violet-500 shadow-violet-500/20"
|
||||
: "border-zinc-700 hover:border-zinc-600"
|
||||
}`}
|
||||
style={{ minWidth, maxWidth: "320px" }}
|
||||
>
|
||||
{/* 기본 입력 핸들 */}
|
||||
{hasInput && !customInputHandles && (
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id={inputHandleId}
|
||||
className="!h-2.5 !w-2.5 !border-2 !bg-zinc-900"
|
||||
style={{ borderColor: color }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 컬러바 + 헤더 */}
|
||||
<div className="flex items-center gap-2.5 px-3 py-2.5">
|
||||
<div
|
||||
className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md"
|
||||
style={{ backgroundColor: `${color}20` }}
|
||||
>
|
||||
<div className="text-zinc-200" style={{ color }}>{icon}</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-semibold text-zinc-200">{label}</div>
|
||||
{summary && (
|
||||
<div className="line-clamp-2 text-[10px] leading-relaxed text-zinc-500">{summary}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 바디 (옵셔널) */}
|
||||
{children && (
|
||||
<div className="border-t border-zinc-800 px-3 py-2 text-[10px] text-zinc-400">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 기본 출력 핸들 */}
|
||||
{hasOutput && !customOutputHandles && (
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id={outputHandleId}
|
||||
className="!h-2.5 !w-2.5 !border-2 !bg-zinc-900"
|
||||
style={{ borderColor: color }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CompactNodeShell.displayName = "CompactNodeShell";
|
||||
|
|
@ -1,132 +1,88 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 조건 분기 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Zap, Check, X } from "lucide-react";
|
||||
import { GitBranch } from "lucide-react";
|
||||
import { CompactNodeShell } from "./CompactNodeShell";
|
||||
import type { ConditionNodeData } from "@/types/node-editor";
|
||||
|
||||
const OPERATOR_LABELS: Record<string, string> = {
|
||||
EQUALS: "=",
|
||||
NOT_EQUALS: "≠",
|
||||
GREATER_THAN: ">",
|
||||
LESS_THAN: "<",
|
||||
GREATER_THAN_OR_EQUAL: "≥",
|
||||
LESS_THAN_OR_EQUAL: "≤",
|
||||
LIKE: "포함",
|
||||
NOT_LIKE: "미포함",
|
||||
IN: "IN",
|
||||
NOT_IN: "NOT IN",
|
||||
IS_NULL: "NULL",
|
||||
IS_NOT_NULL: "NOT NULL",
|
||||
EXISTS_IN: "EXISTS IN",
|
||||
NOT_EXISTS_IN: "NOT EXISTS IN",
|
||||
};
|
||||
|
||||
// EXISTS 계열 연산자인지 확인
|
||||
const isExistsOperator = (operator: string): boolean => {
|
||||
return operator === "EXISTS_IN" || operator === "NOT_EXISTS_IN";
|
||||
EQUALS: "=", NOT_EQUALS: "!=",
|
||||
GREATER_THAN: ">", LESS_THAN: "<",
|
||||
GREATER_THAN_OR_EQUAL: ">=", LESS_THAN_OR_EQUAL: "<=",
|
||||
LIKE: "포함", NOT_LIKE: "미포함",
|
||||
IN: "IN", NOT_IN: "NOT IN",
|
||||
IS_NULL: "NULL", IS_NOT_NULL: "NOT NULL",
|
||||
EXISTS_IN: "EXISTS", NOT_EXISTS_IN: "NOT EXISTS",
|
||||
};
|
||||
|
||||
export const ConditionNode = memo(({ data, selected }: NodeProps<ConditionNodeData>) => {
|
||||
const condCount = data.conditions?.length || 0;
|
||||
const summary = condCount > 0
|
||||
? `${condCount}개 조건 (${data.logic || "AND"})`
|
||||
: "조건을 설정해 주세요";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[280px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-yellow-500 shadow-lg" : "border-border"
|
||||
className={`rounded-lg border bg-zinc-900 shadow-lg transition-all ${
|
||||
selected ? "border-violet-500 shadow-violet-500/20" : "border-zinc-700"
|
||||
}`}
|
||||
style={{ minWidth: "260px", maxWidth: "320px" }}
|
||||
>
|
||||
{/* 입력 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !border-2 !border-yellow-500 !bg-white" />
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!h-2.5 !w-2.5 !border-2 !border-amber-500 !bg-zinc-900"
|
||||
/>
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-amber-500 px-3 py-2 text-white">
|
||||
<Zap className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">조건 검사</div>
|
||||
<div className="text-xs opacity-80">{data.displayName || "조건 분기"}</div>
|
||||
<div className="flex items-center gap-2.5 px-3 py-2.5">
|
||||
<div className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md bg-amber-500/20">
|
||||
<GitBranch className="h-3.5 w-3.5 text-amber-400" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-semibold text-zinc-200">
|
||||
{data.displayName || "조건 분기"}
|
||||
</div>
|
||||
<div className="line-clamp-2 text-[10px] leading-relaxed text-zinc-500">{summary}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
{data.conditions && data.conditions.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-foreground">조건식: ({data.conditions.length}개)</div>
|
||||
<div className="max-h-[150px] space-y-1.5 overflow-y-auto">
|
||||
{data.conditions.slice(0, 4).map((condition, idx) => (
|
||||
<div key={idx} className="rounded bg-amber-50 px-2 py-1.5 text-xs">
|
||||
{idx > 0 && (
|
||||
<div className="mb-1 text-center text-xs font-semibold text-amber-600">{data.logic}</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<span className="font-mono text-foreground">{condition.field}</span>
|
||||
<span
|
||||
className={`rounded px-1 py-0.5 ${
|
||||
isExistsOperator(condition.operator)
|
||||
? "bg-purple-200 text-purple-800"
|
||||
: "bg-yellow-200 text-yellow-800"
|
||||
}`}
|
||||
>
|
||||
{OPERATOR_LABELS[condition.operator] || condition.operator}
|
||||
</span>
|
||||
{/* EXISTS 연산자인 경우 테이블.필드 표시 */}
|
||||
{isExistsOperator(condition.operator) ? (
|
||||
<span className="text-purple-600">
|
||||
{(condition as any).lookupTableLabel || (condition as any).lookupTable || "..."}
|
||||
{(condition as any).lookupField && `.${(condition as any).lookupFieldLabel || (condition as any).lookupField}`}
|
||||
</span>
|
||||
) : (
|
||||
// 일반 연산자인 경우 값 표시
|
||||
condition.value !== null &&
|
||||
condition.value !== undefined && (
|
||||
<span className="text-muted-foreground">
|
||||
{typeof condition.value === "string" ? `"${condition.value}"` : String(condition.value)}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{data.conditions.length > 4 && (
|
||||
<div className="text-xs text-muted-foreground/70">... 외 {data.conditions.length - 4}개</div>
|
||||
{/* 조건 미리보기 */}
|
||||
{condCount > 0 && (
|
||||
<div className="space-y-0.5 border-t border-zinc-800 px-3 py-2 text-[10px] text-zinc-400">
|
||||
{data.conditions!.slice(0, 2).map((c, i) => (
|
||||
<div key={i} className="flex items-center gap-1 flex-wrap">
|
||||
{i > 0 && <span className="text-amber-500">{data.logic}</span>}
|
||||
<span className="font-mono text-zinc-300">{c.field}</span>
|
||||
<span className="text-amber-400">{OPERATOR_LABELS[c.operator] || c.operator}</span>
|
||||
{c.value !== undefined && c.value !== null && (
|
||||
<span className="text-zinc-500">{String(c.value)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-xs text-muted-foreground/70">조건 없음</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{condCount > 2 && <span className="text-zinc-600">외 {condCount - 2}개</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 분기 출력 핸들 */}
|
||||
<div className="relative border-t">
|
||||
{/* TRUE 출력 - 오른쪽 위 */}
|
||||
<div className="relative border-b p-2">
|
||||
<div className="flex items-center justify-end gap-1 pr-6 text-xs">
|
||||
<Check className="h-3 w-3 text-emerald-600" />
|
||||
<span className="font-medium text-emerald-600">TRUE</span>
|
||||
</div>
|
||||
{/* 분기 출력 */}
|
||||
<div className="border-t border-zinc-800">
|
||||
<div className="relative flex items-center justify-end px-3 py-1.5">
|
||||
<span className="text-[10px] font-medium text-emerald-400">통과</span>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="true"
|
||||
className="!top-1/2 !-right-1.5 !h-3 !w-3 !-translate-y-1/2 !border-2 !border-emerald-500 !bg-white"
|
||||
className="!h-2.5 !w-2.5 !border-2 !border-emerald-500 !bg-zinc-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* FALSE 출력 - 오른쪽 아래 */}
|
||||
<div className="relative p-2">
|
||||
<div className="flex items-center justify-end gap-1 pr-6 text-xs">
|
||||
<X className="h-3 w-3 text-destructive" />
|
||||
<span className="font-medium text-destructive">FALSE</span>
|
||||
</div>
|
||||
<div className="relative flex items-center justify-end border-t border-zinc-800/50 px-3 py-1.5">
|
||||
<span className="text-[10px] font-medium text-pink-400">미통과</span>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="false"
|
||||
className="!top-1/2 !-right-1.5 !h-3 !w-3 !-translate-y-1/2 !border-2 !border-destructive !bg-white"
|
||||
className="!h-2.5 !w-2.5 !border-2 !border-pink-500 !bg-zinc-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,87 +1,38 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 데이터 변환 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Wand2, ArrowRight } from "lucide-react";
|
||||
import { NodeProps } from "reactflow";
|
||||
import { Repeat } from "lucide-react";
|
||||
import { CompactNodeShell } from "./CompactNodeShell";
|
||||
import type { DataTransformNodeData } from "@/types/node-editor";
|
||||
|
||||
export const DataTransformNode = memo(({ data, selected }: NodeProps<DataTransformNodeData>) => {
|
||||
const ruleCount = data.transformRules?.length || 0;
|
||||
const summary = ruleCount > 0
|
||||
? `${ruleCount}개 변환 규칙`
|
||||
: "변환 규칙을 설정해 주세요";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-orange-500 shadow-lg" : "border-border"
|
||||
}`}
|
||||
<CompactNodeShell
|
||||
color="#06B6D4"
|
||||
label={data.displayName || "데이터 변환"}
|
||||
summary={summary}
|
||||
icon={<Repeat className="h-3.5 w-3.5" />}
|
||||
selected={selected}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-primary px-3 py-2 text-white">
|
||||
<Wand2 className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || "데이터 변환"}</div>
|
||||
<div className="text-xs opacity-80">{data.transformations?.length || 0}개 변환</div>
|
||||
{ruleCount > 0 && (
|
||||
<div className="space-y-0.5">
|
||||
{data.transformRules!.slice(0, 3).map((r: any, i: number) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<div className="h-1 w-1 rounded-full bg-cyan-400" />
|
||||
<span>{r.sourceField || r.field || `규칙 ${i + 1}`}</span>
|
||||
{r.targetField && <span className="text-zinc-600">→ {r.targetField}</span>}
|
||||
</div>
|
||||
))}
|
||||
{ruleCount > 3 && <span className="text-zinc-600">외 {ruleCount - 3}개</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
{data.transformations && data.transformations.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{data.transformations.slice(0, 3).map((transform, idx) => {
|
||||
const sourceLabel = transform.sourceFieldLabel || transform.sourceField || "소스";
|
||||
const targetField = transform.targetField || transform.sourceField;
|
||||
const targetLabel = transform.targetFieldLabel || targetField;
|
||||
const isInPlace = !transform.targetField || transform.targetField === transform.sourceField;
|
||||
|
||||
return (
|
||||
<div key={idx} className="rounded bg-indigo-50 p-2">
|
||||
<div className="mb-1 flex items-center gap-2 text-xs">
|
||||
<span className="font-medium text-indigo-700">{transform.type}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{sourceLabel}
|
||||
<span className="mx-1 text-muted-foreground/70">→</span>
|
||||
{isInPlace ? (
|
||||
<span className="font-medium text-primary">(자기자신)</span>
|
||||
) : (
|
||||
<span>{targetLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 타입별 추가 정보 */}
|
||||
{transform.type === "EXPLODE" && transform.delimiter && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">구분자: {transform.delimiter}</div>
|
||||
)}
|
||||
{transform.type === "CONCAT" && transform.separator && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">구분자: {transform.separator}</div>
|
||||
)}
|
||||
{transform.type === "REPLACE" && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
"{transform.searchValue}" → "{transform.replaceValue}"
|
||||
</div>
|
||||
)}
|
||||
{transform.expression && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
<code className="rounded bg-white px-1 py-0.5">{transform.expression}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{data.transformations.length > 3 && (
|
||||
<div className="text-xs text-muted-foreground/70">... 외 {data.transformations.length - 3}개</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4 text-center text-xs text-muted-foreground/70">변환 규칙 없음</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !bg-primary" />
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-primary" />
|
||||
</div>
|
||||
)}
|
||||
</CompactNodeShell>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,75 +1,25 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* DELETE 액션 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Trash2, AlertTriangle } from "lucide-react";
|
||||
import { NodeProps } from "reactflow";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { CompactNodeShell } from "./CompactNodeShell";
|
||||
import type { DeleteActionNodeData } from "@/types/node-editor";
|
||||
|
||||
export const DeleteActionNode = memo(({ data, selected }: NodeProps<DeleteActionNodeData>) => {
|
||||
const whereCount = data.whereConditions?.length || 0;
|
||||
const summary = data.targetTable
|
||||
? `${data.targetTable} (${whereCount}개 조건)`
|
||||
: "대상 테이블을 선택해 주세요";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-destructive shadow-lg" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{/* 입력 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !border-2 !border-destructive !bg-white" />
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-destructive px-3 py-2 text-white">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">DELETE</div>
|
||||
<div className="text-xs opacity-80">{data.displayName || data.targetTable}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">타겟: {data.targetTable}</div>
|
||||
|
||||
{/* WHERE 조건 */}
|
||||
{data.whereConditions && data.whereConditions.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-foreground">WHERE 조건:</div>
|
||||
<div className="max-h-[120px] space-y-1 overflow-y-auto">
|
||||
{data.whereConditions.map((condition, idx) => (
|
||||
<div key={idx} className="rounded bg-destructive/10 px-2 py-1 text-xs">
|
||||
<span className="font-mono text-foreground">{condition.field}</span>
|
||||
<span className="mx-1 text-destructive">{condition.operator}</span>
|
||||
<span className="text-muted-foreground">{condition.sourceField || condition.staticValue || "?"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded bg-amber-50 p-2 text-xs text-yellow-700">⚠️ 조건 없음 - 모든 데이터 삭제 주의!</div>
|
||||
)}
|
||||
|
||||
{/* 경고 메시지 */}
|
||||
<div className="mt-3 flex items-start gap-2 rounded border border-destructive/20 bg-destructive/10 p-2">
|
||||
<AlertTriangle className="h-3 w-3 flex-shrink-0 text-destructive" />
|
||||
<div className="text-xs text-destructive">
|
||||
<div className="font-medium">주의</div>
|
||||
<div className="mt-0.5">삭제된 데이터는 복구할 수 없습니다</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 옵션 */}
|
||||
{data.options?.requireConfirmation && (
|
||||
<div className="mt-2">
|
||||
<span className="rounded bg-destructive/10 px-1.5 py-0.5 text-xs text-destructive">실행 전 확인 필요</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 출력 핸들 */}
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !border-2 !border-destructive !bg-white" />
|
||||
</div>
|
||||
<CompactNodeShell
|
||||
color="#EF4444"
|
||||
label={data.displayName || "DELETE"}
|
||||
summary={summary}
|
||||
icon={<Trash2 className="h-3.5 w-3.5" />}
|
||||
selected={selected}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,104 +1,30 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 메일 발송 액션 노드
|
||||
* 등록된 메일 계정을 선택하여 이메일을 발송하는 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Mail, User, CheckCircle } from "lucide-react";
|
||||
import type { EmailActionNodeData } from "@/types/node-editor";
|
||||
import { NodeProps } from "reactflow";
|
||||
import { Mail } from "lucide-react";
|
||||
import { CompactNodeShell } from "./CompactNodeShell";
|
||||
|
||||
export const EmailActionNode = memo(({ data, selected }: NodeProps<EmailActionNodeData>) => {
|
||||
const hasAccount = !!data.accountId;
|
||||
const hasRecipient = data.to && data.to.trim().length > 0;
|
||||
const hasSubject = data.subject && data.subject.trim().length > 0;
|
||||
export const EmailActionNode = memo(({ data, selected }: NodeProps<any>) => {
|
||||
const summary = data.to
|
||||
? `To: ${data.to}`
|
||||
: "수신자를 설정해 주세요";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-pink-500 shadow-lg" : "border-border"
|
||||
}`}
|
||||
<CompactNodeShell
|
||||
color="#EC4899"
|
||||
label={data.displayName || "메일 발송"}
|
||||
summary={summary}
|
||||
icon={<Mail className="h-3.5 w-3.5" />}
|
||||
selected={selected}
|
||||
>
|
||||
{/* 입력 핸들 */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!h-3 !w-3 !border-2 !border-white !bg-pink-500"
|
||||
/>
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-pink-500 px-3 py-2 text-white">
|
||||
<Mail className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || "메일 발송"}</div>
|
||||
{data.subject && (
|
||||
<div className="line-clamp-2">
|
||||
제목: {data.subject}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="space-y-2 p-3">
|
||||
{/* 발송 계정 상태 */}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<User className="h-3 w-3 text-muted-foreground/70" />
|
||||
<span className="text-muted-foreground">
|
||||
{hasAccount ? (
|
||||
<span className="flex items-center gap-1 text-emerald-600">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
계정 선택됨
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-amber-500">발송 계정 선택 필요</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 수신자 */}
|
||||
<div className="text-xs">
|
||||
<span className="text-muted-foreground">수신자: </span>
|
||||
{hasRecipient ? (
|
||||
<span className="text-foreground">{data.to}</span>
|
||||
) : (
|
||||
<span className="text-amber-500">미설정</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 제목 */}
|
||||
<div className="text-xs">
|
||||
<span className="text-muted-foreground">제목: </span>
|
||||
{hasSubject ? (
|
||||
<span className="truncate text-foreground">{data.subject}</span>
|
||||
) : (
|
||||
<span className="text-amber-500">미설정</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 본문 형식 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-xs ${
|
||||
data.bodyType === "html" ? "bg-primary/10 text-primary" : "bg-muted text-foreground"
|
||||
}`}
|
||||
>
|
||||
{data.bodyType === "html" ? "HTML" : "TEXT"}
|
||||
</span>
|
||||
{data.attachments && data.attachments.length > 0 && (
|
||||
<span className="rounded bg-purple-100 px-1.5 py-0.5 text-xs text-purple-700">
|
||||
첨부 {data.attachments.length}개
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 출력 핸들 */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!h-3 !w-3 !border-2 !border-white !bg-pink-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CompactNodeShell>
|
||||
);
|
||||
});
|
||||
|
||||
EmailActionNode.displayName = "EmailActionNode";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,87 +1,25 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 외부 DB 소스 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Plug } from "lucide-react";
|
||||
import { NodeProps } from "reactflow";
|
||||
import { HardDrive } from "lucide-react";
|
||||
import { CompactNodeShell } from "./CompactNodeShell";
|
||||
import type { ExternalDBSourceNodeData } from "@/types/node-editor";
|
||||
|
||||
const DB_TYPE_COLORS: Record<string, string> = {
|
||||
PostgreSQL: "#336791",
|
||||
MySQL: "#4479A1",
|
||||
Oracle: "#F80000",
|
||||
MSSQL: "#CC2927",
|
||||
MariaDB: "#003545",
|
||||
};
|
||||
|
||||
const DB_TYPE_ICONS: Record<string, string> = {
|
||||
PostgreSQL: "🐘",
|
||||
MySQL: "🐬",
|
||||
Oracle: "🔴",
|
||||
MSSQL: "🟦",
|
||||
MariaDB: "🦭",
|
||||
};
|
||||
|
||||
export const ExternalDBSourceNode = memo(({ data, selected }: NodeProps<ExternalDBSourceNodeData>) => {
|
||||
const dbColor = (data.dbType && DB_TYPE_COLORS[data.dbType]) || "#F59E0B";
|
||||
const dbIcon = (data.dbType && DB_TYPE_ICONS[data.dbType]) || "🔌";
|
||||
const summary = data.connectionName
|
||||
? `${data.connectionName} → ${data.tableName || "..."}`
|
||||
: "외부 DB 연결을 설정해 주세요";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-orange-500 shadow-lg" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg px-3 py-2 text-white" style={{ backgroundColor: dbColor }}>
|
||||
<Plug className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || data.connectionName}</div>
|
||||
<div className="text-xs opacity-80">{data.tableName}</div>
|
||||
</div>
|
||||
<span className="text-lg">{dbIcon}</span>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
<div className="mb-2 flex items-center gap-1 text-xs">
|
||||
<div className="rounded bg-amber-100 px-2 py-0.5 font-medium text-orange-700">{data.dbType || "DB"}</div>
|
||||
<div className="flex-1 text-muted-foreground">외부 DB</div>
|
||||
</div>
|
||||
|
||||
{/* 필드 목록 */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-foreground">출력 필드:</div>
|
||||
<div className="max-h-[150px] overflow-y-auto">
|
||||
{data.fields && data.fields.length > 0 ? (
|
||||
data.fields.slice(0, 5).map((field) => (
|
||||
<div key={field.name} className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<div className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: dbColor }} />
|
||||
<span className="font-mono">{field.name}</span>
|
||||
<span className="text-muted-foreground/70">({field.type})</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground/70">필드 없음</div>
|
||||
)}
|
||||
{data.fields && data.fields.length > 5 && (
|
||||
<div className="text-xs text-muted-foreground/70">... 외 {data.fields.length - 5}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 출력 핸들 */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!h-3 !w-3 !border-2 !bg-white"
|
||||
style={{ borderColor: dbColor }}
|
||||
/>
|
||||
</div>
|
||||
<CompactNodeShell
|
||||
color="#F59E0B"
|
||||
label={data.displayName || "외부 DB"}
|
||||
summary={summary}
|
||||
icon={<HardDrive className="h-3.5 w-3.5" />}
|
||||
selected={selected}
|
||||
hasInput={false}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,164 +1,23 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 수식 변환 노드 (Formula Transform Node)
|
||||
* 산술 연산, 함수, 조건문 등을 사용해 새로운 필드를 계산합니다.
|
||||
* 타겟 테이블의 기존 값을 참조하여 UPSERT 시나리오를 지원합니다.
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Calculator, Database, ArrowRight } from "lucide-react";
|
||||
import type { FormulaTransformNodeData, FormulaType } from "@/types/node-editor";
|
||||
import { NodeProps } from "reactflow";
|
||||
import { Calculator } from "lucide-react";
|
||||
import { CompactNodeShell } from "./CompactNodeShell";
|
||||
|
||||
// 수식 타입별 라벨
|
||||
const FORMULA_TYPE_LABELS: Record<FormulaType, { label: string; color: string }> = {
|
||||
arithmetic: { label: "산술", color: "bg-amber-500" },
|
||||
function: { label: "함수", color: "bg-primary" },
|
||||
condition: { label: "조건", color: "bg-amber-500" },
|
||||
static: { label: "정적", color: "bg-muted0" },
|
||||
};
|
||||
|
||||
// 연산자 표시
|
||||
const OPERATOR_LABELS: Record<string, string> = {
|
||||
"+": "+",
|
||||
"-": "-",
|
||||
"*": "x",
|
||||
"/": "/",
|
||||
"%": "%",
|
||||
};
|
||||
|
||||
// 피연산자를 문자열로 변환
|
||||
function getOperandStr(operand: any): string {
|
||||
if (!operand) return "?";
|
||||
if (operand.type === "static") return String(operand.value || "?");
|
||||
if (operand.fieldLabel) return operand.fieldLabel;
|
||||
return operand.field || operand.resultField || "?";
|
||||
}
|
||||
|
||||
// 수식 요약 생성
|
||||
function getFormulaSummary(transformation: FormulaTransformNodeData["transformations"][0]): string {
|
||||
const { formulaType, arithmetic, function: func, condition, staticValue } = transformation;
|
||||
|
||||
switch (formulaType) {
|
||||
case "arithmetic": {
|
||||
if (!arithmetic) return "미설정";
|
||||
const leftStr = getOperandStr(arithmetic.leftOperand);
|
||||
const rightStr = getOperandStr(arithmetic.rightOperand);
|
||||
let formula = `${leftStr} ${OPERATOR_LABELS[arithmetic.operator]} ${rightStr}`;
|
||||
|
||||
// 추가 연산 표시
|
||||
if (arithmetic.additionalOperations && arithmetic.additionalOperations.length > 0) {
|
||||
for (const addOp of arithmetic.additionalOperations) {
|
||||
const opStr = getOperandStr(addOp.operand);
|
||||
formula += ` ${OPERATOR_LABELS[addOp.operator] || addOp.operator} ${opStr}`;
|
||||
}
|
||||
}
|
||||
|
||||
return formula;
|
||||
}
|
||||
case "function": {
|
||||
if (!func) return "미설정";
|
||||
const args = func.arguments
|
||||
.map((arg) => (arg.type === "static" ? arg.value : `${arg.type}.${arg.field || arg.resultField}`))
|
||||
.join(", ");
|
||||
return `${func.name}(${args})`;
|
||||
}
|
||||
case "condition": {
|
||||
if (!condition) return "미설정";
|
||||
return "CASE WHEN ... THEN ... ELSE ...";
|
||||
}
|
||||
case "static": {
|
||||
return staticValue !== undefined ? String(staticValue) : "미설정";
|
||||
}
|
||||
default:
|
||||
return "미설정";
|
||||
}
|
||||
}
|
||||
|
||||
export const FormulaTransformNode = memo(({ data, selected }: NodeProps<FormulaTransformNodeData>) => {
|
||||
const transformationCount = data.transformations?.length || 0;
|
||||
const hasTargetLookup = !!data.targetLookup?.tableName;
|
||||
export const FormulaTransformNode = memo(({ data, selected }: NodeProps<any>) => {
|
||||
const summary = data.formula
|
||||
? `${data.formula.substring(0, 30)}${data.formula.length > 30 ? "..." : ""}`
|
||||
: "수식을 입력해 주세요";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[300px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-orange-500 shadow-lg" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-amber-500 px-3 py-2 text-white">
|
||||
<Calculator className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || "수식 변환"}</div>
|
||||
<div className="text-xs opacity-80">
|
||||
{transformationCount}개 변환 {hasTargetLookup && "| 타겟 조회"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="space-y-3 p-3">
|
||||
{/* 타겟 테이블 조회 설정 */}
|
||||
{hasTargetLookup && (
|
||||
<div className="rounded bg-primary/10 p-2">
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
<Database className="h-3 w-3 text-primary" />
|
||||
<span className="text-xs font-medium text-primary">타겟 조회</span>
|
||||
</div>
|
||||
<div className="text-xs text-primary">{data.targetLookup?.tableLabel || data.targetLookup?.tableName}</div>
|
||||
{data.targetLookup?.lookupKeys && data.targetLookup.lookupKeys.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{data.targetLookup.lookupKeys.slice(0, 2).map((key, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center gap-1 rounded bg-primary/10 px-1.5 py-0.5 text-xs text-primary"
|
||||
>
|
||||
{key.sourceFieldLabel || key.sourceField}
|
||||
<ArrowRight className="h-2 w-2" />
|
||||
{key.targetFieldLabel || key.targetField}
|
||||
</span>
|
||||
))}
|
||||
{data.targetLookup.lookupKeys.length > 2 && (
|
||||
<span className="text-xs text-primary">+{data.targetLookup.lookupKeys.length - 2}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 변환 규칙들 */}
|
||||
{transformationCount > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{data.transformations.slice(0, 4).map((trans, idx) => {
|
||||
const typeInfo = FORMULA_TYPE_LABELS[trans.formulaType];
|
||||
return (
|
||||
<div key={trans.id || idx} className="rounded bg-muted p-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`rounded px-1.5 py-0.5 text-xs font-medium text-white ${typeInfo.color}`}>
|
||||
{typeInfo.label}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{trans.outputFieldLabel || trans.outputField}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 truncate font-mono text-xs text-muted-foreground">{getFormulaSummary(trans)}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{data.transformations.length > 4 && (
|
||||
<div className="text-center text-xs text-muted-foreground/70">... 외 {data.transformations.length - 4}개</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4 text-center text-xs text-muted-foreground/70">변환 규칙 없음</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !bg-amber-500" />
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-amber-500" />
|
||||
</div>
|
||||
<CompactNodeShell
|
||||
color="#F97316"
|
||||
label={data.displayName || "수식 변환"}
|
||||
summary={summary}
|
||||
icon={<Calculator className="h-3.5 w-3.5" />}
|
||||
selected={selected}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,124 +1,34 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* HTTP 요청 액션 노드
|
||||
* REST API를 호출하는 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Globe, Lock, Unlock } from "lucide-react";
|
||||
import type { HttpRequestActionNodeData } from "@/types/node-editor";
|
||||
import { NodeProps } from "reactflow";
|
||||
import { Send } from "lucide-react";
|
||||
import { CompactNodeShell } from "./CompactNodeShell";
|
||||
|
||||
// HTTP 메서드별 색상
|
||||
const METHOD_COLORS: Record<string, { bg: string; text: string }> = {
|
||||
GET: { bg: "bg-emerald-100", text: "text-emerald-700" },
|
||||
POST: { bg: "bg-primary/10", text: "text-primary" },
|
||||
PUT: { bg: "bg-amber-100", text: "text-orange-700" },
|
||||
PATCH: { bg: "bg-amber-100", text: "text-yellow-700" },
|
||||
DELETE: { bg: "bg-destructive/10", text: "text-destructive" },
|
||||
HEAD: { bg: "bg-muted", text: "text-foreground" },
|
||||
OPTIONS: { bg: "bg-purple-100", text: "text-purple-700" },
|
||||
};
|
||||
|
||||
export const HttpRequestActionNode = memo(({ data, selected }: NodeProps<HttpRequestActionNodeData>) => {
|
||||
const methodColor = METHOD_COLORS[data.method] || METHOD_COLORS.GET;
|
||||
const hasUrl = data.url && data.url.trim().length > 0;
|
||||
const hasAuth = data.authentication?.type && data.authentication.type !== "none";
|
||||
|
||||
// URL에서 도메인 추출
|
||||
const getDomain = (url: string) => {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
return urlObj.hostname;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
export const HttpRequestActionNode = memo(({ data, selected }: NodeProps<any>) => {
|
||||
const method = data.method || "GET";
|
||||
const summary = data.url
|
||||
? `${method} ${data.url}`
|
||||
: "요청 URL을 입력해 주세요";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-cyan-500 shadow-lg" : "border-border"
|
||||
}`}
|
||||
<CompactNodeShell
|
||||
color="#06B6D4"
|
||||
label={data.displayName || "HTTP 요청"}
|
||||
summary={summary}
|
||||
icon={<Send className="h-3.5 w-3.5" />}
|
||||
selected={selected}
|
||||
>
|
||||
{/* 입력 핸들 */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!h-3 !w-3 !border-2 !border-white !bg-cyan-500"
|
||||
/>
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-cyan-500 px-3 py-2 text-white">
|
||||
<Globe className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || "HTTP 요청"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="space-y-2 p-3">
|
||||
{/* 메서드 & 인증 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`rounded px-2 py-0.5 text-xs font-bold ${methodColor.bg} ${methodColor.text}`}>
|
||||
{data.method}
|
||||
{data.url && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="rounded bg-cyan-500/20 px-1 py-0.5 font-mono text-[9px] font-semibold text-cyan-400">
|
||||
{method}
|
||||
</span>
|
||||
{hasAuth ? (
|
||||
<span className="flex items-center gap-1 rounded bg-emerald-100 px-1.5 py-0.5 text-xs text-emerald-700">
|
||||
<Lock className="h-3 w-3" />
|
||||
{data.authentication?.type}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
|
||||
<Unlock className="h-3 w-3" />
|
||||
인증없음
|
||||
</span>
|
||||
)}
|
||||
<span className="break-all font-mono">{data.url}</span>
|
||||
</div>
|
||||
|
||||
{/* URL */}
|
||||
<div className="text-xs">
|
||||
<span className="text-muted-foreground">URL: </span>
|
||||
{hasUrl ? (
|
||||
<span className="truncate text-foreground" title={data.url}>
|
||||
{getDomain(data.url)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-amber-500">URL 설정 필요</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 바디 타입 */}
|
||||
{data.bodyType && data.bodyType !== "none" && (
|
||||
<div className="text-xs">
|
||||
<span className="text-muted-foreground">Body: </span>
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-muted-foreground">
|
||||
{data.bodyType.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 타임아웃 & 재시도 */}
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
{data.options?.timeout && (
|
||||
<span>타임아웃: {Math.round(data.options.timeout / 1000)}초</span>
|
||||
)}
|
||||
{data.options?.retryCount && data.options.retryCount > 0 && (
|
||||
<span>재시도: {data.options.retryCount}회</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 출력 핸들 */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!h-3 !w-3 !border-2 !border-white !bg-cyan-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CompactNodeShell>
|
||||
);
|
||||
});
|
||||
|
||||
HttpRequestActionNode.displayName = "HttpRequestActionNode";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,81 +1,38 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* INSERT 액션 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { NodeProps } from "reactflow";
|
||||
import { Plus } from "lucide-react";
|
||||
import { CompactNodeShell } from "./CompactNodeShell";
|
||||
import type { InsertActionNodeData } from "@/types/node-editor";
|
||||
|
||||
export const InsertActionNode = memo(({ data, selected }: NodeProps<InsertActionNodeData>) => {
|
||||
const mappingCount = data.fieldMappings?.length || 0;
|
||||
const summary = data.targetTable
|
||||
? `${data.targetTable} (${mappingCount}개 필드)`
|
||||
: "대상 테이블을 선택해 주세요";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-emerald-500 shadow-lg" : "border-border"
|
||||
}`}
|
||||
<CompactNodeShell
|
||||
color="#22C55E"
|
||||
label={data.displayName || "INSERT"}
|
||||
summary={summary}
|
||||
icon={<Plus className="h-3.5 w-3.5" />}
|
||||
selected={selected}
|
||||
>
|
||||
{/* 입력 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !border-2 !border-emerald-500 !bg-white" />
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-emerald-500 px-3 py-2 text-white">
|
||||
<Plus className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">INSERT</div>
|
||||
<div className="text-xs opacity-80">{data.displayName || data.targetTable}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
타겟: {data.displayName || data.targetTable}
|
||||
{data.targetTable && data.displayName && data.displayName !== data.targetTable && (
|
||||
<span className="ml-1 font-mono text-muted-foreground/70">({data.targetTable})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 필드 매핑 */}
|
||||
{data.fieldMappings && data.fieldMappings.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-foreground">삽입 필드:</div>
|
||||
<div className="max-h-[120px] space-y-1 overflow-y-auto">
|
||||
{data.fieldMappings.slice(0, 4).map((mapping, idx) => (
|
||||
<div key={idx} className="rounded bg-muted px-2 py-1 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{mapping.sourceFieldLabel || mapping.sourceField || mapping.staticValue || "?"}
|
||||
</span>
|
||||
<span className="mx-1 text-muted-foreground/70">→</span>
|
||||
<span className="font-mono text-foreground">{mapping.targetFieldLabel || mapping.targetField}</span>
|
||||
</div>
|
||||
))}
|
||||
{data.fieldMappings.length > 4 && (
|
||||
<div className="text-xs text-muted-foreground/70">... 외 {data.fieldMappings.length - 4}개</div>
|
||||
)}
|
||||
{mappingCount > 0 && (
|
||||
<div className="space-y-0.5">
|
||||
{data.fieldMappings!.slice(0, 3).map((m, i) => (
|
||||
<div key={i} className="flex items-center gap-1">
|
||||
<span>{m.sourceFieldLabel || m.sourceField || "?"}</span>
|
||||
<span className="text-zinc-600">→</span>
|
||||
<span className="font-mono text-zinc-300">{m.targetFieldLabel || m.targetField}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 옵션 */}
|
||||
{data.options && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{data.options.ignoreDuplicates && (
|
||||
<span className="rounded bg-emerald-100 px-1.5 py-0.5 text-xs text-emerald-700">중복 무시</span>
|
||||
)}
|
||||
{data.options.batchSize && (
|
||||
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-xs text-primary">
|
||||
배치 {data.options.batchSize}건
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 출력 핸들 */}
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !border-2 !border-emerald-500 !bg-white" />
|
||||
</div>
|
||||
))}
|
||||
{mappingCount > 3 && <span className="text-zinc-600">외 {mappingCount - 3}개</span>}
|
||||
</div>
|
||||
)}
|
||||
</CompactNodeShell>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,58 +1,24 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 로그 노드 - 디버깅 및 모니터링용
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { FileText, AlertCircle, Info, AlertTriangle } from "lucide-react";
|
||||
import type { LogNodeData } from "@/types/node-editor";
|
||||
import { NodeProps } from "reactflow";
|
||||
import { FileText } from "lucide-react";
|
||||
import { CompactNodeShell } from "./CompactNodeShell";
|
||||
|
||||
const LOG_LEVEL_CONFIG = {
|
||||
debug: { icon: Info, color: "text-primary", bg: "bg-primary/10", border: "border-primary/20" },
|
||||
info: { icon: Info, color: "text-emerald-600", bg: "bg-emerald-50", border: "border-emerald-200" },
|
||||
warn: { icon: AlertTriangle, color: "text-amber-600", bg: "bg-amber-50", border: "border-amber-200" },
|
||||
error: { icon: AlertCircle, color: "text-destructive", bg: "bg-destructive/10", border: "border-destructive/20" },
|
||||
};
|
||||
|
||||
export const LogNode = memo(({ data, selected }: NodeProps<LogNodeData>) => {
|
||||
const config = LOG_LEVEL_CONFIG[data.level] || LOG_LEVEL_CONFIG.info;
|
||||
const Icon = config.icon;
|
||||
export const LogNode = memo(({ data, selected }: NodeProps<any>) => {
|
||||
const summary = data.logLevel
|
||||
? `${data.logLevel} 레벨 로깅`
|
||||
: "로그를 기록해요";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[200px] rounded-lg border-2 bg-white shadow-sm transition-all ${
|
||||
selected ? `${config.border} shadow-md` : "border-border"
|
||||
}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className={`flex items-center gap-2 rounded-t-lg ${config.bg} px-3 py-2`}>
|
||||
<FileText className={`h-4 w-4 ${config.color}`} />
|
||||
<div className="flex-1">
|
||||
<div className={`text-sm font-semibold ${config.color}`}>로그</div>
|
||||
<div className="text-xs text-muted-foreground">{data.level.toUpperCase()}</div>
|
||||
</div>
|
||||
<Icon className={`h-4 w-4 ${config.color}`} />
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
{data.message ? (
|
||||
<div className="text-sm text-foreground">{data.message}</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground/70">로그 메시지 없음</div>
|
||||
)}
|
||||
|
||||
{data.includeData && (
|
||||
<div className="mt-2 rounded bg-muted px-2 py-1 text-xs text-muted-foreground">✓ 데이터 포함</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !bg-muted-foreground" />
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-muted-foreground" />
|
||||
</div>
|
||||
<CompactNodeShell
|
||||
color="#6B7280"
|
||||
label={data.displayName || "로그"}
|
||||
summary={summary}
|
||||
icon={<FileText className="h-3.5 w-3.5" />}
|
||||
selected={selected}
|
||||
hasOutput={false}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,121 +1,24 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 프로시저/함수 호출 액션 노드
|
||||
* 내부 또는 외부 DB의 프로시저/함수를 호출하는 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Database, Workflow } from "lucide-react";
|
||||
import type { ProcedureCallActionNodeData } from "@/types/node-editor";
|
||||
import { NodeProps } from "reactflow";
|
||||
import { Database } from "lucide-react";
|
||||
import { CompactNodeShell } from "./CompactNodeShell";
|
||||
|
||||
export const ProcedureCallActionNode = memo(
|
||||
({ data, selected }: NodeProps<ProcedureCallActionNodeData>) => {
|
||||
const hasProcedure = !!data.procedureName;
|
||||
const inParams = data.parameters?.filter((p) => p.mode === "IN" || p.mode === "INOUT") ?? [];
|
||||
const outParams = data.parameters?.filter((p) => p.mode === "OUT" || p.mode === "INOUT") ?? [];
|
||||
export const ProcedureCallActionNode = memo(({ data, selected }: NodeProps<any>) => {
|
||||
const summary = data.procedureName
|
||||
? `${data.procedureName}()`
|
||||
: "프로시저를 선택해 주세요";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-violet-500 shadow-lg" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{/* 입력 핸들 */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!h-3 !w-3 !border-2 !border-white !bg-violet-500"
|
||||
/>
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-violet-500 px-3 py-2 text-white">
|
||||
<Workflow className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">
|
||||
{data.displayName || "프로시저 호출"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="space-y-2 p-3">
|
||||
{/* DB 소스 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-3 w-3 text-muted-foreground/70" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{data.dbSource === "external" ? (
|
||||
<span className="rounded bg-amber-100 px-2 py-0.5 text-amber-700">
|
||||
{data.connectionName || "외부 DB"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded bg-primary/10 px-2 py-0.5 text-primary">
|
||||
내부 DB
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`ml-auto rounded px-2 py-0.5 text-xs font-medium ${
|
||||
data.callType === "function"
|
||||
? "bg-cyan-100 text-cyan-700"
|
||||
: "bg-violet-100 text-violet-700"
|
||||
}`}
|
||||
>
|
||||
{data.callType === "function" ? "FUNCTION" : "PROCEDURE"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 프로시저명 */}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Workflow className="h-3 w-3 text-muted-foreground/70" />
|
||||
{hasProcedure ? (
|
||||
<span className="font-mono text-emerald-600 truncate">
|
||||
{data.procedureSchema && data.procedureSchema !== "public"
|
||||
? `${data.procedureSchema}.`
|
||||
: ""}
|
||||
{data.procedureName}()
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-amber-500">프로시저 선택 필요</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 파라미터 수 */}
|
||||
{hasProcedure && inParams.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
입력 파라미터: {inParams.length}개
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 반환 필드 */}
|
||||
{hasProcedure && outParams.length > 0 && (
|
||||
<div className="mt-1 space-y-1 border-t border-border pt-1">
|
||||
<div className="text-[10px] font-medium text-emerald-600">
|
||||
반환 필드:
|
||||
</div>
|
||||
{outParams.map((p) => (
|
||||
<div
|
||||
key={p.name}
|
||||
className="flex items-center justify-between rounded bg-emerald-50 px-2 py-0.5 text-[10px]"
|
||||
>
|
||||
<span className="font-mono text-emerald-700">{p.name}</span>
|
||||
<span className="text-muted-foreground/70">{p.dataType}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 출력 핸들 */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!h-3 !w-3 !border-2 !border-white !bg-violet-500"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
return (
|
||||
<CompactNodeShell
|
||||
color="#8B5CF6"
|
||||
label={data.displayName || "프로시저 호출"}
|
||||
summary={summary}
|
||||
icon={<Database className="h-3.5 w-3.5" />}
|
||||
selected={selected}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
ProcedureCallActionNode.displayName = "ProcedureCallActionNode";
|
||||
|
|
|
|||
|
|
@ -1,80 +1,35 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* REST API 소스 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Globe, Lock } from "lucide-react";
|
||||
import { NodeProps } from "reactflow";
|
||||
import { Globe } from "lucide-react";
|
||||
import { CompactNodeShell } from "./CompactNodeShell";
|
||||
import type { RestAPISourceNodeData } from "@/types/node-editor";
|
||||
|
||||
const METHOD_COLORS: Record<string, string> = {
|
||||
GET: "bg-emerald-100 text-emerald-700",
|
||||
POST: "bg-primary/10 text-primary",
|
||||
PUT: "bg-amber-100 text-yellow-700",
|
||||
DELETE: "bg-destructive/10 text-destructive",
|
||||
PATCH: "bg-purple-100 text-purple-700",
|
||||
};
|
||||
|
||||
export const RestAPISourceNode = memo(({ data, selected }: NodeProps<RestAPISourceNodeData>) => {
|
||||
const methodColor = METHOD_COLORS[data.method] || "bg-muted text-foreground";
|
||||
const method = data.method || "GET";
|
||||
const summary = data.url
|
||||
? `${method} ${data.url}`
|
||||
: "API URL을 입력해 주세요";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-orange-500 shadow-lg" : "border-border"
|
||||
}`}
|
||||
<CompactNodeShell
|
||||
color="#10B981"
|
||||
label={data.displayName || "REST API"}
|
||||
summary={summary}
|
||||
icon={<Globe className="h-3.5 w-3.5" />}
|
||||
selected={selected}
|
||||
hasInput={false}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-teal-600 px-3 py-2 text-white">
|
||||
<Globe className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || "REST API"}</div>
|
||||
<div className="text-xs opacity-80">{data.url || "URL 미설정"}</div>
|
||||
{data.url && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="rounded bg-emerald-500/20 px-1 py-0.5 font-mono text-[9px] font-semibold text-emerald-400">
|
||||
{method}
|
||||
</span>
|
||||
<span className="break-all font-mono">{data.url}</span>
|
||||
</div>
|
||||
{data.authentication && <Lock className="h-4 w-4 opacity-70" />}
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
{/* HTTP 메서드 */}
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className={`rounded px-2 py-1 text-xs font-semibold ${methodColor}`}>{data.method}</span>
|
||||
{data.timeout && <span className="text-xs text-muted-foreground">{data.timeout}ms</span>}
|
||||
</div>
|
||||
|
||||
{/* 헤더 */}
|
||||
{data.headers && Object.keys(data.headers).length > 0 && (
|
||||
<div className="mb-2">
|
||||
<div className="text-xs font-medium text-foreground">헤더:</div>
|
||||
<div className="mt-1 space-y-1">
|
||||
{Object.entries(data.headers)
|
||||
.slice(0, 2)
|
||||
.map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{key}:</span>
|
||||
<span className="truncate text-muted-foreground">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
{Object.keys(data.headers).length > 2 && (
|
||||
<div className="text-xs text-muted-foreground/70">... 외 {Object.keys(data.headers).length - 2}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 응답 매핑 */}
|
||||
{data.responseMapping && (
|
||||
<div className="rounded bg-teal-50 px-2 py-1 text-xs text-teal-700">
|
||||
응답 경로: <code className="font-mono">{data.responseMapping}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 핸들 */}
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-teal-500" />
|
||||
</div>
|
||||
)}
|
||||
</CompactNodeShell>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,118 +1,31 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 스크립트 실행 액션 노드
|
||||
* Python, Shell, PowerShell 등 외부 스크립트를 실행하는 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Terminal, FileCode, Play } from "lucide-react";
|
||||
import type { ScriptActionNodeData } from "@/types/node-editor";
|
||||
import { NodeProps } from "reactflow";
|
||||
import { Terminal } from "lucide-react";
|
||||
import { CompactNodeShell } from "./CompactNodeShell";
|
||||
|
||||
// 스크립트 타입별 아이콘 색상
|
||||
const SCRIPT_TYPE_COLORS: Record<string, { bg: string; text: string; label: string }> = {
|
||||
python: { bg: "bg-amber-100", text: "text-yellow-700", label: "Python" },
|
||||
shell: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Shell" },
|
||||
powershell: { bg: "bg-primary/10", text: "text-primary", label: "PowerShell" },
|
||||
node: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Node.js" },
|
||||
executable: { bg: "bg-muted", text: "text-foreground", label: "실행파일" },
|
||||
};
|
||||
|
||||
export const ScriptActionNode = memo(({ data, selected }: NodeProps<ScriptActionNodeData>) => {
|
||||
const scriptTypeInfo = SCRIPT_TYPE_COLORS[data.scriptType] || SCRIPT_TYPE_COLORS.executable;
|
||||
const hasScript = data.executionMode === "inline" ? !!data.inlineScript : !!data.scriptPath;
|
||||
export const ScriptActionNode = memo(({ data, selected }: NodeProps<any>) => {
|
||||
const scriptType = data.scriptType || "python";
|
||||
const summary = data.inlineScript
|
||||
? `${scriptType} 스크립트 (${data.inlineScript.split("\n").length}줄)`
|
||||
: "스크립트를 작성해 주세요";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-emerald-500 shadow-lg" : "border-border"
|
||||
}`}
|
||||
<CompactNodeShell
|
||||
color="#10B981"
|
||||
label={data.displayName || "스크립트 실행"}
|
||||
summary={summary}
|
||||
icon={<Terminal className="h-3.5 w-3.5" />}
|
||||
selected={selected}
|
||||
>
|
||||
{/* 입력 핸들 */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!h-3 !w-3 !border-2 !border-white !bg-emerald-500"
|
||||
/>
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-emerald-500 px-3 py-2 text-white">
|
||||
<Terminal className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || "스크립트 실행"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="space-y-2 p-3">
|
||||
{/* 스크립트 타입 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`rounded px-2 py-0.5 text-xs font-medium ${scriptTypeInfo.bg} ${scriptTypeInfo.text}`}>
|
||||
{scriptTypeInfo.label}
|
||||
</span>
|
||||
<span className="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{data.executionMode === "inline" ? "인라인" : "파일"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 스크립트 정보 */}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{data.executionMode === "inline" ? (
|
||||
<>
|
||||
<FileCode className="h-3 w-3 text-muted-foreground/70" />
|
||||
<span className="text-muted-foreground">
|
||||
{hasScript ? (
|
||||
<span className="text-emerald-600">
|
||||
{data.inlineScript!.split("\n").length}줄 스크립트
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-amber-500">스크립트 입력 필요</span>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-3 w-3 text-muted-foreground/70" />
|
||||
<span className="text-muted-foreground">
|
||||
{hasScript ? (
|
||||
<span className="truncate text-emerald-600">{data.scriptPath}</span>
|
||||
) : (
|
||||
<span className="text-amber-500">파일 경로 필요</span>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 입력 방식 */}
|
||||
<div className="text-xs">
|
||||
<span className="text-muted-foreground">입력: </span>
|
||||
<span className="text-foreground">
|
||||
{data.inputMethod === "stdin" && "표준입력 (stdin)"}
|
||||
{data.inputMethod === "args" && "명령줄 인자"}
|
||||
{data.inputMethod === "env" && "환경변수"}
|
||||
{data.inputMethod === "file" && "파일"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 타임아웃 */}
|
||||
{data.options?.timeout && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
타임아웃: {Math.round(data.options.timeout / 1000)}초
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 출력 핸들 */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!h-3 !w-3 !border-2 !border-white !bg-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
{data.scriptType && (
|
||||
<span className="rounded bg-emerald-500/20 px-1 py-0.5 font-mono text-[9px] font-semibold text-emerald-400">
|
||||
{scriptType}
|
||||
</span>
|
||||
)}
|
||||
</CompactNodeShell>
|
||||
);
|
||||
});
|
||||
|
||||
ScriptActionNode.displayName = "ScriptActionNode";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,69 +1,40 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 테이블 소스 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { NodeProps } from "reactflow";
|
||||
import { Database } from "lucide-react";
|
||||
import { CompactNodeShell } from "./CompactNodeShell";
|
||||
import type { TableSourceNodeData } from "@/types/node-editor";
|
||||
|
||||
export const TableSourceNode = memo(({ data, selected }: NodeProps<TableSourceNodeData>) => {
|
||||
// 디버깅: 필드 데이터 확인
|
||||
if (data.fields && data.fields.length > 0) {
|
||||
console.log("🔍 TableSource 필드 데이터:", data.fields);
|
||||
}
|
||||
const fieldCount = data.fields?.length || 0;
|
||||
const summary = data.tableName
|
||||
? `${data.tableName} (${fieldCount}개 필드)`
|
||||
: "테이블을 선택해 주세요";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-primary shadow-lg" : "border-border"
|
||||
}`}
|
||||
<CompactNodeShell
|
||||
color="#3B82F6"
|
||||
label={data.displayName || data.tableName || "테이블 소스"}
|
||||
summary={summary}
|
||||
icon={<Database className="h-3.5 w-3.5" />}
|
||||
selected={selected}
|
||||
hasInput={false}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-primary px-3 py-2 text-white">
|
||||
<Database className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || data.tableName || "테이블 소스"}</div>
|
||||
{data.tableName && data.displayName !== data.tableName && (
|
||||
<div className="text-xs opacity-80">{data.tableName}</div>
|
||||
{fieldCount > 0 && (
|
||||
<div className="space-y-0.5">
|
||||
{data.fields!.slice(0, 4).map((f) => (
|
||||
<div key={f.name} className="flex items-center gap-1.5">
|
||||
<div className="h-1 w-1 flex-shrink-0 rounded-full bg-blue-400" />
|
||||
<span>{f.label || f.displayName || f.name}</span>
|
||||
</div>
|
||||
))}
|
||||
{fieldCount > 4 && (
|
||||
<span className="text-zinc-600">외 {fieldCount - 4}개</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">📍 내부 데이터베이스</div>
|
||||
|
||||
{/* 필드 목록 */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-foreground">출력 필드:</div>
|
||||
<div className="max-h-[150px] overflow-y-auto">
|
||||
{data.fields && data.fields.length > 0 ? (
|
||||
data.fields.slice(0, 5).map((field) => (
|
||||
<div key={field.name} className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-primary/70" />
|
||||
<span className="font-medium">{field.label || field.displayName || field.name}</span>
|
||||
{(field.label || field.displayName) && field.label !== field.name && (
|
||||
<span className="font-mono text-muted-foreground/70">({field.name})</span>
|
||||
)}
|
||||
<span className="text-muted-foreground/70">{field.type}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground/70">필드 없음</div>
|
||||
)}
|
||||
{data.fields && data.fields.length > 5 && (
|
||||
<div className="text-xs text-muted-foreground/70">... 외 {data.fields.length - 5}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 출력 핸들 */}
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !border-2 !border-primary !bg-white" />
|
||||
</div>
|
||||
)}
|
||||
</CompactNodeShell>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,97 +1,26 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* UPDATE 액션 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Edit } from "lucide-react";
|
||||
import { NodeProps } from "reactflow";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { CompactNodeShell } from "./CompactNodeShell";
|
||||
import type { UpdateActionNodeData } from "@/types/node-editor";
|
||||
|
||||
export const UpdateActionNode = memo(({ data, selected }: NodeProps<UpdateActionNodeData>) => {
|
||||
const mappingCount = data.fieldMappings?.length || 0;
|
||||
const whereCount = data.whereConditions?.length || 0;
|
||||
const summary = data.targetTable
|
||||
? `${data.targetTable} (${mappingCount}개 필드, ${whereCount}개 조건)`
|
||||
: "대상 테이블을 선택해 주세요";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-primary shadow-lg" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{/* 입력 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !border-2 !border-primary !bg-white" />
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-primary px-3 py-2 text-white">
|
||||
<Edit className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">UPDATE</div>
|
||||
<div className="text-xs opacity-80">{data.displayName || data.targetTable}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
타겟: {data.displayName || data.targetTable}
|
||||
{data.targetTable && data.displayName && data.displayName !== data.targetTable && (
|
||||
<span className="ml-1 font-mono text-muted-foreground/70">({data.targetTable})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* WHERE 조건 */}
|
||||
{data.whereConditions && data.whereConditions.length > 0 && (
|
||||
<div className="mb-3 space-y-1">
|
||||
<div className="text-xs font-medium text-foreground">WHERE 조건:</div>
|
||||
<div className="max-h-[80px] space-y-1 overflow-y-auto">
|
||||
{data.whereConditions.slice(0, 2).map((condition, idx) => (
|
||||
<div key={idx} className="rounded bg-primary/10 px-2 py-1 text-xs">
|
||||
<span className="font-mono text-foreground">{condition.fieldLabel || condition.field}</span>
|
||||
<span className="mx-1 text-primary">{condition.operator}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{condition.sourceFieldLabel || condition.sourceField || condition.staticValue || "?"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{data.whereConditions.length > 2 && (
|
||||
<div className="text-xs text-muted-foreground/70">... 외 {data.whereConditions.length - 2}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 필드 매핑 */}
|
||||
{data.fieldMappings && data.fieldMappings.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-foreground">업데이트 필드:</div>
|
||||
<div className="max-h-[100px] space-y-1 overflow-y-auto">
|
||||
{data.fieldMappings.slice(0, 3).map((mapping, idx) => (
|
||||
<div key={idx} className="rounded bg-muted px-2 py-1 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{mapping.sourceFieldLabel || mapping.sourceField || mapping.staticValue || "?"}
|
||||
</span>
|
||||
<span className="mx-1 text-muted-foreground/70">→</span>
|
||||
<span className="font-mono text-foreground">{mapping.targetFieldLabel || mapping.targetField}</span>
|
||||
</div>
|
||||
))}
|
||||
{data.fieldMappings.length > 3 && (
|
||||
<div className="text-xs text-muted-foreground/70">... 외 {data.fieldMappings.length - 3}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 옵션 */}
|
||||
{data.options && data.options.batchSize && (
|
||||
<div className="mt-2">
|
||||
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-xs text-primary">
|
||||
배치 {data.options.batchSize}건
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 출력 핸들 */}
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !border-2 !border-primary !bg-white" />
|
||||
</div>
|
||||
<CompactNodeShell
|
||||
color="#3B82F6"
|
||||
label={data.displayName || "UPDATE"}
|
||||
summary={summary}
|
||||
icon={<Pencil className="h-3.5 w-3.5" />}
|
||||
selected={selected}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,93 +1,26 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* UPSERT 액션 노드
|
||||
* INSERT와 UPDATE를 결합한 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Database, RefreshCw } from "lucide-react";
|
||||
import { NodeProps } from "reactflow";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { CompactNodeShell } from "./CompactNodeShell";
|
||||
import type { UpsertActionNodeData } from "@/types/node-editor";
|
||||
|
||||
export const UpsertActionNode = memo(({ data, selected }: NodeProps<UpsertActionNodeData>) => {
|
||||
const mappingCount = data.fieldMappings?.length || 0;
|
||||
const conflictCount = data.conflictKeys?.length || 0;
|
||||
const summary = data.targetTable
|
||||
? `${data.targetTable} (${mappingCount}개 필드, ${conflictCount}개 키)`
|
||||
: "대상 테이블을 선택해 주세요";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-orange-500 shadow-lg" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-purple-600 px-3 py-2 text-white">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || "UPSERT 액션"}</div>
|
||||
<div className="text-xs opacity-80">{data.targetTable}</div>
|
||||
</div>
|
||||
<Database className="h-4 w-4 opacity-70" />
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
타겟: {data.displayName || data.targetTable}
|
||||
{data.targetTable && data.displayName && data.displayName !== data.targetTable && (
|
||||
<span className="ml-1 font-mono text-muted-foreground/70">({data.targetTable})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 충돌 키 */}
|
||||
{data.conflictKeys && data.conflictKeys.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<div className="text-xs font-medium text-foreground">충돌 키:</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{data.conflictKeys.map((key, idx) => (
|
||||
<span key={idx} className="rounded bg-purple-100 px-2 py-0.5 text-xs text-purple-700">
|
||||
{data.conflictKeyLabels?.[idx] || key}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 필드 매핑 */}
|
||||
{data.fieldMappings && data.fieldMappings.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<div className="text-xs font-medium text-foreground">필드 매핑:</div>
|
||||
<div className="mt-1 space-y-1">
|
||||
{data.fieldMappings.slice(0, 3).map((mapping, idx) => (
|
||||
<div key={idx} className="rounded bg-muted px-2 py-1 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{mapping.sourceFieldLabel || mapping.sourceField || mapping.staticValue || "?"}
|
||||
</span>
|
||||
<span className="mx-1 text-muted-foreground/70">→</span>
|
||||
<span className="font-mono text-foreground">{mapping.targetFieldLabel || mapping.targetField}</span>
|
||||
</div>
|
||||
))}
|
||||
{data.fieldMappings.length > 3 && (
|
||||
<div className="text-xs text-muted-foreground/70">... 외 {data.fieldMappings.length - 3}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 옵션 */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{data.options?.updateOnConflict && (
|
||||
<span className="rounded bg-primary/10 px-2 py-0.5 text-xs text-primary">충돌 시 업데이트</span>
|
||||
)}
|
||||
{data.options?.batchSize && (
|
||||
<span className="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||
배치: {data.options.batchSize}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !bg-purple-500" />
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-purple-500" />
|
||||
</div>
|
||||
<CompactNodeShell
|
||||
color="#8B5CF6"
|
||||
label={data.displayName || "UPSERT"}
|
||||
summary={summary}
|
||||
icon={<RefreshCw className="h-3.5 w-3.5" />}
|
||||
selected={selected}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@
|
|||
* 노드 속성 편집 패널
|
||||
*/
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { TableSourceProperties } from "./properties/TableSourceProperties";
|
||||
import { InsertActionProperties } from "./properties/InsertActionProperties";
|
||||
|
|
@ -29,70 +27,32 @@ import type { NodeType } from "@/types/node-editor";
|
|||
export function PropertiesPanel() {
|
||||
const { nodes, selectedNodes, setShowPropertiesPanel } = useFlowEditorStore();
|
||||
|
||||
// 선택된 노드가 하나일 경우 해당 노드 데이터 가져오기
|
||||
const selectedNode = selectedNodes.length === 1 ? nodes.find((n) => n.id === selectedNodes[0]) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
height: "64px",
|
||||
}}
|
||||
className="flex items-center justify-between border-b bg-white p-4"
|
||||
>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">속성</h3>
|
||||
{selectedNode && (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{getNodeTypeLabel(selectedNode.type as NodeType)}</p>
|
||||
)}
|
||||
if (selectedNodes.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-4">
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
<p>노드를 선택해 주세요</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowPropertiesPanel(false)} className="h-6 w-6 p-0">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{/* 내용 - 스크롤 가능 영역 */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
}}
|
||||
>
|
||||
{selectedNodes.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center p-4">
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
<div className="mb-2 text-2xl">📝</div>
|
||||
<p>노드를 선택하여</p>
|
||||
<p>속성을 편집하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
) : selectedNodes.length === 1 && selectedNode ? (
|
||||
<NodePropertiesRenderer node={selectedNode} />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center p-4">
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
<div className="mb-2 text-2xl">📋</div>
|
||||
<p>{selectedNodes.length}개의 노드가</p>
|
||||
<p>선택되었습니다</p>
|
||||
<p className="mt-2 text-xs">한 번에 하나의 노드만 편집할 수 있습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
if (selectedNodes.length > 1) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-4">
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
<p>{selectedNodes.length}개의 노드가 선택됐어요</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">하나의 노드만 선택하면 속성을 편집할 수 있어요</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedNode) return null;
|
||||
|
||||
return <NodePropertiesRenderer node={selectedNode} />;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -155,14 +115,10 @@ function NodePropertiesRenderer({ node }: { node: any }) {
|
|||
return (
|
||||
<div className="p-4">
|
||||
<div className="rounded border border-amber-200 bg-amber-50 p-4 text-sm">
|
||||
<p className="font-medium text-yellow-800">🚧 속성 편집 준비 중</p>
|
||||
<p className="mt-2 text-xs text-yellow-700">
|
||||
{getNodeTypeLabel(node.type as NodeType)} 노드의 속성 편집 UI는 곧 구현될 예정입니다.
|
||||
<p className="font-medium text-amber-800">속성 편집 준비 중이에요</p>
|
||||
<p className="mt-2 text-xs text-amber-700">
|
||||
{getNodeTypeLabel(node.type as NodeType)} 노드의 속성 편집은 곧 지원될 예정이에요.
|
||||
</p>
|
||||
<div className="mt-3 rounded bg-white p-2 text-xs">
|
||||
<p className="font-medium text-foreground">노드 ID:</p>
|
||||
<p className="font-mono text-muted-foreground">{node.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -89,6 +89,24 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
|||
// 자동화 관리
|
||||
"/admin/automaticMng/flowMgmtList": dynamic(() => import("@/app/(main)/admin/automaticMng/flowMgmtList/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/automaticMng/batchmngList": dynamic(() => import("@/app/(main)/admin/automaticMng/batchmngList/page"), { ssr: false, loading: LoadingFallback }),
|
||||
|
||||
// 설계 관리 (커스텀 페이지)
|
||||
"/design/task-management": dynamic(() => import("@/app/(main)/design/task-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/design/my-work": dynamic(() => import("@/app/(main)/design/my-work/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/design/design-request": dynamic(() => import("@/app/(main)/design/design-request/page"), { ssr: false, loading: LoadingFallback }),
|
||||
|
||||
// 영업 관리 (커스텀 페이지)
|
||||
"/sales/shipping-plan": dynamic(() => import("@/app/(main)/sales/shipping-plan/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/sales/shipping-order": dynamic(() => import("@/app/(main)/sales/shipping-order/page"), { ssr: false, loading: LoadingFallback }),
|
||||
|
||||
// 생산 관리 (커스텀 페이지)
|
||||
"/production/work-instruction": dynamic(() => import("@/app/(main)/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }),
|
||||
|
||||
// 물류 관리 (커스텀 페이지)
|
||||
"/logistics/material-status": dynamic(() => import("@/app/(main)/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
|
||||
|
||||
// 설계 관리 (커스텀 페이지)
|
||||
"/design/change-management": dynamic(() => import("@/app/(main)/design/change-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/automaticMng/exconList": dynamic(() => import("@/app/(main)/admin/automaticMng/exconList/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/automaticMng/exCallConfList": dynamic(() => import("@/app/(main)/admin/automaticMng/exCallConfList/page"), { ssr: false, loading: LoadingFallback }),
|
||||
|
||||
|
|
|
|||
|
|
@ -428,6 +428,14 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||
return;
|
||||
}
|
||||
|
||||
// 4) 커스텀 페이지 URL (React 직접 구현 페이지) → admin 탭으로 렌더링
|
||||
if (menu.url && menu.url !== "#" && !menu.url.startsWith("/screen/") && !menu.url.startsWith("/screens/")) {
|
||||
console.log("[handleMenuClick] → 커스텀 페이지 탭:", menu.url);
|
||||
openTab({ type: "admin", title: menuName, adminUrl: menu.url });
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn("[handleMenuClick] 어떤 조건에도 매칭 안 됨:", { menuName, menuType: menu.menuType, url: menu.url, screenId: menu.screenId });
|
||||
toast.warning("이 메뉴에 할당된 화면이 없습니다. 메뉴 설정을 확인해주세요.");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,16 +5,29 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Plus, Save, ListOrdered } from "lucide-react";
|
||||
import { Plus, Save, Search, Hash, Table2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||
import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule";
|
||||
import { CODE_PART_TYPE_OPTIONS } from "@/types/numbering-rule";
|
||||
import { NumberingRuleCard } from "./NumberingRuleCard";
|
||||
import { NumberingRulePreview, computePartDisplayItems, getPartTypeColorClass } from "./NumberingRulePreview";
|
||||
import { getNumberingRules, saveNumberingRuleToTest } from "@/lib/api/numberingRule";
|
||||
import { saveNumberingRuleToTest } from "@/lib/api/numberingRule";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NumberingColumn {
|
||||
tableName: string;
|
||||
tableLabel: string;
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
}
|
||||
|
||||
interface GroupedColumns {
|
||||
tableLabel: string;
|
||||
columns: NumberingColumn[];
|
||||
}
|
||||
|
||||
interface NumberingRuleDesignerProps {
|
||||
initialConfig?: NumberingRuleConfig;
|
||||
onSave?: (config: NumberingRuleConfig) => void;
|
||||
|
|
@ -36,64 +49,95 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
currentTableName,
|
||||
menuObjid,
|
||||
}) => {
|
||||
const [rulesList, setRulesList] = useState<NumberingRuleConfig[]>([]);
|
||||
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
|
||||
const [numberingColumns, setNumberingColumns] = useState<NumberingColumn[]>([]);
|
||||
const [selectedColumn, setSelectedColumn] = useState<{ tableName: string; columnName: string } | null>(null);
|
||||
const [currentRule, setCurrentRule] = useState<NumberingRuleConfig | null>(null);
|
||||
const [selectedPartOrder, setSelectedPartOrder] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [columnSearch, setColumnSearch] = useState("");
|
||||
const [separatorTypes, setSeparatorTypes] = useState<Record<number, SeparatorType>>({});
|
||||
const [customSeparators, setCustomSeparators] = useState<Record<number, string>>({});
|
||||
|
||||
const selectedRule = rulesList.find((r) => r.ruleId === selectedRuleId) ?? currentRule;
|
||||
|
||||
// 좌측: 규칙 목록 로드
|
||||
useEffect(() => {
|
||||
loadRules();
|
||||
loadNumberingColumns();
|
||||
}, []);
|
||||
|
||||
const loadRules = async () => {
|
||||
const loadNumberingColumns = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getNumberingRules();
|
||||
if (response.success && response.data) {
|
||||
setRulesList(response.data);
|
||||
if (response.data.length > 0 && !selectedRuleId) {
|
||||
const first = response.data[0];
|
||||
setSelectedRuleId(first.ruleId);
|
||||
setCurrentRule(JSON.parse(JSON.stringify(first)));
|
||||
}
|
||||
const response = await apiClient.get("/table-management/numbering-columns");
|
||||
if (response.data.success && response.data.data) {
|
||||
setNumberingColumns(response.data.data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("채번 규칙 목록 로드 실패:", e);
|
||||
} catch (error: any) {
|
||||
console.error("채번 컬럼 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectRule = (rule: NumberingRuleConfig) => {
|
||||
setSelectedRuleId(rule.ruleId);
|
||||
setCurrentRule(JSON.parse(JSON.stringify(rule)));
|
||||
const handleSelectColumn = async (tableName: string, columnName: string) => {
|
||||
setSelectedColumn({ tableName, columnName });
|
||||
setSelectedPartOrder(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.get(`/numbering-rules/by-column/${tableName}/${columnName}`);
|
||||
if (response.data.success && response.data.data) {
|
||||
const rule = response.data.data as NumberingRuleConfig;
|
||||
setCurrentRule(JSON.parse(JSON.stringify(rule)));
|
||||
} else {
|
||||
const newRule: NumberingRuleConfig = {
|
||||
ruleId: `rule-${Date.now()}`,
|
||||
ruleName: `${columnName} 채번`,
|
||||
parts: [],
|
||||
separator: "-",
|
||||
resetPeriod: "none",
|
||||
currentSequence: 1,
|
||||
scopeType: "table",
|
||||
tableName,
|
||||
columnName,
|
||||
};
|
||||
setCurrentRule(newRule);
|
||||
}
|
||||
} catch {
|
||||
const newRule: NumberingRuleConfig = {
|
||||
ruleId: `rule-${Date.now()}`,
|
||||
ruleName: `${columnName} 채번`,
|
||||
parts: [],
|
||||
separator: "-",
|
||||
resetPeriod: "none",
|
||||
currentSequence: 1,
|
||||
scopeType: "table",
|
||||
tableName,
|
||||
columnName,
|
||||
};
|
||||
setCurrentRule(newRule);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddNewRule = () => {
|
||||
const newRule: NumberingRuleConfig = {
|
||||
ruleId: `rule-${Date.now()}`,
|
||||
ruleName: "새 규칙",
|
||||
parts: [],
|
||||
separator: "-",
|
||||
resetPeriod: "none",
|
||||
currentSequence: 1,
|
||||
scopeType: "global",
|
||||
tableName: currentTableName ?? "",
|
||||
columnName: "",
|
||||
};
|
||||
setRulesList((prev) => [...prev, newRule]);
|
||||
setSelectedRuleId(newRule.ruleId);
|
||||
setCurrentRule(JSON.parse(JSON.stringify(newRule)));
|
||||
setSelectedPartOrder(null);
|
||||
toast.success("새 규칙이 추가되었습니다");
|
||||
};
|
||||
// 테이블별 그룹화
|
||||
const groupedColumns = numberingColumns.reduce<Record<string, GroupedColumns>>((acc, col) => {
|
||||
if (!acc[col.tableName]) {
|
||||
acc[col.tableName] = { tableLabel: col.tableLabel, columns: [] };
|
||||
}
|
||||
acc[col.tableName].columns.push(col);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// 검색 필터
|
||||
const filteredGroups = Object.entries(groupedColumns).filter(([tableName, group]) => {
|
||||
if (!columnSearch) return true;
|
||||
const search = columnSearch.toLowerCase();
|
||||
return (
|
||||
tableName.toLowerCase().includes(search) ||
|
||||
group.tableLabel.toLowerCase().includes(search) ||
|
||||
group.columns.some(
|
||||
(c) => c.columnName.toLowerCase().includes(search) || c.columnLabel.toLowerCase().includes(search)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (currentRule) onChange?.(currentRule);
|
||||
|
|
@ -225,24 +269,14 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
const ruleToSave = {
|
||||
...currentRule,
|
||||
parts: partsWithDefaults,
|
||||
scopeType: "global" as const,
|
||||
tableName: currentRule.tableName || currentTableName || "",
|
||||
columnName: currentRule.columnName || "",
|
||||
scopeType: "table" as const,
|
||||
tableName: selectedColumn?.tableName || currentRule.tableName || "",
|
||||
columnName: selectedColumn?.columnName || currentRule.columnName || "",
|
||||
};
|
||||
const response = await saveNumberingRuleToTest(ruleToSave);
|
||||
if (response.success && response.data) {
|
||||
const saved: NumberingRuleConfig = JSON.parse(JSON.stringify(response.data));
|
||||
setCurrentRule(saved);
|
||||
setRulesList((prev) => {
|
||||
const idx = prev.findIndex((r) => r.ruleId === currentRule.ruleId);
|
||||
if (idx >= 0) {
|
||||
const next = [...prev];
|
||||
next[idx] = saved;
|
||||
return next;
|
||||
}
|
||||
return [...prev, saved];
|
||||
});
|
||||
setSelectedRuleId(saved.ruleId);
|
||||
const currentData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig;
|
||||
setCurrentRule(currentData);
|
||||
await onSave?.(response.data);
|
||||
toast.success("채번 규칙이 저장되었습니다");
|
||||
} else {
|
||||
|
|
@ -257,7 +291,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentRule, onSave, currentTableName]);
|
||||
}, [currentRule, onSave, selectedColumn]);
|
||||
|
||||
const selectedPart = currentRule?.parts.find((p) => p.order === selectedPartOrder) ?? null;
|
||||
const globalSep = currentRule?.separator ?? "-";
|
||||
|
|
@ -265,92 +299,118 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
|
||||
return (
|
||||
<div className={cn("flex h-full", className)}>
|
||||
{/* 좌측: 규칙 리스트 (code-nav, 220px) */}
|
||||
<div className="code-nav flex w-[220px] flex-shrink-0 flex-col border-r border-border">
|
||||
<div className="code-nav-head flex items-center justify-between gap-2 border-b border-border px-3 py-2.5">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<ListOrdered className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-xs font-bold">채번 규칙 ({rulesList.length})</span>
|
||||
{/* 좌측: 채번 컬럼 목록 (테이블별 그룹화) */}
|
||||
<div className="code-nav flex w-[240px] flex-shrink-0 flex-col border-r border-border">
|
||||
<div className="code-nav-head flex flex-col gap-2 border-b border-border px-3 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Hash className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="text-xs font-bold">채번 컬럼 ({numberingColumns.length})</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={columnSearch}
|
||||
onChange={(e) => setColumnSearch(e.target.value)}
|
||||
placeholder="검색..."
|
||||
className="h-7 pl-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-8 shrink-0 gap-1 text-xs font-medium"
|
||||
onClick={handleAddNewRule}
|
||||
disabled={isPreview || loading}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
<div className="code-nav-list flex-1 overflow-y-auto">
|
||||
{loading && rulesList.length === 0 ? (
|
||||
{loading && numberingColumns.length === 0 ? (
|
||||
<div className="flex h-24 items-center justify-center text-xs text-muted-foreground">
|
||||
로딩 중...
|
||||
</div>
|
||||
) : rulesList.length === 0 ? (
|
||||
<div className="flex h-24 items-center justify-center rounded-lg border border-dashed border-border bg-muted/50 text-xs text-muted-foreground">
|
||||
규칙이 없습니다
|
||||
) : filteredGroups.length === 0 ? (
|
||||
<div className="flex h-24 flex-col items-center justify-center gap-1 px-3 text-center text-xs text-muted-foreground">
|
||||
<Hash className="h-6 w-6" />
|
||||
{numberingColumns.length === 0
|
||||
? "채번 타입 컬럼이 없습니다"
|
||||
: "검색 결과 없음"}
|
||||
</div>
|
||||
) : (
|
||||
rulesList.map((rule) => {
|
||||
const isSelected = selectedRuleId === rule.ruleId;
|
||||
return (
|
||||
<button
|
||||
key={rule.ruleId}
|
||||
type="button"
|
||||
className={cn(
|
||||
"code-nav-item flex w-full items-center gap-2 border-b border-border/50 px-3 py-2 text-left transition-colors",
|
||||
isSelected
|
||||
? "border-l-[3px] border-primary bg-primary/5 pl-2.5 font-bold"
|
||||
: "hover:bg-accent"
|
||||
)}
|
||||
onClick={() => handleSelectRule(rule)}
|
||||
>
|
||||
<span className="rule-name min-w-0 flex-1 truncate text-xs font-semibold">
|
||||
{rule.ruleName}
|
||||
filteredGroups.map(([tableName, group]) => (
|
||||
<div key={tableName}>
|
||||
<div className="flex items-center gap-1.5 bg-muted/50 px-3 py-1.5">
|
||||
<Table2 className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="truncate text-[10px] font-bold text-muted-foreground">
|
||||
{group.tableLabel || tableName}
|
||||
</span>
|
||||
<span className="rule-table max-w-[70px] shrink-0 truncate text-[9px] text-muted-foreground">
|
||||
{rule.tableName || "-"}
|
||||
</span>
|
||||
<span className="rule-parts shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[8px] font-bold text-muted-foreground">
|
||||
{rule.parts?.length ?? 0}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
</div>
|
||||
{group.columns.map((col) => {
|
||||
const isSelected =
|
||||
selectedColumn?.tableName === col.tableName &&
|
||||
selectedColumn?.columnName === col.columnName;
|
||||
return (
|
||||
<button
|
||||
key={`${col.tableName}.${col.columnName}`}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 border-b border-border/30 px-3 py-2 text-left transition-colors",
|
||||
isSelected
|
||||
? "border-l-[3px] border-l-primary bg-primary/5 pl-2.5 font-bold"
|
||||
: "pl-5 hover:bg-accent"
|
||||
)}
|
||||
onClick={() => handleSelectColumn(col.tableName, col.columnName)}
|
||||
>
|
||||
<Hash className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-semibold">
|
||||
{col.columnLabel || col.columnName}
|
||||
</div>
|
||||
<div className="truncate text-[9px] text-muted-foreground">
|
||||
{col.columnName}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측: 미리보기 + 파이프라인 + 설정 + 저장 바 (code-main) */}
|
||||
{/* 우측: 미리보기 + 파이프라인 + 설정 + 저장 바 */}
|
||||
<div className="code-main flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{!currentRule ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center text-center">
|
||||
<ListOrdered className="mb-3 h-10 w-10 text-muted-foreground" />
|
||||
<p className="mb-2 text-lg font-medium text-muted-foreground">규칙을 선택하세요</p>
|
||||
<Hash className="mb-3 h-10 w-10 text-muted-foreground" />
|
||||
<p className="mb-2 text-lg font-medium text-muted-foreground">컬럼을 선택하세요</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
좌측에서 채번 규칙을 선택하거나 "추가"로 새 규칙을 만드세요
|
||||
좌측에서 채번 컬럼을 선택하면 규칙을 편집할 수 있습니다
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 헤더: 규칙명 + 적용 대상 표시 */}
|
||||
<div className="flex flex-col gap-2 px-6 pt-4">
|
||||
<Label className="text-xs font-medium">규칙명</Label>
|
||||
<Input
|
||||
value={currentRule.ruleName}
|
||||
onChange={(e) => setCurrentRule((prev) => (prev ? { ...prev, ruleName: e.target.value } : null))}
|
||||
placeholder="예: 프로젝트 코드"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs font-medium">규칙명</Label>
|
||||
<Input
|
||||
value={currentRule.ruleName}
|
||||
onChange={(e) => setCurrentRule((prev) => (prev ? { ...prev, ruleName: e.target.value } : null))}
|
||||
placeholder="예: 프로젝트 코드"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{selectedColumn && (
|
||||
<div className="flex-shrink-0 pt-4">
|
||||
<span className="rounded bg-muted px-2 py-1 text-[10px] font-medium text-muted-foreground">
|
||||
{selectedColumn.tableName}.{selectedColumn.columnName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 큰 미리보기 스트립 (code-preview-strip) */}
|
||||
{/* 미리보기 스트립 */}
|
||||
<div className="code-preview-strip flex-shrink-0 border-b border-border px-6 py-5">
|
||||
<NumberingRulePreview config={currentRule} variant="strip" />
|
||||
</div>
|
||||
|
||||
{/* 파이프라인 영역 (code-pipeline-area) */}
|
||||
{/* 파이프라인 영역 */}
|
||||
<div className="code-pipeline-area flex flex-col gap-3 border-b border-border px-6 py-5">
|
||||
<div className="area-label flex items-center gap-1.5">
|
||||
<span className="text-xs font-bold">코드 구성</span>
|
||||
|
|
@ -360,15 +420,21 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
</div>
|
||||
<div className="code-pipeline flex flex-1 flex-wrap items-center gap-0 overflow-x-auto overflow-y-hidden pb-2">
|
||||
{currentRule.parts.length === 0 ? (
|
||||
<div className="flex h-24 min-w-[200px] items-center justify-center rounded-xl border-2 border-dashed border-border bg-muted/30 text-xs text-muted-foreground">
|
||||
규칙을 추가하여 코드를 구성하세요
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-24 min-w-[200px] flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-border bg-muted/30 text-xs text-muted-foreground transition-colors hover:border-primary hover:bg-primary/5 hover:text-primary"
|
||||
onClick={handleAddPart}
|
||||
disabled={isPreview || loading}
|
||||
>
|
||||
<Plus className="h-6 w-6" />
|
||||
클릭하여 첫 번째 규칙을 추가하세요
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{currentRule.parts.map((part, index) => {
|
||||
const item = partItems.find((i) => i.order === part.order);
|
||||
const sep = part.separatorAfter ?? globalSep;
|
||||
const isSelected = selectedPartOrder === part.order;
|
||||
const isPartSelected = selectedPartOrder === part.order;
|
||||
const typeLabel = CODE_PART_TYPE_OPTIONS.find((o) => o.value === part.partType)?.label ?? part.partType;
|
||||
return (
|
||||
<React.Fragment key={`part-${part.order}-${index}`}>
|
||||
|
|
@ -380,7 +446,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
part.partType === "text" && "border-primary",
|
||||
part.partType === "sequence" && "border-primary",
|
||||
(part.partType === "number" || part.partType === "category" || part.partType === "reference") && "border-border",
|
||||
isSelected && "border-primary bg-primary/5 shadow-md ring-2 ring-primary/30"
|
||||
isPartSelected && "border-primary bg-primary/5 shadow-md ring-2 ring-primary/30"
|
||||
)}
|
||||
onClick={() => setSelectedPartOrder(part.order)}
|
||||
>
|
||||
|
|
@ -416,7 +482,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 설정 패널 (선택된 세그먼트 상세, code-config-panel) */}
|
||||
{/* 설정 패널 */}
|
||||
{selectedPart && (
|
||||
<div className="code-config-panel min-h-0 flex-1 overflow-y-auto px-6 py-5">
|
||||
<div className="code-config-grid grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-3">
|
||||
|
|
@ -460,7 +526,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 저장 바 (code-save-bar) */}
|
||||
{/* 저장 바 */}
|
||||
<div className="code-save-bar flex flex-shrink-0 items-center justify-between gap-4 border-t border-border bg-muted/30 px-6 py-4">
|
||||
<div className="min-w-0 flex-1 text-xs text-muted-foreground">
|
||||
{currentRule.tableName && (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { Toaster as SonnerToaster } from "sonner";
|
||||
|
||||
export function Toaster() {
|
||||
const { theme = "system" } = useTheme();
|
||||
|
||||
return (
|
||||
<SonnerToaster
|
||||
position="bottom-center"
|
||||
theme={theme as "light" | "dark" | "system"}
|
||||
closeButton
|
||||
richColors
|
||||
duration={2500}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: "sonner-toast-snackbar",
|
||||
success: "sonner-toast-success",
|
||||
error: "sonner-toast-error",
|
||||
warning: "sonner-toast-warning",
|
||||
info: "sonner-toast-info",
|
||||
closeButton: "sonner-close-btn",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,6 +3,8 @@
|
|||
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export type BatchExecutionType = "mapping" | "node_flow";
|
||||
|
||||
export interface BatchConfig {
|
||||
id?: number;
|
||||
batch_name: string;
|
||||
|
|
@ -10,14 +12,55 @@ export interface BatchConfig {
|
|||
cron_schedule: string;
|
||||
is_active?: string;
|
||||
company_code?: string;
|
||||
save_mode?: 'INSERT' | 'UPSERT'; // 저장 모드 (기본: INSERT)
|
||||
conflict_key?: string; // UPSERT 시 충돌 기준 컬럼명
|
||||
auth_service_name?: string; // REST API 인증에 사용할 토큰 서비스명
|
||||
save_mode?: 'INSERT' | 'UPSERT';
|
||||
conflict_key?: string;
|
||||
auth_service_name?: string;
|
||||
execution_type?: BatchExecutionType;
|
||||
node_flow_id?: number;
|
||||
node_flow_context?: Record<string, any>;
|
||||
created_date?: Date;
|
||||
created_by?: string;
|
||||
updated_date?: Date;
|
||||
updated_by?: string;
|
||||
batch_mappings?: BatchMapping[];
|
||||
last_status?: string;
|
||||
last_executed_at?: string;
|
||||
last_total_records?: number;
|
||||
}
|
||||
|
||||
export interface NodeFlowInfo {
|
||||
flow_id: number;
|
||||
flow_name: string;
|
||||
description?: string;
|
||||
company_code?: string;
|
||||
node_count: number;
|
||||
}
|
||||
|
||||
export interface BatchStats {
|
||||
totalBatches: number;
|
||||
activeBatches: number;
|
||||
todayExecutions: number;
|
||||
todayFailures: number;
|
||||
prevDayExecutions: number;
|
||||
prevDayFailures: number;
|
||||
}
|
||||
|
||||
export interface SparklineData {
|
||||
hour: string;
|
||||
success: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export interface RecentLog {
|
||||
id: number;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
status: string;
|
||||
total_records: number;
|
||||
success_records: number;
|
||||
failed_records: number;
|
||||
error_message: string | null;
|
||||
duration_ms: number | null;
|
||||
}
|
||||
|
||||
export interface BatchMapping {
|
||||
|
|
@ -48,6 +91,8 @@ export interface BatchConfigFilter {
|
|||
is_active?: string;
|
||||
company_code?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface BatchJob {
|
||||
|
|
@ -95,6 +140,9 @@ export interface BatchMappingRequest {
|
|||
cronSchedule: string;
|
||||
mappings: BatchMapping[];
|
||||
isActive?: boolean;
|
||||
executionType?: BatchExecutionType;
|
||||
nodeFlowId?: number;
|
||||
nodeFlowContext?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
|
|
@ -190,7 +238,7 @@ export class BatchAPI {
|
|||
static async createBatchConfig(data: BatchMappingRequest): Promise<BatchConfig> {
|
||||
try {
|
||||
const response = await apiClient.post<ApiResponse<BatchConfig>>(
|
||||
`/batch-configs`,
|
||||
`/batch-management/batch-configs`,
|
||||
data,
|
||||
);
|
||||
|
||||
|
|
@ -460,7 +508,76 @@ export class BatchAPI {
|
|||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BatchJob export 추가 (이미 위에서 interface로 정의됨)
|
||||
export { BatchJob };
|
||||
/**
|
||||
* 노드 플로우 목록 조회 (배치 설정 시 플로우 선택용)
|
||||
*/
|
||||
static async getNodeFlows(): Promise<NodeFlowInfo[]> {
|
||||
try {
|
||||
const response = await apiClient.get<ApiResponse<NodeFlowInfo[]>>(
|
||||
`/batch-management/node-flows`
|
||||
);
|
||||
if (response.data.success) {
|
||||
return response.data.data || [];
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error("노드 플로우 목록 조회 오류:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 대시보드 통계 조회
|
||||
*/
|
||||
static async getBatchStats(): Promise<BatchStats | null> {
|
||||
try {
|
||||
const response = await apiClient.get<ApiResponse<BatchStats>>(
|
||||
`/batch-management/stats`
|
||||
);
|
||||
if (response.data.success) {
|
||||
return response.data.data || null;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("배치 통계 조회 오류:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치별 최근 24시간 스파크라인 데이터
|
||||
*/
|
||||
static async getBatchSparkline(batchId: number): Promise<SparklineData[]> {
|
||||
try {
|
||||
const response = await apiClient.get<ApiResponse<SparklineData[]>>(
|
||||
`/batch-management/batch-configs/${batchId}/sparkline`
|
||||
);
|
||||
if (response.data.success) {
|
||||
return response.data.data || [];
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error("스파크라인 조회 오류:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치별 최근 실행 로그
|
||||
*/
|
||||
static async getBatchRecentLogs(batchId: number, limit: number = 5): Promise<RecentLog[]> {
|
||||
try {
|
||||
const response = await apiClient.get<ApiResponse<RecentLog[]>>(
|
||||
`/batch-management/batch-configs/${batchId}/recent-logs?limit=${limit}`
|
||||
);
|
||||
if (response.data.success) {
|
||||
return response.data.data || [];
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error("최근 실행 로그 조회 오류:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,330 @@
|
|||
import { apiClient, ApiResponse } from "./client";
|
||||
|
||||
// ============================================
|
||||
// 설계의뢰/설변요청 (DR/ECR)
|
||||
// ============================================
|
||||
|
||||
export async function getDesignRequestList(params?: {
|
||||
source_type?: string;
|
||||
status?: string;
|
||||
priority?: string;
|
||||
search?: string;
|
||||
}): Promise<ApiResponse<any[]>> {
|
||||
try {
|
||||
const res = await apiClient.get("/design/requests", { params });
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDesignRequestDetail(id: string): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
const res = await apiClient.get(`/design/requests/${id}`);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createDesignRequest(data: any): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
const res = await apiClient.post("/design/requests", data);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateDesignRequest(id: string, data: any): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
const res = await apiClient.put(`/design/requests/${id}`, data);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteDesignRequest(id: string): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
const res = await apiClient.delete(`/design/requests/${id}`);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function addRequestHistory(id: string, data: { step: string; history_date: string; user_name: string; description: string }): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
const res = await apiClient.post(`/design/requests/${id}/history`, data);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 설계 프로젝트
|
||||
// ============================================
|
||||
|
||||
export async function getProjectList(params?: {
|
||||
status?: string;
|
||||
search?: string;
|
||||
}): Promise<ApiResponse<any[]>> {
|
||||
try {
|
||||
const res = await apiClient.get("/design/projects", { params });
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProjectDetail(id: string): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
const res = await apiClient.get(`/design/projects/${id}`);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createProject(data: any): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
const res = await apiClient.post("/design/projects", data);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateProject(id: string, data: any): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
const res = await apiClient.put(`/design/projects/${id}`, data);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteProject(id: string): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
const res = await apiClient.delete(`/design/projects/${id}`);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 프로젝트 태스크
|
||||
// ============================================
|
||||
|
||||
export async function getTasksByProject(projectId: string): Promise<ApiResponse<any[]>> {
|
||||
try {
|
||||
const res = await apiClient.get(`/design/projects/${projectId}/tasks`);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTask(projectId: string, data: any): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
const res = await apiClient.post(`/design/projects/${projectId}/tasks`, data);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateTask(taskId: string, data: any): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
const res = await apiClient.put(`/design/tasks/${taskId}`, data);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTask(taskId: string): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
const res = await apiClient.delete(`/design/tasks/${taskId}`);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 작업일지
|
||||
// ============================================
|
||||
|
||||
export async function getWorkLogsByTask(taskId: string): Promise<ApiResponse<any[]>> {
|
||||
try {
|
||||
const res = await apiClient.get(`/design/tasks/${taskId}/work-logs`);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createWorkLog(taskId: string, data: any): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
const res = await apiClient.post(`/design/tasks/${taskId}/work-logs`, data);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteWorkLog(workLogId: string): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
const res = await apiClient.delete(`/design/work-logs/${workLogId}`);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 태스크 하위항목
|
||||
// ============================================
|
||||
|
||||
export async function createSubItem(taskId: string, data: any): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
const res = await apiClient.post(`/design/tasks/${taskId}/sub-items`, data);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSubItem(subItemId: string, data: any): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
const res = await apiClient.put(`/design/sub-items/${subItemId}`, data);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSubItem(subItemId: string): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
const res = await apiClient.delete(`/design/sub-items/${subItemId}`);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 태스크 이슈
|
||||
// ============================================
|
||||
|
||||
export async function createIssue(taskId: string, data: any): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
const res = await apiClient.post(`/design/tasks/${taskId}/issues`, data);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateIssue(issueId: string, data: any): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
const res = await apiClient.put(`/design/issues/${issueId}`, data);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ECN (설변통보)
|
||||
// ============================================
|
||||
|
||||
export async function getEcnList(params?: {
|
||||
status?: string;
|
||||
search?: string;
|
||||
}): Promise<ApiResponse<any[]>> {
|
||||
try {
|
||||
const res = await apiClient.get("/design/ecn", { params });
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createEcn(data: any): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
const res = await apiClient.post("/design/ecn", data);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateEcn(id: string, data: any): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
const res = await apiClient.put(`/design/ecn/${id}`, data);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteEcn(id: string): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
const res = await apiClient.delete(`/design/ecn/${id}`);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 나의 업무 (My Work)
|
||||
// ============================================
|
||||
|
||||
export async function getMyWork(params?: {
|
||||
status?: string;
|
||||
project_id?: string;
|
||||
}): Promise<ApiResponse<any[]>> {
|
||||
try {
|
||||
const res = await apiClient.get("/design/my-work", { params });
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 구매요청 / 협업요청
|
||||
// ============================================
|
||||
|
||||
export async function createPurchaseReq(workLogId: string, data: any): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
const res = await apiClient.post(`/design/work-logs/${workLogId}/purchase-reqs`, data);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCoopReq(workLogId: string, data: any): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
const res = await apiClient.post(`/design/work-logs/${workLogId}/coop-reqs`, data);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function addCoopResponse(coopReqId: string, data: any): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
const res = await apiClient.post(`/design/coop-reqs/${coopReqId}/responses`, data);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
/**
|
||||
* 자재현황 API 클라이언트
|
||||
*/
|
||||
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export interface WorkOrder {
|
||||
id: number;
|
||||
plan_no: string;
|
||||
item_code: string;
|
||||
item_name: string;
|
||||
plan_qty: number;
|
||||
completed_qty: number;
|
||||
plan_date: string;
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
status: string;
|
||||
work_order_no: string | null;
|
||||
company_code: string;
|
||||
}
|
||||
|
||||
export interface MaterialLocation {
|
||||
location: string;
|
||||
warehouse: string;
|
||||
qty: number;
|
||||
}
|
||||
|
||||
export interface MaterialData {
|
||||
code: string;
|
||||
name: string;
|
||||
required: number;
|
||||
current: number;
|
||||
unit: string;
|
||||
locations: MaterialLocation[];
|
||||
}
|
||||
|
||||
export interface WarehouseData {
|
||||
warehouse_code: string;
|
||||
warehouse_name: string;
|
||||
warehouse_type: string | null;
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export async function getWorkOrders(params: {
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
itemCode?: string;
|
||||
itemName?: string;
|
||||
}): Promise<ApiResponse<WorkOrder[]>> {
|
||||
try {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params.dateFrom) queryParams.append("dateFrom", params.dateFrom);
|
||||
if (params.dateTo) queryParams.append("dateTo", params.dateTo);
|
||||
if (params.itemCode) queryParams.append("itemCode", params.itemCode);
|
||||
if (params.itemName) queryParams.append("itemName", params.itemName);
|
||||
|
||||
const qs = queryParams.toString();
|
||||
const url = `/material-status/work-orders${qs ? `?${qs}` : ""}`;
|
||||
const response = await apiClient.get(url);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMaterialStatus(params: {
|
||||
planIds: number[];
|
||||
warehouseCode?: string;
|
||||
}): Promise<ApiResponse<MaterialData[]>> {
|
||||
try {
|
||||
const response = await apiClient.post(
|
||||
"/material-status/materials",
|
||||
params
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWarehouses(): Promise<ApiResponse<WarehouseData[]>> {
|
||||
try {
|
||||
const response = await apiClient.get("/material-status/warehouses");
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,299 @@
|
|||
/**
|
||||
* 공정정보관리 API 클라이언트
|
||||
*/
|
||||
|
||||
import { apiClient } from "./client";
|
||||
|
||||
// ═══ Types ═══
|
||||
|
||||
export interface ProcessMaster {
|
||||
id: string;
|
||||
company_code: string;
|
||||
process_code: string;
|
||||
process_name: string;
|
||||
process_type: string;
|
||||
standard_time: string;
|
||||
worker_count: string;
|
||||
use_yn: string;
|
||||
}
|
||||
|
||||
export interface ProcessEquipment {
|
||||
id: string;
|
||||
process_code: string;
|
||||
equipment_code: string;
|
||||
equipment_name?: string;
|
||||
}
|
||||
|
||||
export interface Equipment {
|
||||
id: string;
|
||||
equipment_code: string;
|
||||
equipment_name: string;
|
||||
}
|
||||
|
||||
export interface ItemForRouting {
|
||||
id: string;
|
||||
item_number: string;
|
||||
item_name: string;
|
||||
size: string;
|
||||
unit: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface RoutingVersion {
|
||||
id: string;
|
||||
item_code: string;
|
||||
version_name: string;
|
||||
description: string;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
export interface RoutingDetail {
|
||||
id: string;
|
||||
routing_version_id: string;
|
||||
seq_no: string;
|
||||
process_code: string;
|
||||
process_name?: string;
|
||||
is_required: string;
|
||||
is_fixed_order: string;
|
||||
work_type: string;
|
||||
standard_time: string;
|
||||
outsource_supplier: string;
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const BASE = "/process-info";
|
||||
|
||||
// ═══ 공정 마스터 ═══
|
||||
|
||||
export async function getProcessList(params?: {
|
||||
processCode?: string;
|
||||
processName?: string;
|
||||
processType?: string;
|
||||
useYn?: string;
|
||||
}): Promise<ApiResponse<ProcessMaster[]>> {
|
||||
try {
|
||||
const qp = new URLSearchParams();
|
||||
if (params?.processCode) qp.append("processCode", params.processCode);
|
||||
if (params?.processName) qp.append("processName", params.processName);
|
||||
if (params?.processType) qp.append("processType", params.processType);
|
||||
if (params?.useYn) qp.append("useYn", params.useYn);
|
||||
const qs = qp.toString();
|
||||
const res = await apiClient.get(`${BASE}/processes${qs ? `?${qs}` : ""}`);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createProcess(data: Partial<ProcessMaster>): Promise<ApiResponse<ProcessMaster>> {
|
||||
try {
|
||||
const res = await apiClient.post(`${BASE}/processes`, data);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.response?.data?.message || e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateProcess(id: string, data: Partial<ProcessMaster>): Promise<ApiResponse<ProcessMaster>> {
|
||||
try {
|
||||
const res = await apiClient.put(`${BASE}/processes/${id}`, data);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.response?.data?.message || e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteProcesses(ids: string[]): Promise<ApiResponse<{ deletedCount: number }>> {
|
||||
try {
|
||||
const res = await apiClient.post(`${BASE}/processes/delete`, { ids });
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ═══ 공정별 설비 ═══
|
||||
|
||||
export async function getProcessEquipments(processCode: string): Promise<ApiResponse<ProcessEquipment[]>> {
|
||||
try {
|
||||
const res = await apiClient.get(`${BASE}/processes/${processCode}/equipments`);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function addProcessEquipment(data: { process_code: string; equipment_code: string }): Promise<ApiResponse<ProcessEquipment>> {
|
||||
try {
|
||||
const res = await apiClient.post(`${BASE}/process-equipments`, data);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.response?.data?.message || e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeProcessEquipment(id: string): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
const res = await apiClient.delete(`${BASE}/process-equipments/${id}`);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEquipmentList(): Promise<ApiResponse<Equipment[]>> {
|
||||
try {
|
||||
const res = await apiClient.get(`${BASE}/equipments`);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ═══ 등록 품목 관리 (item_routing_registered) ═══
|
||||
|
||||
export const ROUTING_SCREEN_CODE = "screen_1599";
|
||||
|
||||
export interface RegisteredItem {
|
||||
registered_id: string;
|
||||
sort_order: string;
|
||||
id: string;
|
||||
item_name: string;
|
||||
item_code: string;
|
||||
routing_count: string;
|
||||
}
|
||||
|
||||
const PWS_BASE = "/process-work-standard";
|
||||
|
||||
export async function getRegisteredItems(search?: string): Promise<ApiResponse<RegisteredItem[]>> {
|
||||
try {
|
||||
const qs = new URLSearchParams({
|
||||
tableName: "item_info",
|
||||
nameColumn: "item_name",
|
||||
codeColumn: "item_number",
|
||||
routingTable: "item_routing_version",
|
||||
routingFkColumn: "item_code",
|
||||
});
|
||||
if (search) qs.set("search", search);
|
||||
const res = await apiClient.get(`${PWS_BASE}/registered-items/${ROUTING_SCREEN_CODE}?${qs.toString()}`);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerItemsBatch(
|
||||
items: Array<{ itemId: string; itemCode: string }>
|
||||
): Promise<ApiResponse<any[]>> {
|
||||
try {
|
||||
const res = await apiClient.post(`${PWS_BASE}/registered-items/batch`, {
|
||||
screenCode: ROUTING_SCREEN_CODE,
|
||||
items,
|
||||
});
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.response?.data?.message || e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function unregisterItem(registeredId: string): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
const res = await apiClient.delete(`${PWS_BASE}/registered-items/${registeredId}`);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ═══ 품목별 라우팅 ═══
|
||||
|
||||
export async function searchAllItems(search?: string): Promise<ApiResponse<ItemForRouting[]>> {
|
||||
try {
|
||||
const qs = search ? `?search=${encodeURIComponent(search)}` : "";
|
||||
const res = await apiClient.get(`${BASE}/items/search-all${qs}`);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRoutingVersions(itemCode: string): Promise<ApiResponse<RoutingVersion[]>> {
|
||||
try {
|
||||
const res = await apiClient.get(`${BASE}/routing-versions/${itemCode}`);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createRoutingVersion(data: {
|
||||
item_code: string;
|
||||
version_name: string;
|
||||
description?: string;
|
||||
is_default?: boolean;
|
||||
}): Promise<ApiResponse<RoutingVersion>> {
|
||||
try {
|
||||
const res = await apiClient.post(`${BASE}/routing-versions`, data);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteRoutingVersion(id: string): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
const res = await apiClient.delete(`${BASE}/routing-versions/${id}`);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRoutingDetails(versionId: string): Promise<ApiResponse<RoutingDetail[]>> {
|
||||
try {
|
||||
const res = await apiClient.get(`${BASE}/routing-details/${versionId}`);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveRoutingDetails(
|
||||
versionId: string,
|
||||
details: Partial<RoutingDetail>[]
|
||||
): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
const res = await apiClient.put(`${BASE}/routing-details/${versionId}`, { details });
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ═══ BOM 구성 자재 조회 ═══
|
||||
|
||||
export interface BomMaterial {
|
||||
id: string;
|
||||
child_item_id: string;
|
||||
quantity: string;
|
||||
detail_unit: string | null;
|
||||
process_type: string | null;
|
||||
child_item_name: string | null;
|
||||
child_item_code: string | null;
|
||||
child_item_type: string | null;
|
||||
item_unit: string | null;
|
||||
}
|
||||
|
||||
export async function getBomMaterials(itemCode: string): Promise<ApiResponse<BomMaterial[]>> {
|
||||
try {
|
||||
const res = await apiClient.get(`${BASE}/bom-materials/${encodeURIComponent(itemCode)}`);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
import { apiClient } from "./client";
|
||||
|
||||
// --- 타입 정의 ---
|
||||
|
||||
export interface InboundItem {
|
||||
id: string;
|
||||
company_code: string;
|
||||
inbound_number: string;
|
||||
inbound_type: string;
|
||||
inbound_date: string;
|
||||
reference_number: string | null;
|
||||
supplier_code: string | null;
|
||||
supplier_name: string | null;
|
||||
item_number: string | null;
|
||||
item_name: string | null;
|
||||
spec: string | null;
|
||||
material: string | null;
|
||||
unit: string | null;
|
||||
inbound_qty: number;
|
||||
unit_price: number;
|
||||
total_amount: number;
|
||||
lot_number: string | null;
|
||||
warehouse_code: string | null;
|
||||
warehouse_name?: string | null;
|
||||
location_code: string | null;
|
||||
inbound_status: string;
|
||||
inspection_status: string | null;
|
||||
inspector: string | null;
|
||||
manager: string | null;
|
||||
memo: string | null;
|
||||
source_table: string | null;
|
||||
source_id: string | null;
|
||||
created_date: string;
|
||||
created_by: string | null;
|
||||
}
|
||||
|
||||
export interface PurchaseOrderSource {
|
||||
id: string;
|
||||
purchase_no: string;
|
||||
order_date: string;
|
||||
supplier_code: string;
|
||||
supplier_name: string;
|
||||
item_code: string;
|
||||
item_name: string;
|
||||
spec: string | null;
|
||||
material: string | null;
|
||||
order_qty: number;
|
||||
received_qty: number;
|
||||
remain_qty: number;
|
||||
unit_price: number;
|
||||
status: string;
|
||||
due_date: string | null;
|
||||
}
|
||||
|
||||
export interface ShipmentSource {
|
||||
detail_id: number;
|
||||
instruction_id: number;
|
||||
instruction_no: string;
|
||||
instruction_date: string;
|
||||
partner_id: string;
|
||||
instruction_status: string;
|
||||
item_code: string;
|
||||
item_name: string;
|
||||
spec: string | null;
|
||||
material: string | null;
|
||||
ship_qty: number;
|
||||
order_qty: number;
|
||||
source_type: string | null;
|
||||
}
|
||||
|
||||
export interface ItemSource {
|
||||
id: string;
|
||||
item_number: string;
|
||||
item_name: string;
|
||||
spec: string | null;
|
||||
material: string | null;
|
||||
unit: string | null;
|
||||
standard_price: number;
|
||||
}
|
||||
|
||||
export interface WarehouseOption {
|
||||
warehouse_code: string;
|
||||
warehouse_name: string;
|
||||
warehouse_type: string;
|
||||
}
|
||||
|
||||
export interface CreateReceivingPayload {
|
||||
inbound_number: string;
|
||||
inbound_date: string;
|
||||
warehouse_code?: string;
|
||||
location_code?: string;
|
||||
inspector?: string;
|
||||
manager?: string;
|
||||
memo?: string;
|
||||
items: Array<{
|
||||
inbound_type: string;
|
||||
reference_number?: string;
|
||||
supplier_code?: string;
|
||||
supplier_name?: string;
|
||||
item_number?: string;
|
||||
item_name?: string;
|
||||
spec?: string;
|
||||
material?: string;
|
||||
unit?: string;
|
||||
inbound_qty: number;
|
||||
unit_price?: number;
|
||||
total_amount?: number;
|
||||
lot_number?: string;
|
||||
warehouse_code?: string;
|
||||
location_code?: string;
|
||||
inbound_status?: string;
|
||||
inspection_status?: string;
|
||||
inspector?: string;
|
||||
manager?: string;
|
||||
memo?: string;
|
||||
source_table: string;
|
||||
source_id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// --- API 호출 ---
|
||||
|
||||
export async function getReceivingList(params?: {
|
||||
inbound_type?: string;
|
||||
inbound_status?: string;
|
||||
search_keyword?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
}) {
|
||||
const res = await apiClient.get("/receiving/list", { params });
|
||||
return res.data as { success: boolean; data: InboundItem[] };
|
||||
}
|
||||
|
||||
export async function createReceiving(payload: CreateReceivingPayload) {
|
||||
const res = await apiClient.post("/receiving", payload);
|
||||
return res.data as { success: boolean; data: InboundItem[]; message?: string };
|
||||
}
|
||||
|
||||
export async function updateReceiving(id: string, payload: Partial<InboundItem>) {
|
||||
const res = await apiClient.put(`/receiving/${id}`, payload);
|
||||
return res.data as { success: boolean; data: InboundItem };
|
||||
}
|
||||
|
||||
export async function deleteReceiving(id: string) {
|
||||
const res = await apiClient.delete(`/receiving/${id}`);
|
||||
return res.data as { success: boolean; message?: string };
|
||||
}
|
||||
|
||||
export async function generateReceivingNumber() {
|
||||
const res = await apiClient.get("/receiving/generate-number");
|
||||
return res.data as { success: boolean; data: string };
|
||||
}
|
||||
|
||||
export async function getReceivingWarehouses() {
|
||||
const res = await apiClient.get("/receiving/warehouses");
|
||||
return res.data as { success: boolean; data: WarehouseOption[] };
|
||||
}
|
||||
|
||||
// 소스 데이터 조회
|
||||
export async function getPurchaseOrderSources(keyword?: string) {
|
||||
const res = await apiClient.get("/receiving/source/purchase-orders", {
|
||||
params: keyword ? { keyword } : {},
|
||||
});
|
||||
return res.data as { success: boolean; data: PurchaseOrderSource[] };
|
||||
}
|
||||
|
||||
export async function getShipmentSources(keyword?: string) {
|
||||
const res = await apiClient.get("/receiving/source/shipments", {
|
||||
params: keyword ? { keyword } : {},
|
||||
});
|
||||
return res.data as { success: boolean; data: ShipmentSource[] };
|
||||
}
|
||||
|
||||
export async function getItemSources(keyword?: string) {
|
||||
const res = await apiClient.get("/receiving/source/items", {
|
||||
params: keyword ? { keyword } : {},
|
||||
});
|
||||
return res.data as { success: boolean; data: ItemSource[] };
|
||||
}
|
||||
|
|
@ -58,3 +58,102 @@ export async function batchSaveShippingPlans(
|
|||
const res = await apiClient.post("/shipping-plan/batch", { plans, source });
|
||||
return res.data as { success: boolean; message?: string; data?: any };
|
||||
}
|
||||
|
||||
// 출하계획 목록 조회 (관리 화면용)
|
||||
export interface ShipmentPlanListItem {
|
||||
id: number;
|
||||
plan_date: string;
|
||||
plan_qty: string;
|
||||
status: string;
|
||||
memo: string | null;
|
||||
shipment_plan_no: string | null;
|
||||
created_date: string;
|
||||
created_by: string;
|
||||
detail_id: string | null;
|
||||
sales_order_id: number | null;
|
||||
remain_qty: string | null;
|
||||
order_no: string;
|
||||
part_code: string;
|
||||
part_name: string;
|
||||
spec: string;
|
||||
material: string;
|
||||
customer_name: string;
|
||||
partner_code: string;
|
||||
due_date: string;
|
||||
order_qty: string;
|
||||
shipped_qty: string;
|
||||
}
|
||||
|
||||
export interface ShipmentPlanListParams {
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
status?: string;
|
||||
customer?: string;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
export async function getShipmentPlanList(params: ShipmentPlanListParams) {
|
||||
const res = await apiClient.get("/shipping-plan/list", { params });
|
||||
return res.data as { success: boolean; data: ShipmentPlanListItem[] };
|
||||
}
|
||||
|
||||
// 출하계획 단건 수정
|
||||
export async function updateShipmentPlan(
|
||||
id: number,
|
||||
data: { planQty?: number; planDate?: string; memo?: string }
|
||||
) {
|
||||
const res = await apiClient.put(`/shipping-plan/${id}`, data);
|
||||
return res.data as { success: boolean; data?: any; message?: string };
|
||||
}
|
||||
|
||||
// ─── 출하지시 API ───
|
||||
|
||||
export async function getShippingOrderList(params?: {
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
status?: string;
|
||||
customer?: string;
|
||||
keyword?: string;
|
||||
}) {
|
||||
const res = await apiClient.get("/shipping-order/list", { params });
|
||||
return res.data as { success: boolean; data: any[] };
|
||||
}
|
||||
|
||||
export async function saveShippingOrder(data: any) {
|
||||
const res = await apiClient.post("/shipping-order/save", data);
|
||||
return res.data as { success: boolean; data?: any; message?: string };
|
||||
}
|
||||
|
||||
export async function previewShippingOrderNo() {
|
||||
const res = await apiClient.get("/shipping-order/preview-no");
|
||||
return res.data as { success: boolean; instructionNo: string };
|
||||
}
|
||||
|
||||
export async function deleteShippingOrders(ids: number[]) {
|
||||
const res = await apiClient.post("/shipping-order/delete", { ids });
|
||||
return res.data as { success: boolean; deletedCount?: number; message?: string };
|
||||
}
|
||||
|
||||
// 모달 데이터 소스 (페이징 지원)
|
||||
export interface PaginatedSourceResponse {
|
||||
success: boolean;
|
||||
data: any[];
|
||||
totalCount: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export async function getShipmentPlanSource(params?: { keyword?: string; customer?: string; page?: number; pageSize?: number }) {
|
||||
const res = await apiClient.get("/shipping-order/source/shipment-plan", { params });
|
||||
return res.data as PaginatedSourceResponse;
|
||||
}
|
||||
|
||||
export async function getSalesOrderSource(params?: { keyword?: string; customer?: string; page?: number; pageSize?: number }) {
|
||||
const res = await apiClient.get("/shipping-order/source/sales-order", { params });
|
||||
return res.data as PaginatedSourceResponse;
|
||||
}
|
||||
|
||||
export async function getItemSource(params?: { keyword?: string; page?: number; pageSize?: number }) {
|
||||
const res = await apiClient.get("/shipping-order/source/item", { params });
|
||||
return res.data as PaginatedSourceResponse;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,137 @@
|
|||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
export interface PaginatedResponse { success: boolean; data: any[]; totalCount: number; page: number; pageSize: number; }
|
||||
|
||||
export async function getWorkInstructionList(params?: Record<string, any>) {
|
||||
const res = await apiClient.get("/work-instruction/list", { params });
|
||||
return res.data as { success: boolean; data: any[] };
|
||||
}
|
||||
|
||||
export async function previewWorkInstructionNo() {
|
||||
const res = await apiClient.get("/work-instruction/preview-no");
|
||||
return res.data as { success: boolean; instructionNo: string };
|
||||
}
|
||||
|
||||
export async function saveWorkInstruction(data: any) {
|
||||
const res = await apiClient.post("/work-instruction/save", data);
|
||||
return res.data as { success: boolean; data?: any; message?: string };
|
||||
}
|
||||
|
||||
export async function deleteWorkInstructions(ids: string[]) {
|
||||
const res = await apiClient.post("/work-instruction/delete", { ids });
|
||||
return res.data as { success: boolean; deletedCount?: number; message?: string };
|
||||
}
|
||||
|
||||
export async function getWIItemSource(params?: Record<string, any>) {
|
||||
const res = await apiClient.get("/work-instruction/source/item", { params });
|
||||
return res.data as PaginatedResponse;
|
||||
}
|
||||
|
||||
export async function getWISalesOrderSource(params?: Record<string, any>) {
|
||||
const res = await apiClient.get("/work-instruction/source/sales-order", { params });
|
||||
return res.data as PaginatedResponse;
|
||||
}
|
||||
|
||||
export async function getWIProductionPlanSource(params?: Record<string, any>) {
|
||||
const res = await apiClient.get("/work-instruction/source/production-plan", { params });
|
||||
return res.data as PaginatedResponse;
|
||||
}
|
||||
|
||||
export async function getEquipmentList() {
|
||||
const res = await apiClient.get("/work-instruction/equipment");
|
||||
return res.data as { success: boolean; data: { id: string; equipment_code: string; equipment_name: string }[] };
|
||||
}
|
||||
|
||||
export async function getEmployeeList() {
|
||||
const res = await apiClient.get("/work-instruction/employees");
|
||||
return res.data as { success: boolean; data: { user_id: string; user_name: string; dept_name: string | null }[] };
|
||||
}
|
||||
|
||||
// ─── 라우팅 & 공정작업기준 API ───
|
||||
|
||||
export interface RoutingProcess {
|
||||
routing_detail_id: string;
|
||||
seq_no: string;
|
||||
process_code: string;
|
||||
process_name: string;
|
||||
is_required?: string;
|
||||
work_type?: string;
|
||||
}
|
||||
|
||||
export interface RoutingVersionData {
|
||||
id: string;
|
||||
version_name: string;
|
||||
description?: string;
|
||||
is_default: boolean;
|
||||
processes: RoutingProcess[];
|
||||
}
|
||||
|
||||
export interface WIWorkItemDetail {
|
||||
id?: string;
|
||||
work_item_id?: string;
|
||||
detail_type?: string;
|
||||
content?: string;
|
||||
is_required?: string;
|
||||
sort_order?: number;
|
||||
remark?: string;
|
||||
inspection_code?: string;
|
||||
inspection_method?: string;
|
||||
unit?: string;
|
||||
lower_limit?: string;
|
||||
upper_limit?: string;
|
||||
duration_minutes?: number;
|
||||
input_type?: string;
|
||||
lookup_target?: string;
|
||||
display_fields?: string;
|
||||
}
|
||||
|
||||
export interface WIWorkItem {
|
||||
id?: string;
|
||||
routing_detail_id?: string;
|
||||
work_phase: string;
|
||||
title: string;
|
||||
is_required: string;
|
||||
sort_order: number;
|
||||
description?: string;
|
||||
detail_count?: number;
|
||||
details?: WIWorkItemDetail[];
|
||||
source_work_item_id?: string;
|
||||
}
|
||||
|
||||
export interface WIWorkStandardProcess {
|
||||
routing_detail_id: string;
|
||||
seq_no: string;
|
||||
process_code: string;
|
||||
process_name: string;
|
||||
workItems: WIWorkItem[];
|
||||
}
|
||||
|
||||
export async function getRoutingVersions(wiNo: string, itemCode: string) {
|
||||
const res = await apiClient.get(`/work-instruction/${wiNo}/routing-versions/${encodeURIComponent(itemCode)}`);
|
||||
return res.data as { success: boolean; data: RoutingVersionData[] };
|
||||
}
|
||||
|
||||
export async function updateWIRouting(wiNo: string, routingVersionId: string) {
|
||||
const res = await apiClient.put(`/work-instruction/${wiNo}/routing`, { routingVersionId });
|
||||
return res.data as { success: boolean };
|
||||
}
|
||||
|
||||
export async function getWIWorkStandard(wiNo: string, routingVersionId: string) {
|
||||
const res = await apiClient.get(`/work-instruction/${wiNo}/work-standard`, { params: { routingVersionId } });
|
||||
return res.data as { success: boolean; data: { processes: WIWorkStandardProcess[]; isCustom: boolean } };
|
||||
}
|
||||
|
||||
export async function copyWorkStandard(wiNo: string, routingVersionId: string) {
|
||||
const res = await apiClient.post(`/work-instruction/${wiNo}/work-standard/copy`, { routingVersionId });
|
||||
return res.data as { success: boolean };
|
||||
}
|
||||
|
||||
export async function saveWIWorkStandard(wiNo: string, routingDetailId: string, workItems: WIWorkItem[]) {
|
||||
const res = await apiClient.put(`/work-instruction/${wiNo}/work-standard/save`, { routingDetailId, workItems });
|
||||
return res.data as { success: boolean };
|
||||
}
|
||||
|
||||
export async function resetWIWorkStandard(wiNo: string) {
|
||||
const res = await apiClient.delete(`/work-instruction/${wiNo}/work-standard/reset`);
|
||||
return res.data as { success: boolean };
|
||||
}
|
||||
|
|
@ -208,6 +208,7 @@ export function ProcessWorkStandardComponent({
|
|||
selectedWorkItemDetails={selectedDetailsByPhase[phase.key] || []}
|
||||
detailTypes={config.detailTypes}
|
||||
readonly={config.readonly}
|
||||
selectedItemCode={selection.itemCode || undefined}
|
||||
onSelectWorkItem={handleSelectWorkItem}
|
||||
onAddWorkItem={handleAddWorkItem}
|
||||
onEditWorkItem={handleEditWorkItem}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Search, Loader2 } from "lucide-react";
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -22,6 +23,7 @@ import {
|
|||
} from "@/components/ui/dialog";
|
||||
import { WorkItemDetail, DetailTypeDefinition, InspectionStandard } from "../types";
|
||||
import { InspectionStandardLookup } from "./InspectionStandardLookup";
|
||||
import { getBomMaterials, BomMaterial } from "@/lib/api/processInfo";
|
||||
|
||||
interface DetailFormModalProps {
|
||||
open: boolean;
|
||||
|
|
@ -30,24 +32,53 @@ interface DetailFormModalProps {
|
|||
detailTypes: DetailTypeDefinition[];
|
||||
editData?: WorkItemDetail | null;
|
||||
mode: "add" | "edit";
|
||||
selectedItemCode?: string;
|
||||
}
|
||||
|
||||
const LOOKUP_TARGETS = [
|
||||
{ value: "equipment", label: "설비정보" },
|
||||
{ value: "material", label: "자재정보" },
|
||||
{ value: "worker", label: "작업자정보" },
|
||||
{ value: "tool", label: "공구정보" },
|
||||
{ value: "document", label: "문서정보" },
|
||||
];
|
||||
|
||||
const INPUT_TYPES = [
|
||||
{ value: "text", label: "텍스트" },
|
||||
{ value: "number", label: "숫자" },
|
||||
{ value: "date", label: "날짜" },
|
||||
{ value: "textarea", label: "장문텍스트" },
|
||||
{ value: "select", label: "선택형" },
|
||||
{ value: "textarea", label: "장문 텍스트" },
|
||||
];
|
||||
|
||||
const UNIT_OPTIONS = [
|
||||
"mm", "cm", "m", "μm", "℃", "℉", "bar", "Pa", "MPa", "psi",
|
||||
"RPM", "kg", "N", "N·m", "m/s", "m/min", "A", "V", "kW", "%",
|
||||
"L/min", "Hz", "dB", "ea", "g", "mg", "ml", "L",
|
||||
];
|
||||
|
||||
const PLC_DATA_OPTIONS = [
|
||||
{ value: "PLC_TEMP_01", label: "온도 (PLC_TEMP_01)" },
|
||||
{ value: "PLC_PRES_01", label: "압력 (PLC_PRES_01)" },
|
||||
{ value: "PLC_RPM_01", label: "회전수 (PLC_RPM_01)" },
|
||||
{ value: "PLC_TORQ_01", label: "토크 (PLC_TORQ_01)" },
|
||||
{ value: "PLC_SPD_01", label: "속도 (PLC_SPD_01)" },
|
||||
{ value: "PLC_CUR_01", label: "전류 (PLC_CUR_01)" },
|
||||
{ value: "PLC_VOLT_01", label: "전압 (PLC_VOLT_01)" },
|
||||
{ value: "PLC_VIB_01", label: "진동 (PLC_VIB_01)" },
|
||||
{ value: "PLC_HUM_01", label: "습도 (PLC_HUM_01)" },
|
||||
{ value: "PLC_FLOW_01", label: "유량 (PLC_FLOW_01)" },
|
||||
];
|
||||
|
||||
const PLC_PRODUCTION_OPTIONS = {
|
||||
work_qty: [
|
||||
{ value: "PLC_CNT_01", label: "생산카운터 (PLC_CNT_01)" },
|
||||
{ value: "PLC_CNT_02", label: "완료카운터 (PLC_CNT_02)" },
|
||||
{ value: "PLC_QTY_01", label: "작업수량 (PLC_QTY_01)" },
|
||||
],
|
||||
defect_qty: [
|
||||
{ value: "PLC_NG_01", label: "불량카운터 (PLC_NG_01)" },
|
||||
{ value: "PLC_NG_02", label: "NG감지기 (PLC_NG_02)" },
|
||||
{ value: "PLC_REJ_01", label: "리젝트수 (PLC_REJ_01)" },
|
||||
],
|
||||
good_qty: [
|
||||
{ value: "PLC_OK_01", label: "양품카운터 (PLC_OK_01)" },
|
||||
{ value: "PLC_OK_02", label: "합격카운터 (PLC_OK_02)" },
|
||||
{ value: "PLC_GOOD_01", label: "양품수량 (PLC_GOOD_01)" },
|
||||
],
|
||||
};
|
||||
|
||||
export function DetailFormModal({
|
||||
open,
|
||||
onClose,
|
||||
|
|
@ -55,10 +86,35 @@ export function DetailFormModal({
|
|||
detailTypes,
|
||||
editData,
|
||||
mode,
|
||||
selectedItemCode,
|
||||
}: DetailFormModalProps) {
|
||||
const [formData, setFormData] = useState<Partial<WorkItemDetail>>({});
|
||||
const [inspectionLookupOpen, setInspectionLookupOpen] = useState(false);
|
||||
const [selectedInspection, setSelectedInspection] = useState<InspectionStandard | null>(null);
|
||||
const [bomMaterials, setBomMaterials] = useState<BomMaterial[]>([]);
|
||||
const [bomLoading, setBomLoading] = useState(false);
|
||||
const [bomChecked, setBomChecked] = useState<Set<string>>(new Set());
|
||||
|
||||
const loadBomMaterials = useCallback(async () => {
|
||||
if (!selectedItemCode) {
|
||||
setBomMaterials([]);
|
||||
return;
|
||||
}
|
||||
setBomLoading(true);
|
||||
try {
|
||||
const res = await getBomMaterials(selectedItemCode);
|
||||
if (res.success && res.data) {
|
||||
setBomMaterials(res.data);
|
||||
setBomChecked(new Set(res.data.map((m) => m.child_item_id)));
|
||||
} else {
|
||||
setBomMaterials([]);
|
||||
}
|
||||
} catch {
|
||||
setBomMaterials([]);
|
||||
} finally {
|
||||
setBomLoading(false);
|
||||
}
|
||||
}, [selectedItemCode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
|
|
@ -86,6 +142,12 @@ export function DetailFormModal({
|
|||
}
|
||||
}, [open, mode, editData, detailTypes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && formData.detail_type === "material_input") {
|
||||
loadBomMaterials();
|
||||
}
|
||||
}, [open, formData.detail_type, loadBomMaterials]);
|
||||
|
||||
const updateField = (field: string, value: any) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
|
@ -108,17 +170,33 @@ export function DetailFormModal({
|
|||
|
||||
const type = formData.detail_type;
|
||||
|
||||
if (type === "check" && !formData.content?.trim()) return;
|
||||
if (type === "inspect" && !formData.content?.trim()) return;
|
||||
if (type === "checklist" && !formData.content?.trim()) return;
|
||||
if (type === "inspection") {
|
||||
if (!formData.process_inspection_apply) return;
|
||||
if (formData.process_inspection_apply === "none" && !formData.content?.trim()) return;
|
||||
}
|
||||
if (type === "procedure" && !formData.content?.trim()) return;
|
||||
if (type === "input" && !formData.content?.trim()) return;
|
||||
if (type === "info" && !formData.lookup_target) return;
|
||||
if (type === "equip_inspection" && !formData.equip_inspection_apply) return;
|
||||
if (type === "equip_condition" && !formData.content?.trim()) return;
|
||||
|
||||
const submitData = { ...formData };
|
||||
|
||||
if (type === "info" && !submitData.content?.trim()) {
|
||||
const targetLabel = LOOKUP_TARGETS.find(t => t.value === submitData.lookup_target)?.label || submitData.lookup_target;
|
||||
submitData.content = `${targetLabel} 조회`;
|
||||
// content 자동 설정 (UI에서 직접 입력이 없는 유형들)
|
||||
if (type === "inspection" && submitData.process_inspection_apply === "apply") {
|
||||
submitData.content = submitData.content || "품목별 검사정보 (자동 연동)";
|
||||
}
|
||||
if (type === "lookup") {
|
||||
submitData.content = submitData.content || "품목 등록 문서 (자동 연동)";
|
||||
}
|
||||
if (type === "equip_inspection" && submitData.equip_inspection_apply === "apply") {
|
||||
submitData.content = submitData.content || "설비 점검항목 (설비정보 연동)";
|
||||
}
|
||||
if (type === "production_result") {
|
||||
submitData.content = submitData.content || "작업수량 / 불량수량 / 양품수량";
|
||||
}
|
||||
if (type === "material_input") {
|
||||
submitData.content = submitData.content || "BOM 구성 자재 (자동 연동)";
|
||||
}
|
||||
|
||||
onSubmit(submitData);
|
||||
|
|
@ -130,7 +208,7 @@ export function DetailFormModal({
|
|||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
상세 항목 {mode === "add" ? "추가" : "수정"}
|
||||
|
|
@ -149,12 +227,11 @@ export function DetailFormModal({
|
|||
<Select
|
||||
value={currentType}
|
||||
onValueChange={(v) => {
|
||||
updateField("detail_type", v);
|
||||
setSelectedInspection(null);
|
||||
setFormData((prev) => ({
|
||||
setFormData({
|
||||
detail_type: v,
|
||||
is_required: prev.is_required || "Y",
|
||||
}));
|
||||
is_required: formData.is_required || "Y",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
||||
|
|
@ -170,139 +247,167 @@ export function DetailFormModal({
|
|||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 체크리스트 */}
|
||||
{currentType === "check" && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">
|
||||
체크 내용 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={formData.content || ""}
|
||||
onChange={(e) => updateField("content", e.target.value)}
|
||||
placeholder="예: 전원 상태 확인"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
{/* ============ 체크리스트 ============ */}
|
||||
{currentType === "checklist" && (
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">
|
||||
체크 내용 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={formData.content || ""}
|
||||
onChange={(e) => updateField("content", e.target.value)}
|
||||
placeholder="예: 전원 상태 확인"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 검사항목 */}
|
||||
{currentType === "inspect" && (
|
||||
{/* ============ 검사항목 ============ */}
|
||||
{currentType === "inspection" && (
|
||||
<>
|
||||
{/* 공정검사 적용 여부 */}
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">
|
||||
검사기준 선택 <span className="text-destructive">*</span>
|
||||
공정검사 적용 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="mt-1 flex gap-2">
|
||||
<Select value="_placeholder" disabled>
|
||||
<SelectTrigger className="h-8 flex-1 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue>
|
||||
{selectedInspection
|
||||
? `${selectedInspection.inspection_code} - ${selectedInspection.inspection_item}`
|
||||
: "검사기준을 선택하세요"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_placeholder">선택</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="h-8 shrink-0 gap-1 text-xs sm:h-10 sm:text-sm"
|
||||
onClick={() => setInspectionLookupOpen(true)}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
조회
|
||||
</Button>
|
||||
<div className="mt-2 flex gap-4">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-xs sm:text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name="processInspection"
|
||||
checked={formData.process_inspection_apply === "apply"}
|
||||
onChange={() => updateField("process_inspection_apply", "apply")}
|
||||
className="h-4 w-4 accent-primary"
|
||||
/>
|
||||
적용
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-2 text-xs sm:text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name="processInspection"
|
||||
checked={formData.process_inspection_apply === "none"}
|
||||
onChange={() => updateField("process_inspection_apply", "none")}
|
||||
className="h-4 w-4 accent-primary"
|
||||
/>
|
||||
미적용
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedInspection && (
|
||||
<div className="rounded border bg-muted/30 p-3">
|
||||
<p className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
선택된 검사기준 정보
|
||||
{/* 적용 시: 품목별 검사정보 자동 연동 안내 */}
|
||||
{formData.process_inspection_apply === "apply" && (
|
||||
<div className="rounded-lg border border-sky-200 bg-sky-50 p-3">
|
||||
<p className="text-xs font-semibold text-sky-800">
|
||||
품목별 검사정보 (자동 연동)
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||
품목에 등록된 검사기준이 자동으로 적용됩니다.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
||||
<p>
|
||||
<strong>검사코드:</strong> {selectedInspection.inspection_code}
|
||||
</p>
|
||||
<p>
|
||||
<strong>검사항목:</strong> {selectedInspection.inspection_item}
|
||||
</p>
|
||||
<p>
|
||||
<strong>검사방법:</strong> {selectedInspection.inspection_method || "-"}
|
||||
</p>
|
||||
<p>
|
||||
<strong>단위:</strong> {selectedInspection.unit || "-"}
|
||||
</p>
|
||||
<p>
|
||||
<strong>하한값:</strong> {selectedInspection.lower_limit || "-"}
|
||||
</p>
|
||||
<p>
|
||||
<strong>상한값:</strong> {selectedInspection.upper_limit || "-"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">
|
||||
검사 항목명 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={formData.content || ""}
|
||||
onChange={(e) => updateField("content", e.target.value)}
|
||||
placeholder="예: 외경 치수"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
{/* 미적용 시: 수동 입력 */}
|
||||
{formData.process_inspection_apply === "none" && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">
|
||||
검사 항목명 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={formData.content || ""}
|
||||
onChange={(e) => updateField("content", e.target.value)}
|
||||
placeholder="예: 외경 치수"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">검사 방법</Label>
|
||||
<Input
|
||||
value={formData.inspection_method || ""}
|
||||
onChange={(e) => updateField("inspection_method", e.target.value)}
|
||||
placeholder="예: 마이크로미터"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">단위</Label>
|
||||
<Select
|
||||
value={formData.unit || ""}
|
||||
onValueChange={(v) => updateField("unit", v)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="단위 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{UNIT_OPTIONS.map((u) => (
|
||||
<SelectItem key={u} value={u}>{u}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">검사 방법</Label>
|
||||
<Input
|
||||
value={formData.inspection_method || ""}
|
||||
onChange={(e) => updateField("inspection_method", e.target.value)}
|
||||
placeholder="예: 마이크로미터"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">단위</Label>
|
||||
<Input
|
||||
value={formData.unit || ""}
|
||||
onChange={(e) => updateField("unit", e.target.value)}
|
||||
placeholder="예: mm"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 기준값 ± 오차범위 */}
|
||||
<div className="rounded-lg border bg-muted/30 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="number"
|
||||
step="any"
|
||||
value={formData.base_value || ""}
|
||||
onChange={(e) => updateField("base_value", e.target.value)}
|
||||
placeholder="기준값"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-muted-foreground">±</span>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="number"
|
||||
step="any"
|
||||
value={formData.tolerance || ""}
|
||||
onChange={(e) => updateField("tolerance", e.target.value)}
|
||||
placeholder="오차범위"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">하한값</Label>
|
||||
<Input
|
||||
value={formData.lower_limit || ""}
|
||||
onChange={(e) => updateField("lower_limit", e.target.value)}
|
||||
placeholder="예: 7.95"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">상한값</Label>
|
||||
<Input
|
||||
value={formData.upper_limit || ""}
|
||||
onChange={(e) => updateField("upper_limit", e.target.value)}
|
||||
placeholder="예: 8.05"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 자동수집 */}
|
||||
<div className="mt-3 flex items-center gap-3 border-t pt-3">
|
||||
<label className="flex shrink-0 cursor-pointer items-center gap-2 text-xs font-medium sm:text-sm">
|
||||
<Checkbox
|
||||
checked={formData.auto_collect === "Y"}
|
||||
onCheckedChange={(checked) => {
|
||||
updateField("auto_collect", checked ? "Y" : "N");
|
||||
if (!checked) updateField("plc_data", "");
|
||||
}}
|
||||
/>
|
||||
자동수집
|
||||
</label>
|
||||
<Select
|
||||
value={formData.plc_data || ""}
|
||||
onValueChange={(v) => updateField("plc_data", v)}
|
||||
disabled={formData.auto_collect !== "Y"}
|
||||
>
|
||||
<SelectTrigger className={`h-8 flex-1 text-xs sm:h-10 sm:text-sm ${formData.auto_collect !== "Y" ? "opacity-50" : ""}`}>
|
||||
<SelectValue placeholder="수집데이터 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PLC_DATA_OPTIONS.map((p) => (
|
||||
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 작업절차 */}
|
||||
{/* ============ 작업절차 ============ */}
|
||||
{currentType === "procedure" && (
|
||||
<>
|
||||
<div>
|
||||
|
|
@ -322,10 +427,7 @@ export function DetailFormModal({
|
|||
type="number"
|
||||
value={formData.duration_minutes ?? ""}
|
||||
onChange={(e) =>
|
||||
updateField(
|
||||
"duration_minutes",
|
||||
e.target.value ? Number(e.target.value) : undefined
|
||||
)
|
||||
updateField("duration_minutes", e.target.value ? Number(e.target.value) : undefined)
|
||||
}
|
||||
placeholder="예: 5"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
|
|
@ -334,7 +436,7 @@ export function DetailFormModal({
|
|||
</>
|
||||
)}
|
||||
|
||||
{/* 직접입력 */}
|
||||
{/* ============ 직접입력 ============ */}
|
||||
{currentType === "input" && (
|
||||
<>
|
||||
<div>
|
||||
|
|
@ -359,9 +461,7 @@ export function DetailFormModal({
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{INPUT_TYPES.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
|
@ -369,41 +469,367 @@ export function DetailFormModal({
|
|||
</>
|
||||
)}
|
||||
|
||||
{/* 정보조회 */}
|
||||
{currentType === "info" && (
|
||||
{/* ============ 문서참조 ============ */}
|
||||
{currentType === "lookup" && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">
|
||||
조회 대상 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.lookup_target || ""}
|
||||
onValueChange={(v) => updateField("lookup_target", v)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LOOKUP_TARGETS.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2.5">
|
||||
<span className="text-sm">📄</span>
|
||||
<span className="text-xs text-blue-800">
|
||||
해당 품목에 등록된 문서를 자동으로 불러옵니다.
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">표시 항목</Label>
|
||||
<Input
|
||||
value={formData.display_fields || ""}
|
||||
onChange={(e) => updateField("display_fields", e.target.value)}
|
||||
placeholder="예: 설비명, 설비코드"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
<Label className="text-xs sm:text-sm">참조 문서 목록</Label>
|
||||
<div className="mt-1 rounded-lg border p-3">
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
품목이 선택되지 않았습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ============ 설비점검 ============ */}
|
||||
{currentType === "equip_inspection" && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 rounded-lg border border-green-200 bg-green-50 px-3 py-2.5">
|
||||
<span className="text-sm">🏭</span>
|
||||
<span className="text-xs text-green-800">
|
||||
공정에 지정된 설비를 자동 참조합니다.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">
|
||||
설비점검 적용 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="mt-2 flex gap-4">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-xs sm:text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name="equipInspApply"
|
||||
checked={formData.equip_inspection_apply === "apply"}
|
||||
onChange={() => updateField("equip_inspection_apply", "apply")}
|
||||
className="h-4 w-4 accent-primary"
|
||||
/>
|
||||
적용
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-2 text-xs sm:text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name="equipInspApply"
|
||||
checked={formData.equip_inspection_apply === "none"}
|
||||
onChange={() => updateField("equip_inspection_apply", "none")}
|
||||
className="h-4 w-4 accent-primary"
|
||||
/>
|
||||
미적용
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 적용 시: 설비 점검항목 자동 연동 */}
|
||||
{formData.equip_inspection_apply === "apply" && (
|
||||
<div className="rounded-lg border border-sky-200 bg-sky-50 p-3">
|
||||
<p className="text-xs font-semibold text-sky-800">
|
||||
설비 점검항목 (설비정보 연동)
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||
공정에 지정된 설비의 점검항목이 자동으로 적용됩니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ============ 설비조건 ============ */}
|
||||
{currentType === "equip_condition" && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 rounded-lg border border-green-200 bg-green-50 px-3 py-2.5">
|
||||
<span className="text-sm">🏭</span>
|
||||
<span className="text-xs text-green-800">
|
||||
공정에 지정된 설비를 자동 참조합니다.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">
|
||||
설비조건 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="mt-2 rounded-lg border bg-muted/30 p-3 space-y-3">
|
||||
{/* 조건명 + 단위 */}
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-[1.2]">
|
||||
<Input
|
||||
value={formData.content || ""}
|
||||
onChange={(e) => updateField("content", e.target.value)}
|
||||
placeholder="조건명 (예: 온도, 압력, RPM)"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-[0.6]">
|
||||
<Select
|
||||
value={formData.condition_unit || ""}
|
||||
onValueChange={(v) => updateField("condition_unit", v)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="단위" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{UNIT_OPTIONS.map((u) => (
|
||||
<SelectItem key={u} value={u}>{u}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기준값 ± 오차범위 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="number"
|
||||
step="any"
|
||||
value={formData.condition_base_value || ""}
|
||||
onChange={(e) => updateField("condition_base_value", e.target.value)}
|
||||
placeholder="기준값"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-muted-foreground">±</span>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="number"
|
||||
step="any"
|
||||
value={formData.condition_tolerance || ""}
|
||||
onChange={(e) => updateField("condition_tolerance", e.target.value)}
|
||||
placeholder="오차범위"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 자동수집 */}
|
||||
<div className="flex items-center gap-3 border-t pt-3">
|
||||
<label className="flex shrink-0 cursor-pointer items-center gap-2 text-xs font-medium sm:text-sm">
|
||||
<Checkbox
|
||||
checked={formData.condition_auto_collect === "Y"}
|
||||
onCheckedChange={(checked) => {
|
||||
updateField("condition_auto_collect", checked ? "Y" : "N");
|
||||
if (!checked) updateField("condition_plc_data", "");
|
||||
}}
|
||||
/>
|
||||
자동수집
|
||||
</label>
|
||||
<Select
|
||||
value={formData.condition_plc_data || ""}
|
||||
onValueChange={(v) => updateField("condition_plc_data", v)}
|
||||
disabled={formData.condition_auto_collect !== "Y"}
|
||||
>
|
||||
<SelectTrigger className={`h-8 flex-1 text-xs sm:h-10 sm:text-sm ${formData.condition_auto_collect !== "Y" ? "opacity-50" : ""}`}>
|
||||
<SelectValue placeholder="수집데이터 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PLC_DATA_OPTIONS.map((p) => (
|
||||
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ============ 실적등록 ============ */}
|
||||
{currentType === "production_result" && (
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">실적등록 항목 설정</Label>
|
||||
<div className="mt-2 rounded-lg border bg-muted/30 p-3 space-y-0 divide-y">
|
||||
{/* 작업수량 */}
|
||||
<div className="flex items-center justify-between py-3 first:pt-0">
|
||||
<div className="flex-1">
|
||||
<span className="text-sm font-semibold">📦 작업수량</span>
|
||||
<span className="ml-2 text-[11px] text-muted-foreground">생산된 전체 수량</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex shrink-0 cursor-pointer items-center gap-2 text-xs font-medium">
|
||||
<Checkbox
|
||||
checked={formData.work_qty_auto_collect === "Y"}
|
||||
onCheckedChange={(checked) => {
|
||||
updateField("work_qty_auto_collect", checked ? "Y" : "N");
|
||||
if (!checked) updateField("work_qty_plc_data", "");
|
||||
}}
|
||||
/>
|
||||
자동수집
|
||||
</label>
|
||||
<Select
|
||||
value={formData.work_qty_plc_data || ""}
|
||||
onValueChange={(v) => updateField("work_qty_plc_data", v)}
|
||||
disabled={formData.work_qty_auto_collect !== "Y"}
|
||||
>
|
||||
<SelectTrigger className={`h-8 w-[180px] text-xs ${formData.work_qty_auto_collect !== "Y" ? "opacity-50" : ""}`}>
|
||||
<SelectValue placeholder="수집데이터 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PLC_PRODUCTION_OPTIONS.work_qty.map((p) => (
|
||||
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 불량수량 */}
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<div className="flex-1">
|
||||
<span className="text-sm font-semibold">🚫 불량수량</span>
|
||||
<span className="ml-2 text-[11px] text-muted-foreground">불량으로 판정된 수량</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex shrink-0 cursor-pointer items-center gap-2 text-xs font-medium">
|
||||
<Checkbox
|
||||
checked={formData.defect_qty_auto_collect === "Y"}
|
||||
onCheckedChange={(checked) => {
|
||||
updateField("defect_qty_auto_collect", checked ? "Y" : "N");
|
||||
if (!checked) updateField("defect_qty_plc_data", "");
|
||||
}}
|
||||
/>
|
||||
자동수집
|
||||
</label>
|
||||
<Select
|
||||
value={formData.defect_qty_plc_data || ""}
|
||||
onValueChange={(v) => updateField("defect_qty_plc_data", v)}
|
||||
disabled={formData.defect_qty_auto_collect !== "Y"}
|
||||
>
|
||||
<SelectTrigger className={`h-8 w-[180px] text-xs ${formData.defect_qty_auto_collect !== "Y" ? "opacity-50" : ""}`}>
|
||||
<SelectValue placeholder="수집데이터 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PLC_PRODUCTION_OPTIONS.defect_qty.map((p) => (
|
||||
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 양품수량 */}
|
||||
<div className="flex items-center justify-between py-3 last:pb-0">
|
||||
<div className="flex-1">
|
||||
<span className="text-sm font-semibold">✅ 양품수량</span>
|
||||
<span className="ml-2 text-[11px] text-muted-foreground">작업수량 - 불량수량</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex shrink-0 cursor-pointer items-center gap-2 text-xs font-medium">
|
||||
<Checkbox
|
||||
checked={formData.good_qty_auto_collect === "Y"}
|
||||
onCheckedChange={(checked) => {
|
||||
updateField("good_qty_auto_collect", checked ? "Y" : "N");
|
||||
if (!checked) updateField("good_qty_plc_data", "");
|
||||
}}
|
||||
/>
|
||||
자동수집
|
||||
</label>
|
||||
<Select
|
||||
value={formData.good_qty_plc_data || ""}
|
||||
onValueChange={(v) => updateField("good_qty_plc_data", v)}
|
||||
disabled={formData.good_qty_auto_collect !== "Y"}
|
||||
>
|
||||
<SelectTrigger className={`h-8 w-[180px] text-xs ${formData.good_qty_auto_collect !== "Y" ? "opacity-50" : ""}`}>
|
||||
<SelectValue placeholder="수집데이터 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PLC_PRODUCTION_OPTIONS.good_qty.map((p) => (
|
||||
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ============ 자재투입 ============ */}
|
||||
{currentType === "material_input" && (
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">자재투입 설정</Label>
|
||||
<div className="mt-2 rounded-lg border border-sky-200 bg-sky-50 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-xs font-semibold text-sky-800">
|
||||
📦 BOM 구성 자재 (자동 연동)
|
||||
</p>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{bomMaterials.length > 0 ? `${bomMaterials.length}건` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-y-auto rounded-lg border bg-white">
|
||||
{bomLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-6">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">BOM 데이터를 불러오는 중...</span>
|
||||
</div>
|
||||
) : !selectedItemCode ? (
|
||||
<div className="py-5 text-center text-xs text-muted-foreground">
|
||||
품목을 먼저 선택하세요.
|
||||
</div>
|
||||
) : bomMaterials.length === 0 ? (
|
||||
<div className="py-5 text-center text-xs text-muted-foreground">
|
||||
해당 품목의 BOM 구성 자재가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
bomMaterials.map((mat) => {
|
||||
const typeColor =
|
||||
mat.child_item_type === "원자재" ? "#16a34a"
|
||||
: mat.child_item_type === "반제품" ? "#2563eb"
|
||||
: "#6b7280";
|
||||
return (
|
||||
<div
|
||||
key={mat.id}
|
||||
className="flex items-center gap-2.5 border-b px-3 py-2.5 last:border-b-0 hover:bg-sky-50/50"
|
||||
>
|
||||
<Checkbox
|
||||
checked={bomChecked.has(mat.child_item_id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setBomChecked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) next.add(mat.child_item_id);
|
||||
else next.delete(mat.child_item_id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{mat.child_item_type && (
|
||||
<span
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
||||
style={{ backgroundColor: typeColor }}
|
||||
>
|
||||
{mat.child_item_type}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{mat.child_item_code || "-"}
|
||||
</span>
|
||||
<span className="text-xs font-medium">
|
||||
{mat.child_item_name || "(이름 없음)"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted-foreground">
|
||||
소요량: {mat.quantity || "0"} {mat.detail_unit || mat.item_unit || ""}
|
||||
{mat.process_type ? ` | 공정: ${mat.process_type}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 필수 여부 (모든 유형 공통) */}
|
||||
{currentType && (
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ interface WorkItemDetailListProps {
|
|||
details: WorkItemDetail[];
|
||||
detailTypes: DetailTypeDefinition[];
|
||||
readonly?: boolean;
|
||||
selectedItemCode?: string;
|
||||
onCreateDetail: (data: Partial<WorkItemDetail>) => void;
|
||||
onUpdateDetail: (id: string, data: Partial<WorkItemDetail>) => void;
|
||||
onDeleteDetail: (id: string) => void;
|
||||
|
|
@ -23,6 +24,7 @@ export function WorkItemDetailList({
|
|||
details,
|
||||
detailTypes,
|
||||
readonly,
|
||||
selectedItemCode,
|
||||
onCreateDetail,
|
||||
onUpdateDetail,
|
||||
onDeleteDetail,
|
||||
|
|
@ -66,11 +68,12 @@ export function WorkItemDetailList({
|
|||
|
||||
const getContentSummary = (detail: WorkItemDetail): string => {
|
||||
const type = detail.detail_type;
|
||||
if (type === "inspect" && detail.inspection_code) {
|
||||
if (type === "inspection") {
|
||||
if (detail.process_inspection_apply === "apply") return "품목별 검사정보 (자동 연동)";
|
||||
const parts = [detail.content];
|
||||
if (detail.inspection_method) parts.push(`[${detail.inspection_method}]`);
|
||||
if (detail.lower_limit || detail.upper_limit) {
|
||||
parts.push(`(${detail.lower_limit || "-"} ~ ${detail.upper_limit || "-"} ${detail.unit || ""})`);
|
||||
if (detail.base_value) {
|
||||
parts.push(`(기준: ${detail.base_value}${detail.tolerance ? ` ±${detail.tolerance}` : ""} ${detail.unit || ""})`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
|
@ -83,20 +86,24 @@ export function WorkItemDetailList({
|
|||
number: "숫자",
|
||||
date: "날짜",
|
||||
textarea: "장문",
|
||||
select: "선택형",
|
||||
};
|
||||
return `${detail.content} [${typeMap[detail.input_type] || detail.input_type}]`;
|
||||
}
|
||||
if (type === "info" && detail.lookup_target) {
|
||||
const targetMap: Record<string, string> = {
|
||||
equipment: "설비정보",
|
||||
material: "자재정보",
|
||||
worker: "작업자정보",
|
||||
tool: "공구정보",
|
||||
document: "문서정보",
|
||||
};
|
||||
return `${targetMap[detail.lookup_target] || detail.lookup_target} 조회`;
|
||||
if (type === "lookup") return "품목 등록 문서 (자동 연동)";
|
||||
if (type === "equip_inspection") {
|
||||
return detail.equip_inspection_apply === "apply"
|
||||
? "설비 점검항목 (설비정보 연동)"
|
||||
: detail.content || "설비점검";
|
||||
}
|
||||
if (type === "equip_condition") {
|
||||
const parts = [detail.content];
|
||||
if (detail.condition_base_value) {
|
||||
parts.push(`(기준: ${detail.condition_base_value}${detail.condition_tolerance ? ` ±${detail.condition_tolerance}` : ""} ${detail.condition_unit || ""})`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
if (type === "production_result") return "작업수량 / 불량수량 / 양품수량";
|
||||
if (type === "material_input") return "BOM 구성 자재 (자동 연동)";
|
||||
return detail.content || "-";
|
||||
};
|
||||
|
||||
|
|
@ -214,6 +221,7 @@ export function WorkItemDetailList({
|
|||
detailTypes={detailTypes}
|
||||
editData={editTarget}
|
||||
mode={modalMode}
|
||||
selectedItemCode={selectedItemCode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue