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.
This commit is contained in:
kjs
2026-04-28 16:14:27 +09:00
parent 4afb0f5ca4
commit bf8d99ccf5
16 changed files with 716 additions and 88 deletions

View File

@@ -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); // 출하계획 관리

View File

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

View File

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

View File

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

View File

@@ -92,6 +92,7 @@ export function ItemRoutingTab() {
const [formFixedOrder, setFormFixedOrder] = useState("Y");
const [formWorkType, setFormWorkType] = useState("내부");
const [formStandardTime, setFormStandardTime] = useState("");
const [formExecutionType, setFormExecutionType] = useState<string>("serial"); // serial=순차 / parallel=병렬
const [formOutsources, setFormOutsources] = useState<string[]>([]);
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() {
</Select>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formWorkType} onValueChange={setFormWorkType}>
<SelectTrigger className="h-9" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="내부"></SelectItem>
<SelectItem value="외주"></SelectItem>
<SelectItem value="선택가능"></SelectItem>
</SelectContent>
</Select>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formWorkType} onValueChange={setFormWorkType}>
<SelectTrigger className="h-9" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="내부"></SelectItem>
<SelectItem value="외주"></SelectItem>
<SelectItem value="선택가능"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formExecutionType} onValueChange={setFormExecutionType}>
<SelectTrigger className="h-9" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="serial"> ()</SelectItem>
<SelectItem value="parallel"> ( )</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">()</Label>

View File

@@ -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<Row[]>([]);
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<EDataTableColumn[]>(() => [
{ 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 (
<div className="flex h-[calc(100vh-4rem)] flex-col gap-3 p-3 overflow-hidden">
<div className="shrink-0 flex items-end gap-2 flex-wrap">
<div className="flex flex-col gap-1">
<Label className="text-xs text-muted-foreground"></Label>
<Input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className="h-9 w-40" />
</div>
<div className="flex flex-col gap-1">
<Label className="text-xs text-muted-foreground"></Label>
<Input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="h-9 w-40" />
</div>
<Button onClick={fetchData} disabled={loading} className="h-9">
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <><Search className="h-4 w-4 mr-1" /></>}
</Button>
</div>
<div className="shrink-0 grid grid-cols-4 gap-3">
<Card className="p-3">
<div className="text-xs text-muted-foreground"> </div>
<div className="text-2xl font-bold mt-1">{summary.totalQty.toLocaleString()}</div>
</Card>
<Card className="p-3">
<div className="text-xs text-muted-foreground"> (h)</div>
<div className="text-2xl font-bold mt-1">{summary.totalHours.toLocaleString()}</div>
</Card>
<Card className="p-3">
<div className="text-xs text-muted-foreground"> </div>
<div className="text-2xl font-bold mt-1">{summary.totalDefect.toLocaleString()}</div>
</Card>
<Card className="p-3 bg-primary/5 border-primary/20">
<div className="text-xs text-muted-foreground"> </div>
<div className="text-2xl font-bold mt-1 text-primary">{summary.hourly.toLocaleString()}</div>
</Card>
</div>
<Card className="shrink-0 p-3">
<div className="text-sm font-semibold mb-2"> / </div>
<div style={{ width: "100%", height: 320 }}>
<ResponsiveContainer>
<ComposedChart data={chartData} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="label" tick={{ fontSize: 11 }} />
<YAxis yAxisId="left" tick={{ fontSize: 11 }} />
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 11 }} />
<Tooltip />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Bar yAxisId="left" dataKey="production_qty" name="생산수량" fill="#3b82f6" radius={[4, 4, 0, 0]} />
<Line yAxisId="right" type="monotone" dataKey="work_hours" name="작업시간(h)" stroke="#f97316" strokeWidth={2} dot={{ r: 3 }} />
</ComposedChart>
</ResponsiveContainer>
</div>
</Card>
<div className="flex flex-1 min-h-0 flex-col overflow-hidden border rounded-lg bg-card">
<EDataTable
columns={columns}
data={rows}
loading={loading}
emptyMessage="기간 내 데이터가 없어요"
showPagination
draggableColumns={false}
rowKey={(r: any) => r.prod_date}
columnOrderKey="c16-kpi-daily-production"
/>
</div>
</div>
);
}

View File

@@ -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<string>("serial"); // serial=순차 / parallel=병렬
const [formStandardTime, setFormStandardTime] = useState("");
const [formOutsources, setFormOutsources] = useState<string[]>([]);
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() {
</Select>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formWorkType} onValueChange={setFormWorkType}>
<SelectTrigger className="h-9" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="내부"></SelectItem>
<SelectItem value="외주"></SelectItem>
<SelectItem value="선택가능"></SelectItem>
</SelectContent>
</Select>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formWorkType} onValueChange={setFormWorkType}>
<SelectTrigger className="h-9" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="내부"></SelectItem>
<SelectItem value="외주"></SelectItem>
<SelectItem value="선택가능"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formExecutionType} onValueChange={setFormExecutionType}>
<SelectTrigger className="h-9" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="serial"> ()</SelectItem>
<SelectItem value="parallel"> ( )</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">()</Label>

View File

@@ -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<string, { good: number; defect: number; input: number; firstStart: string | null; lastEnd: string | null; statuses: Set<string> }>();
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<string>() };
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); }
};

View File

@@ -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<string>("serial"); // serial=순차 / parallel=병렬
const [formStandardTime, setFormStandardTime] = useState("");
const [formOutsources, setFormOutsources] = useState<string[]>([]);
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() {
</Select>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formWorkType} onValueChange={setFormWorkType}>
<SelectTrigger className="h-9" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="내부"></SelectItem>
<SelectItem value="외주"></SelectItem>
<SelectItem value="선택가능"></SelectItem>
</SelectContent>
</Select>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formWorkType} onValueChange={setFormWorkType}>
<SelectTrigger className="h-9" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="내부"></SelectItem>
<SelectItem value="외주"></SelectItem>
<SelectItem value="선택가능"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formExecutionType} onValueChange={setFormExecutionType}>
<SelectTrigger className="h-9" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="serial"> ()</SelectItem>
<SelectItem value="parallel"> ( )</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">()</Label>

View File

@@ -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<Row[]>([]);
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<EDataTableColumn[]>(() => [
{ 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 (
<div className="flex h-[calc(100vh-4rem)] flex-col gap-3 p-3 overflow-hidden">
{/* 필터 바 */}
<div className="shrink-0 flex items-end gap-2 flex-wrap">
<div className="flex flex-col gap-1">
<Label className="text-xs text-muted-foreground"></Label>
<Input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className="h-9 w-40" />
</div>
<div className="flex flex-col gap-1">
<Label className="text-xs text-muted-foreground"></Label>
<Input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="h-9 w-40" />
</div>
<Button onClick={fetchData} disabled={loading} className="h-9">
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <><Search className="h-4 w-4 mr-1" /></>}
</Button>
</div>
{/* 요약 카드 */}
<div className="shrink-0 grid grid-cols-4 gap-3">
<Card className="p-3">
<div className="text-xs text-muted-foreground"> </div>
<div className="text-2xl font-bold mt-1">{summary.totalQty.toLocaleString()}</div>
</Card>
<Card className="p-3">
<div className="text-xs text-muted-foreground"> (h)</div>
<div className="text-2xl font-bold mt-1">{summary.totalHours.toLocaleString()}</div>
</Card>
<Card className="p-3">
<div className="text-xs text-muted-foreground"> </div>
<div className="text-2xl font-bold mt-1">{summary.totalDefect.toLocaleString()}</div>
</Card>
<Card className="p-3 bg-primary/5 border-primary/20">
<div className="text-xs text-muted-foreground"> </div>
<div className="text-2xl font-bold mt-1 text-primary">{summary.hourly.toLocaleString()}</div>
</Card>
</div>
{/* 차트 */}
<Card className="shrink-0 p-3">
<div className="text-sm font-semibold mb-2"> / </div>
<div style={{ width: "100%", height: 320 }}>
<ResponsiveContainer>
<ComposedChart data={chartData} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="label" tick={{ fontSize: 11 }} />
<YAxis yAxisId="left" tick={{ fontSize: 11 }} />
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 11 }} />
<Tooltip />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Bar yAxisId="left" dataKey="production_qty" name="생산수량" fill="#3b82f6" radius={[4, 4, 0, 0]} />
<Line yAxisId="right" type="monotone" dataKey="work_hours" name="작업시간(h)" stroke="#f97316" strokeWidth={2} dot={{ r: 3 }} />
</ComposedChart>
</ResponsiveContainer>
</div>
</Card>
{/* 테이블 */}
<div className="flex flex-1 min-h-0 flex-col overflow-hidden border rounded-lg bg-card">
<EDataTable
columns={columns}
data={rows}
loading={loading}
emptyMessage="기간 내 데이터가 없어요"
showPagination
draggableColumns={false}
rowKey={(r: any) => r.prod_date}
columnOrderKey="c30-kpi-daily-production"
/>
</div>
</div>
);
}

View File

@@ -92,6 +92,7 @@ export function ItemRoutingTab() {
const [formFixedOrder, setFormFixedOrder] = useState("Y");
const [formWorkType, setFormWorkType] = useState("내부");
const [formStandardTime, setFormStandardTime] = useState("");
const [formExecutionType, setFormExecutionType] = useState<string>("serial"); // serial=순차 / parallel=병렬
const [formOutsources, setFormOutsources] = useState<string[]>([]);
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() {
</Select>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formWorkType} onValueChange={setFormWorkType}>
<SelectTrigger className="h-9" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="내부"></SelectItem>
<SelectItem value="외주"></SelectItem>
<SelectItem value="선택가능"></SelectItem>
</SelectContent>
</Select>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formWorkType} onValueChange={setFormWorkType}>
<SelectTrigger className="h-9" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="내부"></SelectItem>
<SelectItem value="외주"></SelectItem>
<SelectItem value="선택가능"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formExecutionType} onValueChange={setFormExecutionType}>
<SelectTrigger className="h-9" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="serial"> ()</SelectItem>
<SelectItem value="parallel"> ( )</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">()</Label>

View File

@@ -92,6 +92,7 @@ export function ItemRoutingTab() {
const [formFixedOrder, setFormFixedOrder] = useState("Y");
const [formWorkType, setFormWorkType] = useState("내부");
const [formStandardTime, setFormStandardTime] = useState("");
const [formExecutionType, setFormExecutionType] = useState<string>("serial"); // serial=순차 / parallel=병렬
const [formOutsources, setFormOutsources] = useState<string[]>([]);
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() {
</Select>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formWorkType} onValueChange={setFormWorkType}>
<SelectTrigger className="h-9" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="내부"></SelectItem>
<SelectItem value="외주"></SelectItem>
<SelectItem value="선택가능"></SelectItem>
</SelectContent>
</Select>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formWorkType} onValueChange={setFormWorkType}>
<SelectTrigger className="h-9" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="내부"></SelectItem>
<SelectItem value="외주"></SelectItem>
<SelectItem value="선택가능"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formExecutionType} onValueChange={setFormExecutionType}>
<SelectTrigger className="h-9" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="serial"> ()</SelectItem>
<SelectItem value="parallel"> ( )</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">()</Label>

View File

@@ -92,6 +92,7 @@ export function ItemRoutingTab() {
const [formFixedOrder, setFormFixedOrder] = useState("Y");
const [formWorkType, setFormWorkType] = useState("내부");
const [formStandardTime, setFormStandardTime] = useState("");
const [formExecutionType, setFormExecutionType] = useState<string>("serial"); // serial=순차 / parallel=병렬
const [formOutsources, setFormOutsources] = useState<string[]>([]);
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() {
</Select>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formWorkType} onValueChange={setFormWorkType}>
<SelectTrigger className="h-9" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="내부"></SelectItem>
<SelectItem value="외주"></SelectItem>
<SelectItem value="선택가능"></SelectItem>
</SelectContent>
</Select>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formWorkType} onValueChange={setFormWorkType}>
<SelectTrigger className="h-9" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="내부"></SelectItem>
<SelectItem value="외주"></SelectItem>
<SelectItem value="선택가능"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formExecutionType} onValueChange={setFormExecutionType}>
<SelectTrigger className="h-9" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="serial"> ()</SelectItem>
<SelectItem value="parallel"> ( )</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">()</Label>

View File

@@ -92,6 +92,7 @@ export function ItemRoutingTab() {
const [formFixedOrder, setFormFixedOrder] = useState("Y");
const [formWorkType, setFormWorkType] = useState("내부");
const [formStandardTime, setFormStandardTime] = useState("");
const [formExecutionType, setFormExecutionType] = useState<string>("serial"); // serial=순차 / parallel=병렬
const [formOutsources, setFormOutsources] = useState<string[]>([]);
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() {
</Select>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formWorkType} onValueChange={setFormWorkType}>
<SelectTrigger className="h-9" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="내부"></SelectItem>
<SelectItem value="외주"></SelectItem>
<SelectItem value="선택가능"></SelectItem>
</SelectContent>
</Select>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formWorkType} onValueChange={setFormWorkType}>
<SelectTrigger className="h-9" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="내부"></SelectItem>
<SelectItem value="외주"></SelectItem>
<SelectItem value="선택가능"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formExecutionType} onValueChange={setFormExecutionType}>
<SelectTrigger className="h-9" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="serial"> ()</SelectItem>
<SelectItem value="parallel"> ( )</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">()</Label>

View File

@@ -372,6 +372,8 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
"/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<string, () => Promise<any>> = {
"/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 {

View File

@@ -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<T> {