From bf8d99ccf5a11e2f7c683098e4af650b4f44ee85 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 28 Apr 2026 16:14:27 +0900 Subject: [PATCH] Implement KPI daily production feature - Added a new KPI controller to handle daily production data retrieval. - Created routes for accessing KPI data, specifically for daily production. - Developed frontend components for displaying daily production metrics, including charts and summary cards. - Implemented data fetching logic with date range filtering for production data. - Ensured proper loading states and error handling in the UI. This feature is part of TASK:ERP-022. --- backend-node/src/app.ts | 2 + backend-node/src/controllers/kpiController.ts | 48 +++++ .../src/controllers/processInfoController.ts | 6 +- backend-node/src/routes/kpiRoutes.ts | 13 ++ .../process-info/ItemRoutingTab.tsx | 46 +++-- .../COMPANY_16/kpi/production/daily/page.tsx | 177 +++++++++++++++++ .../process-info/ItemRoutingTab.tsx | 46 +++-- .../COMPANY_16/production/result/page.tsx | 44 ++++- .../process-info/ItemRoutingTab.tsx | 46 +++-- .../COMPANY_30/kpi/production/daily/page.tsx | 185 ++++++++++++++++++ .../process-info/ItemRoutingTab.tsx | 46 +++-- .../process-info/ItemRoutingTab.tsx | 46 +++-- .../process-info/ItemRoutingTab.tsx | 46 +++-- .../process-info/ItemRoutingTab.tsx | 46 +++-- .../components/layout/AdminPageRenderer.tsx | 5 + frontend/lib/api/processInfo.ts | 2 + 16 files changed, 716 insertions(+), 88 deletions(-) create mode 100644 backend-node/src/controllers/kpiController.ts create mode 100644 backend-node/src/routes/kpiRoutes.ts create mode 100644 frontend/app/(main)/COMPANY_16/kpi/production/daily/page.tsx create mode 100644 frontend/app/(main)/COMPANY_30/kpi/production/daily/page.tsx diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 389f8b11..d8298d9e 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -149,6 +149,7 @@ import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다 import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계 import categoryTreeRoutes from "./routes/categoryTreeRoutes"; // 카테고리 트리 (테스트) import processWorkStandardRoutes from "./routes/processWorkStandardRoutes"; // 공정 작업기준 +import kpiRoutes from "./routes/kpiRoutes"; // KPI (TASK:ERP-022) import aiAssistantProxy from "./routes/aiAssistantProxy"; // AI 어시스턴트 API 프록시 (같은 포트로 서비스) import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력 import moldRoutes from "./routes/moldRoutes"; // 금형 관리 @@ -378,6 +379,7 @@ app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계 app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계 app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테스트) app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작업기준 +app.use("/api/kpi", kpiRoutes); // KPI (TASK:ERP-022) app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력 app.use("/api/mold", moldRoutes); // 금형 관리 app.use("/api/shipping-plan", shippingPlanRoutes); // 출하계획 관리 diff --git a/backend-node/src/controllers/kpiController.ts b/backend-node/src/controllers/kpiController.ts new file mode 100644 index 00000000..35519f57 --- /dev/null +++ b/backend-node/src/controllers/kpiController.ts @@ -0,0 +1,48 @@ +/** + * KPI 컨트롤러 — TASK:ERP-022 + * 일별 생산량 등 KPI 지표 조회 전담 + */ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +/** + * GET /api/kpi/daily-production?from=YYYY-MM-DD&to=YYYY-MM-DD + * 회사별 일별 생산량 조회 + */ +export async function getDailyProduction(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 필요" }); + } + + const from = (req.query.from as string) || ""; + const to = (req.query.to as string) || ""; + + const params: any[] = [companyCode]; + let where = "company_code = $1"; + if (from) { + params.push(from); + where += ` AND prod_date >= $${params.length}`; + } + if (to) { + params.push(to); + where += ` AND prod_date <= $${params.length}`; + } + + const result = await getPool().query( + `SELECT prod_date, production_qty, defect_qty, work_hours, remark + FROM kpi_daily_production + WHERE ${where} + ORDER BY prod_date`, + params + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("KPI 일별 생산량 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/controllers/processInfoController.ts b/backend-node/src/controllers/processInfoController.ts index 22dfe51d..96af4e7b 100644 --- a/backend-node/src/controllers/processInfoController.ts +++ b/backend-node/src/controllers/processInfoController.ts @@ -468,10 +468,10 @@ export async function saveRoutingDetails(req: AuthenticatedRequest, res: Respons } const insertRes = 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) + `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, execution_type, writer) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id`, - [companyCode, versionId, d.seq_no, d.process_code, d.is_required || "Y", d.is_fixed_order || "Y", d.work_type || "내부", d.standard_time || "0", legacyCode, writer] + [companyCode, versionId, d.seq_no, d.process_code, d.is_required || "Y", d.is_fixed_order || "Y", d.work_type || "내부", d.standard_time || "0", legacyCode, d.execution_type || null, writer] ); const newDetailId = insertRes.rows[0].id; diff --git a/backend-node/src/routes/kpiRoutes.ts b/backend-node/src/routes/kpiRoutes.ts new file mode 100644 index 00000000..e043eeee --- /dev/null +++ b/backend-node/src/routes/kpiRoutes.ts @@ -0,0 +1,13 @@ +/** + * KPI 라우트 — TASK:ERP-022 + */ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as ctrl from "../controllers/kpiController"; + +const router = Router(); +router.use(authenticateToken); + +router.get("/daily-production", ctrl.getDailyProduction); + +export default router; diff --git a/frontend/app/(main)/COMPANY_10/production/process-info/ItemRoutingTab.tsx b/frontend/app/(main)/COMPANY_10/production/process-info/ItemRoutingTab.tsx index 18bb8106..ec0c8fb1 100644 --- a/frontend/app/(main)/COMPANY_10/production/process-info/ItemRoutingTab.tsx +++ b/frontend/app/(main)/COMPANY_10/production/process-info/ItemRoutingTab.tsx @@ -92,6 +92,7 @@ export function ItemRoutingTab() { const [formFixedOrder, setFormFixedOrder] = useState("Y"); const [formWorkType, setFormWorkType] = useState("내부"); const [formStandardTime, setFormStandardTime] = useState(""); + const [formExecutionType, setFormExecutionType] = useState("serial"); // serial=순차 / parallel=병렬 const [formOutsources, setFormOutsources] = useState([]); const [subcontractorOptions, setSubcontractorOptions] = useState<{ id: string; code: string; name: string }[]>([]); const [detailSubmitting, setDetailSubmitting] = useState(false); @@ -282,6 +283,7 @@ export function ItemRoutingTab() { setFormFixedOrder("Y"); setFormWorkType("내부"); setFormStandardTime(""); + setFormExecutionType("serial"); setFormOutsources([]); setDetailDialogOpen(true); }; @@ -309,6 +311,7 @@ export function ItemRoutingTab() { setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y"); setFormWorkType(row.work_type || "내부"); setFormStandardTime(row.standard_time || ""); + setFormExecutionType(row.execution_type === "parallel" ? "parallel" : "serial"); // 우선순위: id 배열 → legacy code 배열(id로 역변환) → legacy 단일 code(id로 역변환) let loadedIds: string[] = []; if (Array.isArray(row.outsource_supplier_ids) && row.outsource_supplier_ids.length > 0) { @@ -362,6 +365,7 @@ export function ItemRoutingTab() { standard_time: st || "0", outsource_supplier: outsourcePrimaryCode, outsource_supplier_ids: outsourceIds, + execution_type: formExecutionType, }; setDetails((prev) => sortDetailsBySeq([...prev, newRow])); toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요"); @@ -381,6 +385,7 @@ export function ItemRoutingTab() { standard_time: st || "0", outsource_supplier: outsourcePrimaryCode, outsource_supplier_ids: outsourceIds, + execution_type: formExecutionType, } : d, ), @@ -418,6 +423,7 @@ export function ItemRoutingTab() { standard_time: String(d.standard_time ?? "0"), outsource_supplier: d.outsource_supplier || "", outsource_supplier_ids: d.outsource_supplier_ids || [], + execution_type: d.execution_type || "serial", })); setSaving(true); @@ -514,6 +520,7 @@ export function ItemRoutingTab() { ...d, process_display: d.process_name || d.process_code, outsource_display: names.length === 0 ? "—" : names.join(", "), + execution_display: d.execution_type === "parallel" ? "병렬" : "순차", }; }), [details, subcontractorOptions], @@ -777,6 +784,7 @@ export function ItemRoutingTab() { { key: "process_display", label: "공정명" }, { key: "is_required", label: "필수", width: "w-[80px]", align: "center" as const }, { key: "is_fixed_order", label: "순서고정", width: "w-[90px]", align: "center" as const }, + { key: "execution_display", label: "실행방식", width: "w-[90px]", align: "center" as const }, { key: "work_type", label: "작업구분", width: "w-[100px]" }, { key: "standard_time", label: "표준시간", width: "w-[90px]", align: "right" as const }, { key: "outsource_display", label: "외주업체" }, @@ -913,18 +921,32 @@ export function ItemRoutingTab() { -
- - +
+
+ + +
+
+ + +
diff --git a/frontend/app/(main)/COMPANY_16/kpi/production/daily/page.tsx b/frontend/app/(main)/COMPANY_16/kpi/production/daily/page.tsx new file mode 100644 index 00000000..1d6fed5b --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/kpi/production/daily/page.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useState, useEffect, useMemo, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card } from "@/components/ui/card"; +import { Loader2, Search } from "lucide-react"; +import { toast } from "sonner"; +import { apiClient } from "@/lib/api/client"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; +import { + ComposedChart, Bar, Line, XAxis, YAxis, CartesianGrid, + Tooltip, Legend, ResponsiveContainer, +} from "recharts"; + +// TASK:ERP-022 — KPI 일별 생산량 (COMPANY_16) +type Row = { + prod_date: string; + production_qty: number; + defect_qty: number; + work_hours: number; + remark?: string | null; +}; + +function defaultRange() { + const now = new Date(); + const y = now.getFullYear(); + const m = now.getMonth(); + const start = new Date(y, m, 1); + const end = new Date(y, m + 1, 0); + const fmt = (d: Date) => d.toISOString().slice(0, 10); + return { from: fmt(start), to: fmt(end) }; +} + +export default function KpiDailyProductionPage() { + const init = defaultRange(); + const [from, setFrom] = useState(init.from); + const [to, setTo] = useState(init.to); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + + const fetchData = useCallback(async () => { + if (!from || !to) { + toast.error("기간을 입력해주세요"); + return; + } + setLoading(true); + try { + const res = await apiClient.get(`/kpi/daily-production`, { params: { from, to } }); + const data: Row[] = res.data?.data || []; + setRows(data); + } catch (err: any) { + toast.error(err?.response?.data?.message || "조회 실패"); + } finally { + setLoading(false); + } + }, [from, to]); + + useEffect(() => { + fetchData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const chartData = useMemo(() => { + return rows.map((r) => { + const d = new Date(r.prod_date); + const label = `${d.getMonth() + 1}/${d.getDate()}`; + const hourly = Number(r.work_hours) > 0 + ? Number(r.production_qty) / Number(r.work_hours) + : 0; + return { + label, + production_qty: Number(r.production_qty) || 0, + work_hours: Number(r.work_hours) || 0, + hourly: Math.round(hourly * 100) / 100, + }; + }); + }, [rows]); + + const summary = useMemo(() => { + const totalQty = rows.reduce((a, r) => a + (Number(r.production_qty) || 0), 0); + const totalHours = rows.reduce((a, r) => a + (Number(r.work_hours) || 0), 0); + const totalDefect = rows.reduce((a, r) => a + (Number(r.defect_qty) || 0), 0); + const hourly = totalHours > 0 ? totalQty / totalHours : 0; + return { + totalQty, + totalHours: Math.round(totalHours * 100) / 100, + totalDefect, + hourly: Math.round(hourly * 100) / 100, + }; + }, [rows]); + + const columns = useMemo(() => [ + { key: "prod_date", label: "일자", width: "120px", + render: (_v, row) => row.prod_date ? String(row.prod_date).slice(0, 10) : "-" }, + { key: "production_qty", label: "생산수량", width: "120px", align: "right", formatNumber: true }, + { key: "defect_qty", label: "불량수량", width: "100px", align: "right", formatNumber: true }, + { key: "work_hours", label: "작업시간(h)", width: "120px", align: "right", + render: (_v, row) => Number(row.work_hours || 0).toFixed(2) }, + { key: "hourly", label: "시간당 생산량", width: "140px", align: "right", + render: (_v, row) => { + const wh = Number(row.work_hours) || 0; + if (wh === 0) return "-"; + const v = Number(row.production_qty) / wh; + return v.toFixed(2); + } }, + ], []); + + return ( +
+
+
+ + setFrom(e.target.value)} className="h-9 w-40" /> +
+
+ + setTo(e.target.value)} className="h-9 w-40" /> +
+ +
+ +
+ +
총 생산수량
+
{summary.totalQty.toLocaleString()}
+
+ +
총 작업시간 (h)
+
{summary.totalHours.toLocaleString()}
+
+ +
총 불량수량
+
{summary.totalDefect.toLocaleString()}
+
+ +
시간당 평균 생산량
+
{summary.hourly.toLocaleString()}
+
+
+ + +
일별 생산량 / 작업시간
+
+ + + + + + + + + + + + +
+
+ +
+ r.prod_date} + columnOrderKey="c16-kpi-daily-production" + /> +
+
+ ); +} diff --git a/frontend/app/(main)/COMPANY_16/production/process-info/ItemRoutingTab.tsx b/frontend/app/(main)/COMPANY_16/production/process-info/ItemRoutingTab.tsx index 18bb8106..6cee0035 100644 --- a/frontend/app/(main)/COMPANY_16/production/process-info/ItemRoutingTab.tsx +++ b/frontend/app/(main)/COMPANY_16/production/process-info/ItemRoutingTab.tsx @@ -91,6 +91,7 @@ export function ItemRoutingTab() { const [formRequired, setFormRequired] = useState("Y"); const [formFixedOrder, setFormFixedOrder] = useState("Y"); const [formWorkType, setFormWorkType] = useState("내부"); + const [formExecutionType, setFormExecutionType] = useState("serial"); // serial=순차 / parallel=병렬 const [formStandardTime, setFormStandardTime] = useState(""); const [formOutsources, setFormOutsources] = useState([]); const [subcontractorOptions, setSubcontractorOptions] = useState<{ id: string; code: string; name: string }[]>([]); @@ -281,6 +282,7 @@ export function ItemRoutingTab() { setFormRequired("Y"); setFormFixedOrder("Y"); setFormWorkType("내부"); + setFormExecutionType("serial"); setFormStandardTime(""); setFormOutsources([]); setDetailDialogOpen(true); @@ -308,6 +310,7 @@ export function ItemRoutingTab() { setFormRequired(row.is_required === "N" ? "N" : "Y"); setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y"); setFormWorkType(row.work_type || "내부"); + setFormExecutionType(row.execution_type === "parallel" ? "parallel" : "serial"); setFormStandardTime(row.standard_time || ""); // 우선순위: id 배열 → legacy code 배열(id로 역변환) → legacy 단일 code(id로 역변환) let loadedIds: string[] = []; @@ -362,6 +365,7 @@ export function ItemRoutingTab() { standard_time: st || "0", outsource_supplier: outsourcePrimaryCode, outsource_supplier_ids: outsourceIds, + execution_type: formExecutionType, }; setDetails((prev) => sortDetailsBySeq([...prev, newRow])); toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요"); @@ -381,6 +385,7 @@ export function ItemRoutingTab() { standard_time: st || "0", outsource_supplier: outsourcePrimaryCode, outsource_supplier_ids: outsourceIds, + execution_type: formExecutionType, } : d, ), @@ -418,6 +423,7 @@ export function ItemRoutingTab() { standard_time: String(d.standard_time ?? "0"), outsource_supplier: d.outsource_supplier || "", outsource_supplier_ids: d.outsource_supplier_ids || [], + execution_type: d.execution_type || "serial", })); setSaving(true); @@ -514,6 +520,7 @@ export function ItemRoutingTab() { ...d, process_display: d.process_name || d.process_code, outsource_display: names.length === 0 ? "—" : names.join(", "), + execution_display: d.execution_type === "parallel" ? "병렬" : "순차", }; }), [details, subcontractorOptions], @@ -777,6 +784,7 @@ export function ItemRoutingTab() { { key: "process_display", label: "공정명" }, { key: "is_required", label: "필수", width: "w-[80px]", align: "center" as const }, { key: "is_fixed_order", label: "순서고정", width: "w-[90px]", align: "center" as const }, + { key: "execution_display", label: "실행방식", width: "w-[90px]", align: "center" as const }, { key: "work_type", label: "작업구분", width: "w-[100px]" }, { key: "standard_time", label: "표준시간", width: "w-[90px]", align: "right" as const }, { key: "outsource_display", label: "외주업체" }, @@ -913,18 +921,32 @@ export function ItemRoutingTab() {
-
- - +
+
+ + +
+
+ + +
diff --git a/frontend/app/(main)/COMPANY_16/production/result/page.tsx b/frontend/app/(main)/COMPANY_16/production/result/page.tsx index 151bc0bf..6d1c49be 100644 --- a/frontend/app/(main)/COMPANY_16/production/result/page.tsx +++ b/frontend/app/(main)/COMPANY_16/production/result/page.tsx @@ -182,13 +182,55 @@ export default function ProductionResultPage() { const load = async () => { setProcessLoading(true); try { + // 1) 공정 마스터 조회 const res = await apiClient.post(`/table-management/tables/${WOP_TABLE}/data`, { page: 1, size: 0, dataFilter: { enabled: true, filters: [{ columnName: "wo_id", operator: "equals", value: selectedWiId }] }, autoFilter: true, sort: { columnName: "seq_no", order: "asc" }, }); - setProcessData(res.data?.data?.data || res.data?.data?.rows || []); + const wops: any[] = res.data?.data?.data || res.data?.data?.rows || []; + + // 2) 공정별 실적(work_order_process_result) 합계 조인 — 화면이 양품/불량/입력 합계를 보여주려면 result를 별도 집계해 매핑해야 함 + if (wops.length > 0) { + const wopIds = wops.map((w) => w.id); + try { + const wr = await apiClient.post(`/table-management/tables/work_order_process_result/data`, { + page: 1, size: 0, + dataFilter: { enabled: true, filters: [{ columnName: "wop_id", operator: "in", value: wopIds }] }, + autoFilter: true, + }); + const results: any[] = wr.data?.data?.data || wr.data?.data?.rows || []; + const agg = new Map }>(); + for (const r of results) { + const cur = agg.get(r.wop_id) || { good: 0, defect: 0, input: 0, firstStart: null, lastEnd: null, statuses: new Set() }; + cur.good += Number(r.good_qty) || 0; + cur.defect += Number(r.defect_qty) || 0; + cur.input += Number(r.input_qty) || 0; + if (r.started_at && (!cur.firstStart || String(r.started_at) < cur.firstStart)) cur.firstStart = String(r.started_at); + if (r.completed_at && (!cur.lastEnd || String(r.completed_at) > cur.lastEnd)) cur.lastEnd = String(r.completed_at); + if (r.status) cur.statuses.add(String(r.status)); + agg.set(r.wop_id, cur); + } + const enriched = wops.map((w) => { + const a = agg.get(w.id); + return { + ...w, + good_qty: a?.good ?? 0, + defect_qty: a?.defect ?? 0, + input_qty: a?.input ?? 0, + started_at: a?.firstStart ?? null, + completed_at: a?.lastEnd ?? null, + result_status: a?.statuses.has("completed") ? "completed" : (a?.statuses.values().next().value ?? null), + }; + }); + setProcessData(enriched); + } catch { + setProcessData(wops); + } + } else { + setProcessData([]); + } } catch { setProcessData([]); } finally { setProcessLoading(false); } }; diff --git a/frontend/app/(main)/COMPANY_29/production/process-info/ItemRoutingTab.tsx b/frontend/app/(main)/COMPANY_29/production/process-info/ItemRoutingTab.tsx index 18bb8106..6cee0035 100644 --- a/frontend/app/(main)/COMPANY_29/production/process-info/ItemRoutingTab.tsx +++ b/frontend/app/(main)/COMPANY_29/production/process-info/ItemRoutingTab.tsx @@ -91,6 +91,7 @@ export function ItemRoutingTab() { const [formRequired, setFormRequired] = useState("Y"); const [formFixedOrder, setFormFixedOrder] = useState("Y"); const [formWorkType, setFormWorkType] = useState("내부"); + const [formExecutionType, setFormExecutionType] = useState("serial"); // serial=순차 / parallel=병렬 const [formStandardTime, setFormStandardTime] = useState(""); const [formOutsources, setFormOutsources] = useState([]); const [subcontractorOptions, setSubcontractorOptions] = useState<{ id: string; code: string; name: string }[]>([]); @@ -281,6 +282,7 @@ export function ItemRoutingTab() { setFormRequired("Y"); setFormFixedOrder("Y"); setFormWorkType("내부"); + setFormExecutionType("serial"); setFormStandardTime(""); setFormOutsources([]); setDetailDialogOpen(true); @@ -308,6 +310,7 @@ export function ItemRoutingTab() { setFormRequired(row.is_required === "N" ? "N" : "Y"); setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y"); setFormWorkType(row.work_type || "내부"); + setFormExecutionType(row.execution_type === "parallel" ? "parallel" : "serial"); setFormStandardTime(row.standard_time || ""); // 우선순위: id 배열 → legacy code 배열(id로 역변환) → legacy 단일 code(id로 역변환) let loadedIds: string[] = []; @@ -362,6 +365,7 @@ export function ItemRoutingTab() { standard_time: st || "0", outsource_supplier: outsourcePrimaryCode, outsource_supplier_ids: outsourceIds, + execution_type: formExecutionType, }; setDetails((prev) => sortDetailsBySeq([...prev, newRow])); toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요"); @@ -381,6 +385,7 @@ export function ItemRoutingTab() { standard_time: st || "0", outsource_supplier: outsourcePrimaryCode, outsource_supplier_ids: outsourceIds, + execution_type: formExecutionType, } : d, ), @@ -418,6 +423,7 @@ export function ItemRoutingTab() { standard_time: String(d.standard_time ?? "0"), outsource_supplier: d.outsource_supplier || "", outsource_supplier_ids: d.outsource_supplier_ids || [], + execution_type: d.execution_type || "serial", })); setSaving(true); @@ -514,6 +520,7 @@ export function ItemRoutingTab() { ...d, process_display: d.process_name || d.process_code, outsource_display: names.length === 0 ? "—" : names.join(", "), + execution_display: d.execution_type === "parallel" ? "병렬" : "순차", }; }), [details, subcontractorOptions], @@ -777,6 +784,7 @@ export function ItemRoutingTab() { { key: "process_display", label: "공정명" }, { key: "is_required", label: "필수", width: "w-[80px]", align: "center" as const }, { key: "is_fixed_order", label: "순서고정", width: "w-[90px]", align: "center" as const }, + { key: "execution_display", label: "실행방식", width: "w-[90px]", align: "center" as const }, { key: "work_type", label: "작업구분", width: "w-[100px]" }, { key: "standard_time", label: "표준시간", width: "w-[90px]", align: "right" as const }, { key: "outsource_display", label: "외주업체" }, @@ -913,18 +921,32 @@ export function ItemRoutingTab() {
-
- - +
+
+ + +
+
+ + +
diff --git a/frontend/app/(main)/COMPANY_30/kpi/production/daily/page.tsx b/frontend/app/(main)/COMPANY_30/kpi/production/daily/page.tsx new file mode 100644 index 00000000..a86dac62 --- /dev/null +++ b/frontend/app/(main)/COMPANY_30/kpi/production/daily/page.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { useState, useEffect, useMemo, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card } from "@/components/ui/card"; +import { Loader2, Search } from "lucide-react"; +import { toast } from "sonner"; +import { apiClient } from "@/lib/api/client"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; +import { + ComposedChart, Bar, Line, XAxis, YAxis, CartesianGrid, + Tooltip, Legend, ResponsiveContainer, +} from "recharts"; + +// TASK:ERP-022 — KPI 일별 생산량 (COMPANY_30) +type Row = { + prod_date: string; + production_qty: number; + defect_qty: number; + work_hours: number; + remark?: string | null; +}; + +// 기본 기간: 이번 달 1일 ~ 말일 +function defaultRange() { + const now = new Date(); + const y = now.getFullYear(); + const m = now.getMonth(); + const start = new Date(y, m, 1); + const end = new Date(y, m + 1, 0); + const fmt = (d: Date) => d.toISOString().slice(0, 10); + return { from: fmt(start), to: fmt(end) }; +} + +export default function KpiDailyProductionPage() { + const init = defaultRange(); + const [from, setFrom] = useState(init.from); + const [to, setTo] = useState(init.to); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + + const fetchData = useCallback(async () => { + if (!from || !to) { + toast.error("기간을 입력해주세요"); + return; + } + setLoading(true); + try { + const res = await apiClient.get(`/kpi/daily-production`, { params: { from, to } }); + const data: Row[] = res.data?.data || []; + setRows(data); + } catch (err: any) { + toast.error(err?.response?.data?.message || "조회 실패"); + } finally { + setLoading(false); + } + }, [from, to]); + + useEffect(() => { + fetchData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // 차트용 데이터: 일자만 짧게 (M/D) + const chartData = useMemo(() => { + return rows.map((r) => { + const d = new Date(r.prod_date); + const label = `${d.getMonth() + 1}/${d.getDate()}`; + const hourly = Number(r.work_hours) > 0 + ? Number(r.production_qty) / Number(r.work_hours) + : 0; + return { + label, + production_qty: Number(r.production_qty) || 0, + work_hours: Number(r.work_hours) || 0, + hourly: Math.round(hourly * 100) / 100, + }; + }); + }, [rows]); + + // 합계 + const summary = useMemo(() => { + const totalQty = rows.reduce((a, r) => a + (Number(r.production_qty) || 0), 0); + const totalHours = rows.reduce((a, r) => a + (Number(r.work_hours) || 0), 0); + const totalDefect = rows.reduce((a, r) => a + (Number(r.defect_qty) || 0), 0); + const hourly = totalHours > 0 ? totalQty / totalHours : 0; + return { + totalQty, + totalHours: Math.round(totalHours * 100) / 100, + totalDefect, + hourly: Math.round(hourly * 100) / 100, + }; + }, [rows]); + + // 테이블 컬럼 + const columns = useMemo(() => [ + { key: "prod_date", label: "일자", width: "120px", + render: (_v, row) => row.prod_date ? String(row.prod_date).slice(0, 10) : "-" }, + { key: "production_qty", label: "생산수량", width: "120px", align: "right", formatNumber: true }, + { key: "defect_qty", label: "불량수량", width: "100px", align: "right", formatNumber: true }, + { key: "work_hours", label: "작업시간(h)", width: "120px", align: "right", + render: (_v, row) => Number(row.work_hours || 0).toFixed(2) }, + { key: "hourly", label: "시간당 생산량", width: "140px", align: "right", + render: (_v, row) => { + const wh = Number(row.work_hours) || 0; + if (wh === 0) return "-"; + const v = Number(row.production_qty) / wh; + return v.toFixed(2); + } }, + ], []); + + return ( +
+ {/* 필터 바 */} +
+
+ + setFrom(e.target.value)} className="h-9 w-40" /> +
+
+ + setTo(e.target.value)} className="h-9 w-40" /> +
+ +
+ + {/* 요약 카드 */} +
+ +
총 생산수량
+
{summary.totalQty.toLocaleString()}
+
+ +
총 작업시간 (h)
+
{summary.totalHours.toLocaleString()}
+
+ +
총 불량수량
+
{summary.totalDefect.toLocaleString()}
+
+ +
시간당 평균 생산량
+
{summary.hourly.toLocaleString()}
+
+
+ + {/* 차트 */} + +
일별 생산량 / 작업시간
+
+ + + + + + + + + + + + +
+
+ + {/* 테이블 */} +
+ r.prod_date} + columnOrderKey="c30-kpi-daily-production" + /> +
+
+ ); +} diff --git a/frontend/app/(main)/COMPANY_30/production/process-info/ItemRoutingTab.tsx b/frontend/app/(main)/COMPANY_30/production/process-info/ItemRoutingTab.tsx index 18bb8106..ec0c8fb1 100644 --- a/frontend/app/(main)/COMPANY_30/production/process-info/ItemRoutingTab.tsx +++ b/frontend/app/(main)/COMPANY_30/production/process-info/ItemRoutingTab.tsx @@ -92,6 +92,7 @@ export function ItemRoutingTab() { const [formFixedOrder, setFormFixedOrder] = useState("Y"); const [formWorkType, setFormWorkType] = useState("내부"); const [formStandardTime, setFormStandardTime] = useState(""); + const [formExecutionType, setFormExecutionType] = useState("serial"); // serial=순차 / parallel=병렬 const [formOutsources, setFormOutsources] = useState([]); const [subcontractorOptions, setSubcontractorOptions] = useState<{ id: string; code: string; name: string }[]>([]); const [detailSubmitting, setDetailSubmitting] = useState(false); @@ -282,6 +283,7 @@ export function ItemRoutingTab() { setFormFixedOrder("Y"); setFormWorkType("내부"); setFormStandardTime(""); + setFormExecutionType("serial"); setFormOutsources([]); setDetailDialogOpen(true); }; @@ -309,6 +311,7 @@ export function ItemRoutingTab() { setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y"); setFormWorkType(row.work_type || "내부"); setFormStandardTime(row.standard_time || ""); + setFormExecutionType(row.execution_type === "parallel" ? "parallel" : "serial"); // 우선순위: id 배열 → legacy code 배열(id로 역변환) → legacy 단일 code(id로 역변환) let loadedIds: string[] = []; if (Array.isArray(row.outsource_supplier_ids) && row.outsource_supplier_ids.length > 0) { @@ -362,6 +365,7 @@ export function ItemRoutingTab() { standard_time: st || "0", outsource_supplier: outsourcePrimaryCode, outsource_supplier_ids: outsourceIds, + execution_type: formExecutionType, }; setDetails((prev) => sortDetailsBySeq([...prev, newRow])); toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요"); @@ -381,6 +385,7 @@ export function ItemRoutingTab() { standard_time: st || "0", outsource_supplier: outsourcePrimaryCode, outsource_supplier_ids: outsourceIds, + execution_type: formExecutionType, } : d, ), @@ -418,6 +423,7 @@ export function ItemRoutingTab() { standard_time: String(d.standard_time ?? "0"), outsource_supplier: d.outsource_supplier || "", outsource_supplier_ids: d.outsource_supplier_ids || [], + execution_type: d.execution_type || "serial", })); setSaving(true); @@ -514,6 +520,7 @@ export function ItemRoutingTab() { ...d, process_display: d.process_name || d.process_code, outsource_display: names.length === 0 ? "—" : names.join(", "), + execution_display: d.execution_type === "parallel" ? "병렬" : "순차", }; }), [details, subcontractorOptions], @@ -777,6 +784,7 @@ export function ItemRoutingTab() { { key: "process_display", label: "공정명" }, { key: "is_required", label: "필수", width: "w-[80px]", align: "center" as const }, { key: "is_fixed_order", label: "순서고정", width: "w-[90px]", align: "center" as const }, + { key: "execution_display", label: "실행방식", width: "w-[90px]", align: "center" as const }, { key: "work_type", label: "작업구분", width: "w-[100px]" }, { key: "standard_time", label: "표준시간", width: "w-[90px]", align: "right" as const }, { key: "outsource_display", label: "외주업체" }, @@ -913,18 +921,32 @@ export function ItemRoutingTab() {
-
- - +
+
+ + +
+
+ + +
diff --git a/frontend/app/(main)/COMPANY_7/production/process-info/ItemRoutingTab.tsx b/frontend/app/(main)/COMPANY_7/production/process-info/ItemRoutingTab.tsx index 18bb8106..ec0c8fb1 100644 --- a/frontend/app/(main)/COMPANY_7/production/process-info/ItemRoutingTab.tsx +++ b/frontend/app/(main)/COMPANY_7/production/process-info/ItemRoutingTab.tsx @@ -92,6 +92,7 @@ export function ItemRoutingTab() { const [formFixedOrder, setFormFixedOrder] = useState("Y"); const [formWorkType, setFormWorkType] = useState("내부"); const [formStandardTime, setFormStandardTime] = useState(""); + const [formExecutionType, setFormExecutionType] = useState("serial"); // serial=순차 / parallel=병렬 const [formOutsources, setFormOutsources] = useState([]); const [subcontractorOptions, setSubcontractorOptions] = useState<{ id: string; code: string; name: string }[]>([]); const [detailSubmitting, setDetailSubmitting] = useState(false); @@ -282,6 +283,7 @@ export function ItemRoutingTab() { setFormFixedOrder("Y"); setFormWorkType("내부"); setFormStandardTime(""); + setFormExecutionType("serial"); setFormOutsources([]); setDetailDialogOpen(true); }; @@ -309,6 +311,7 @@ export function ItemRoutingTab() { setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y"); setFormWorkType(row.work_type || "내부"); setFormStandardTime(row.standard_time || ""); + setFormExecutionType(row.execution_type === "parallel" ? "parallel" : "serial"); // 우선순위: id 배열 → legacy code 배열(id로 역변환) → legacy 단일 code(id로 역변환) let loadedIds: string[] = []; if (Array.isArray(row.outsource_supplier_ids) && row.outsource_supplier_ids.length > 0) { @@ -362,6 +365,7 @@ export function ItemRoutingTab() { standard_time: st || "0", outsource_supplier: outsourcePrimaryCode, outsource_supplier_ids: outsourceIds, + execution_type: formExecutionType, }; setDetails((prev) => sortDetailsBySeq([...prev, newRow])); toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요"); @@ -381,6 +385,7 @@ export function ItemRoutingTab() { standard_time: st || "0", outsource_supplier: outsourcePrimaryCode, outsource_supplier_ids: outsourceIds, + execution_type: formExecutionType, } : d, ), @@ -418,6 +423,7 @@ export function ItemRoutingTab() { standard_time: String(d.standard_time ?? "0"), outsource_supplier: d.outsource_supplier || "", outsource_supplier_ids: d.outsource_supplier_ids || [], + execution_type: d.execution_type || "serial", })); setSaving(true); @@ -514,6 +520,7 @@ export function ItemRoutingTab() { ...d, process_display: d.process_name || d.process_code, outsource_display: names.length === 0 ? "—" : names.join(", "), + execution_display: d.execution_type === "parallel" ? "병렬" : "순차", }; }), [details, subcontractorOptions], @@ -777,6 +784,7 @@ export function ItemRoutingTab() { { key: "process_display", label: "공정명" }, { key: "is_required", label: "필수", width: "w-[80px]", align: "center" as const }, { key: "is_fixed_order", label: "순서고정", width: "w-[90px]", align: "center" as const }, + { key: "execution_display", label: "실행방식", width: "w-[90px]", align: "center" as const }, { key: "work_type", label: "작업구분", width: "w-[100px]" }, { key: "standard_time", label: "표준시간", width: "w-[90px]", align: "right" as const }, { key: "outsource_display", label: "외주업체" }, @@ -913,18 +921,32 @@ export function ItemRoutingTab() {
-
- - +
+
+ + +
+
+ + +
diff --git a/frontend/app/(main)/COMPANY_8/production/process-info/ItemRoutingTab.tsx b/frontend/app/(main)/COMPANY_8/production/process-info/ItemRoutingTab.tsx index 18bb8106..ec0c8fb1 100644 --- a/frontend/app/(main)/COMPANY_8/production/process-info/ItemRoutingTab.tsx +++ b/frontend/app/(main)/COMPANY_8/production/process-info/ItemRoutingTab.tsx @@ -92,6 +92,7 @@ export function ItemRoutingTab() { const [formFixedOrder, setFormFixedOrder] = useState("Y"); const [formWorkType, setFormWorkType] = useState("내부"); const [formStandardTime, setFormStandardTime] = useState(""); + const [formExecutionType, setFormExecutionType] = useState("serial"); // serial=순차 / parallel=병렬 const [formOutsources, setFormOutsources] = useState([]); const [subcontractorOptions, setSubcontractorOptions] = useState<{ id: string; code: string; name: string }[]>([]); const [detailSubmitting, setDetailSubmitting] = useState(false); @@ -282,6 +283,7 @@ export function ItemRoutingTab() { setFormFixedOrder("Y"); setFormWorkType("내부"); setFormStandardTime(""); + setFormExecutionType("serial"); setFormOutsources([]); setDetailDialogOpen(true); }; @@ -309,6 +311,7 @@ export function ItemRoutingTab() { setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y"); setFormWorkType(row.work_type || "내부"); setFormStandardTime(row.standard_time || ""); + setFormExecutionType(row.execution_type === "parallel" ? "parallel" : "serial"); // 우선순위: id 배열 → legacy code 배열(id로 역변환) → legacy 단일 code(id로 역변환) let loadedIds: string[] = []; if (Array.isArray(row.outsource_supplier_ids) && row.outsource_supplier_ids.length > 0) { @@ -362,6 +365,7 @@ export function ItemRoutingTab() { standard_time: st || "0", outsource_supplier: outsourcePrimaryCode, outsource_supplier_ids: outsourceIds, + execution_type: formExecutionType, }; setDetails((prev) => sortDetailsBySeq([...prev, newRow])); toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요"); @@ -381,6 +385,7 @@ export function ItemRoutingTab() { standard_time: st || "0", outsource_supplier: outsourcePrimaryCode, outsource_supplier_ids: outsourceIds, + execution_type: formExecutionType, } : d, ), @@ -418,6 +423,7 @@ export function ItemRoutingTab() { standard_time: String(d.standard_time ?? "0"), outsource_supplier: d.outsource_supplier || "", outsource_supplier_ids: d.outsource_supplier_ids || [], + execution_type: d.execution_type || "serial", })); setSaving(true); @@ -514,6 +520,7 @@ export function ItemRoutingTab() { ...d, process_display: d.process_name || d.process_code, outsource_display: names.length === 0 ? "—" : names.join(", "), + execution_display: d.execution_type === "parallel" ? "병렬" : "순차", }; }), [details, subcontractorOptions], @@ -777,6 +784,7 @@ export function ItemRoutingTab() { { key: "process_display", label: "공정명" }, { key: "is_required", label: "필수", width: "w-[80px]", align: "center" as const }, { key: "is_fixed_order", label: "순서고정", width: "w-[90px]", align: "center" as const }, + { key: "execution_display", label: "실행방식", width: "w-[90px]", align: "center" as const }, { key: "work_type", label: "작업구분", width: "w-[100px]" }, { key: "standard_time", label: "표준시간", width: "w-[90px]", align: "right" as const }, { key: "outsource_display", label: "외주업체" }, @@ -913,18 +921,32 @@ export function ItemRoutingTab() {
-
- - +
+
+ + +
+
+ + +
diff --git a/frontend/app/(main)/COMPANY_9/production/process-info/ItemRoutingTab.tsx b/frontend/app/(main)/COMPANY_9/production/process-info/ItemRoutingTab.tsx index 18bb8106..ec0c8fb1 100644 --- a/frontend/app/(main)/COMPANY_9/production/process-info/ItemRoutingTab.tsx +++ b/frontend/app/(main)/COMPANY_9/production/process-info/ItemRoutingTab.tsx @@ -92,6 +92,7 @@ export function ItemRoutingTab() { const [formFixedOrder, setFormFixedOrder] = useState("Y"); const [formWorkType, setFormWorkType] = useState("내부"); const [formStandardTime, setFormStandardTime] = useState(""); + const [formExecutionType, setFormExecutionType] = useState("serial"); // serial=순차 / parallel=병렬 const [formOutsources, setFormOutsources] = useState([]); const [subcontractorOptions, setSubcontractorOptions] = useState<{ id: string; code: string; name: string }[]>([]); const [detailSubmitting, setDetailSubmitting] = useState(false); @@ -282,6 +283,7 @@ export function ItemRoutingTab() { setFormFixedOrder("Y"); setFormWorkType("내부"); setFormStandardTime(""); + setFormExecutionType("serial"); setFormOutsources([]); setDetailDialogOpen(true); }; @@ -309,6 +311,7 @@ export function ItemRoutingTab() { setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y"); setFormWorkType(row.work_type || "내부"); setFormStandardTime(row.standard_time || ""); + setFormExecutionType(row.execution_type === "parallel" ? "parallel" : "serial"); // 우선순위: id 배열 → legacy code 배열(id로 역변환) → legacy 단일 code(id로 역변환) let loadedIds: string[] = []; if (Array.isArray(row.outsource_supplier_ids) && row.outsource_supplier_ids.length > 0) { @@ -362,6 +365,7 @@ export function ItemRoutingTab() { standard_time: st || "0", outsource_supplier: outsourcePrimaryCode, outsource_supplier_ids: outsourceIds, + execution_type: formExecutionType, }; setDetails((prev) => sortDetailsBySeq([...prev, newRow])); toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요"); @@ -381,6 +385,7 @@ export function ItemRoutingTab() { standard_time: st || "0", outsource_supplier: outsourcePrimaryCode, outsource_supplier_ids: outsourceIds, + execution_type: formExecutionType, } : d, ), @@ -418,6 +423,7 @@ export function ItemRoutingTab() { standard_time: String(d.standard_time ?? "0"), outsource_supplier: d.outsource_supplier || "", outsource_supplier_ids: d.outsource_supplier_ids || [], + execution_type: d.execution_type || "serial", })); setSaving(true); @@ -514,6 +520,7 @@ export function ItemRoutingTab() { ...d, process_display: d.process_name || d.process_code, outsource_display: names.length === 0 ? "—" : names.join(", "), + execution_display: d.execution_type === "parallel" ? "병렬" : "순차", }; }), [details, subcontractorOptions], @@ -777,6 +784,7 @@ export function ItemRoutingTab() { { key: "process_display", label: "공정명" }, { key: "is_required", label: "필수", width: "w-[80px]", align: "center" as const }, { key: "is_fixed_order", label: "순서고정", width: "w-[90px]", align: "center" as const }, + { key: "execution_display", label: "실행방식", width: "w-[90px]", align: "center" as const }, { key: "work_type", label: "작업구분", width: "w-[100px]" }, { key: "standard_time", label: "표준시간", width: "w-[90px]", align: "right" as const }, { key: "outsource_display", label: "외주업체" }, @@ -913,18 +921,32 @@ export function ItemRoutingTab() {
-
- - +
+
+ + +
+
+ + +
diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx index 3662876a..397e5cc0 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -372,6 +372,8 @@ const ADMIN_PAGE_REGISTRY: Record> = { "/COMPANY_9/design/task-management": dynamic(() => import("@/app/(main)/COMPANY_9/design/task-management/page"), { ssr: false, loading: LoadingFallback }), // === COMPANY_30 === + "/COMPANY_30/kpi/production/daily": dynamic(() => import("@/app/(main)/COMPANY_30/kpi/production/daily/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_16/kpi/production/daily": dynamic(() => import("@/app/(main)/COMPANY_16/kpi/production/daily/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_30/master-data/item-info": dynamic(() => import("@/app/(main)/COMPANY_30/master-data/item-info/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_30/master-data/department": dynamic(() => import("@/app/(main)/COMPANY_30/master-data/department/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_30/master-data/company": dynamic(() => import("@/app/(main)/COMPANY_30/master-data/company/page"), { ssr: false, loading: LoadingFallback }), @@ -601,6 +603,8 @@ const DYNAMIC_ADMIN_IMPORTS: Record Promise> = { "/COMPANY_9/design/design-request": () => import("@/app/(main)/COMPANY_9/design/design-request/page"), "/COMPANY_9/design/task-management": () => import("@/app/(main)/COMPANY_9/design/task-management/page"), // COMPANY_30 + "/COMPANY_30/kpi/production/daily": () => import("@/app/(main)/COMPANY_30/kpi/production/daily/page"), + "/COMPANY_16/kpi/production/daily": () => import("@/app/(main)/COMPANY_16/kpi/production/daily/page"), "/COMPANY_30/master-data/item-info": () => import("@/app/(main)/COMPANY_30/master-data/item-info/page"), "/COMPANY_30/master-data/department": () => import("@/app/(main)/COMPANY_30/master-data/department/page"), "/COMPANY_30/master-data/company": () => import("@/app/(main)/COMPANY_30/master-data/company/page"), @@ -818,6 +822,7 @@ const COMPANY_PAGE_PREFIXES = [ "/quality/", "/mold/", "/monitoring/", + "/kpi/", ]; function isCompanyPage(url: string): boolean { diff --git a/frontend/lib/api/processInfo.ts b/frontend/lib/api/processInfo.ts index 594484cd..0f512fae 100644 --- a/frontend/lib/api/processInfo.ts +++ b/frontend/lib/api/processInfo.ts @@ -60,6 +60,8 @@ export interface RoutingDetail { outsource_supplier: string; outsource_supplier_ids?: string[]; outsource_supplier_list?: string[]; // legacy code 배열 (호환용) + /** 실행 방식 — 카테고리 코드 (item_routing_detail.execution_type 컬럼) */ + execution_type?: string; } interface ApiResponse {