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:
@@ -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); // 출하계획 관리
|
||||
|
||||
48
backend-node/src/controllers/kpiController.ts
Normal file
48
backend-node/src/controllers/kpiController.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
13
backend-node/src/routes/kpiRoutes.ts
Normal file
13
backend-node/src/routes/kpiRoutes.ts
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
177
frontend/app/(main)/COMPANY_16/kpi/production/daily/page.tsx
Normal file
177
frontend/app/(main)/COMPANY_16/kpi/production/daily/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
185
frontend/app/(main)/COMPANY_30/kpi/production/daily/page.tsx
Normal file
185
frontend/app/(main)/COMPANY_30/kpi/production/daily/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user