feat: Add production result management page for COMPANY_10, COMPANY_16, COMPANY_29, COMPANY_30, COMPANY_7, and COMPANY_8
- Implemented a new hardcoded page for managing production results, featuring a work instruction list on the left and detailed process results on the right. - Included summary cards displaying total quantities, good and defective items, and achievement rates. - Added tabs for viewing performance details and defect records, along with a detailed modal for further insights. - Integrated dynamic search filters to enhance user experience in navigating work instructions. These changes aim to provide a comprehensive interface for monitoring and managing production performance across multiple companies.
This commit is contained in:
@@ -0,0 +1,639 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 생산실적관리(PC) — 하드코딩 페이지
|
||||
*
|
||||
* 좌측: 작업지시 목록 (work_instruction + detail + item_info JOIN)
|
||||
* 우측: 선택된 작업지시의 공정별 실적 (work_order_process)
|
||||
* - 요약 카드 (지시수량/양품/불량/달성률)
|
||||
* - 탭: 실적내역 / 불량내역
|
||||
* - 상세 모달
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import {
|
||||
Download, Loader2, Inbox, Search, BarChart3, AlertTriangle,
|
||||
CheckCircle2, Package, ChevronDown, ChevronRight, ClipboardList,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { toast } from "sonner";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
|
||||
const WI_TABLE = "work_instruction";
|
||||
const WOP_TABLE = "work_order_process";
|
||||
|
||||
const fmtNum = (v: any) => {
|
||||
const n = Number(v);
|
||||
return isNaN(n) ? "0" : n.toLocaleString();
|
||||
};
|
||||
|
||||
const fmtDate = (v: any) => {
|
||||
if (!v) return "-";
|
||||
const s = String(v);
|
||||
// "2026-04-06 19:29:07+09" or "2026-04-06T19:29:07"
|
||||
return s.split(/[T ]/)[0] || "-";
|
||||
};
|
||||
const fmtDateTime = (v: any) => {
|
||||
if (!v) return "-";
|
||||
const s = String(v);
|
||||
// "2026-04-06 19:29:07.049337+09" → "04-06 19:29"
|
||||
const match = s.match(/(\d{2})(\d{2})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/);
|
||||
if (match) return `${match[2]}-${match[3]}-${match[4]} ${match[5]}:${match[6]}`;
|
||||
return s.substring(0, 16);
|
||||
};
|
||||
|
||||
// 상태 뱃지
|
||||
const STATUS_CLS: Record<string, string> = {
|
||||
"일반": "bg-muted text-foreground",
|
||||
"긴급": "bg-destructive/10 text-destructive",
|
||||
};
|
||||
const PROGRESS_CLS: Record<string, string> = {
|
||||
"대기": "bg-amber-100 text-amber-700",
|
||||
"진행중": "bg-blue-100 text-blue-700",
|
||||
"완료": "bg-emerald-100 text-emerald-700",
|
||||
"acceptable": "bg-blue-100 text-blue-700",
|
||||
"completed": "bg-emerald-100 text-emerald-700",
|
||||
};
|
||||
const PROGRESS_LABEL: Record<string, string> = {
|
||||
"대기": "대기",
|
||||
"진행중": "진행중",
|
||||
"완료": "완료",
|
||||
"acceptable": "진행중",
|
||||
"completed": "완료",
|
||||
};
|
||||
|
||||
export default function ProductionResultPage() {
|
||||
const { user } = useAuth();
|
||||
|
||||
// ── 좌측: 작업지시 ──
|
||||
const [wiList, setWiList] = useState<any[]>([]);
|
||||
const [wiLoading, setWiLoading] = useState(false);
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
const [selectedWiId, setSelectedWiId] = useState<string | null>(null);
|
||||
const [groupBy, setGroupBy] = useState("none");
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
|
||||
// ── 우측: 실적 ──
|
||||
const [rightTab, setRightTab] = useState<"result" | "defect">("result");
|
||||
const [processData, setProcessData] = useState<any[]>([]);
|
||||
const [processLoading, setProcessLoading] = useState(false);
|
||||
|
||||
// ── 카테고리 ──
|
||||
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||
|
||||
// ── 상세 모달 ──
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detailRow, setDetailRow] = useState<any>(null);
|
||||
|
||||
// ════════ 카테고리 로드 ════════
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const opts: Record<string, { code: string; label: string }[]> = {};
|
||||
const flatten = (vals: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
for (const v of vals) {
|
||||
result.push({ code: v.valueCode || v.value || v.code, label: v.valueLabel || v.label || v.value });
|
||||
if (v.children?.length) result.push(...flatten(v.children));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
for (const col of ["status", "work_team"]) {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${WI_TABLE}/${col}/values`);
|
||||
if (res.data?.success && res.data.data?.length > 0) opts[col] = flatten(res.data.data);
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
setCatOptions(opts);
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const resolveCategory = (col: string, code: string) => {
|
||||
if (!code) return code;
|
||||
return catOptions[col]?.find((o) => o.code === code)?.label || code;
|
||||
};
|
||||
|
||||
// ════════ 데이터 로드 ════════
|
||||
|
||||
const fetchWiList = useCallback(async () => {
|
||||
setWiLoading(true);
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
for (const f of searchFilters) {
|
||||
if (f.value) {
|
||||
if (f.columnName === "progress_status") params.progressStatus = f.value;
|
||||
else if (f.columnName === "status") params.status = f.value;
|
||||
else params.keyword = f.value;
|
||||
}
|
||||
}
|
||||
const res = await apiClient.get("/work-instruction/list", { params });
|
||||
const raw: any[] = res.data?.data || [];
|
||||
|
||||
// work_instruction_no 기준 중복 제거 (detail JOIN으로 여러 행 반환)
|
||||
const seen = new Set<string>();
|
||||
const deduped = raw.filter((r) => {
|
||||
const key = r.work_instruction_no;
|
||||
if (!key || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
// 진행률 계산
|
||||
const enriched = deduped.map((r) => {
|
||||
const qty = Number(r.total_qty || r.qty) || 0;
|
||||
const completed = Number(r.completed_qty) || 0;
|
||||
return {
|
||||
...r,
|
||||
_qty: qty,
|
||||
_completed: completed,
|
||||
_rate: qty > 0 ? Math.round((completed / qty) * 100) : 0,
|
||||
};
|
||||
});
|
||||
setWiList(enriched);
|
||||
} catch {
|
||||
toast.error("작업지시 목록 조회 실패");
|
||||
} finally {
|
||||
setWiLoading(false);
|
||||
}
|
||||
}, [searchFilters]);
|
||||
|
||||
useEffect(() => { fetchWiList(); }, [fetchWiList]);
|
||||
|
||||
// 실적 로드
|
||||
useEffect(() => {
|
||||
if (!selectedWiId) { setProcessData([]); return; }
|
||||
const load = async () => {
|
||||
setProcessLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${WOP_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
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 || []);
|
||||
} catch { setProcessData([]); }
|
||||
finally { setProcessLoading(false); }
|
||||
};
|
||||
load();
|
||||
}, [selectedWiId]);
|
||||
|
||||
// ════════ 계산 ════════
|
||||
|
||||
const selectedWi = wiList.find((w) => (w.wi_id || w.id) === selectedWiId);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const instructionQty = selectedWi?._qty || 0;
|
||||
const goodQty = processData.reduce((s, r) => s + (Number(r.good_qty) || 0), 0);
|
||||
const defectQty = processData.reduce((s, r) => s + (Number(r.defect_qty) || 0), 0);
|
||||
const rate = instructionQty > 0 ? Math.round((goodQty / instructionQty) * 100) : 0;
|
||||
return { instructionQty, goodQty, defectQty, rate };
|
||||
}, [selectedWi, processData]);
|
||||
|
||||
const defectRows = useMemo(() => processData.filter((r) => Number(r.defect_qty) > 0), [processData]);
|
||||
|
||||
const totals = useMemo(() => ({
|
||||
input_qty: processData.reduce((s, r) => s + (Number(r.input_qty) || 0), 0),
|
||||
good_qty: processData.reduce((s, r) => s + (Number(r.good_qty) || 0), 0),
|
||||
defect_qty: processData.reduce((s, r) => s + (Number(r.defect_qty) || 0), 0),
|
||||
}), [processData]);
|
||||
|
||||
// ════════ 그룹핑 ════════
|
||||
|
||||
const groupedData = useMemo(() => {
|
||||
if (groupBy === "none") return wiList;
|
||||
const groups = new Map<string, any[]>();
|
||||
for (const wi of wiList) {
|
||||
let key = wi[groupBy] || "미지정";
|
||||
if (groupBy === "progress_status") key = PROGRESS_LABEL[key] || key;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(wi);
|
||||
}
|
||||
const result: any[] = [];
|
||||
groups.forEach((items, gk) => {
|
||||
result.push({ _group: true, _key: gk, _count: items.length });
|
||||
result.push(...items);
|
||||
});
|
||||
// 기본 전개
|
||||
if (expandedGroups.size === 0) setExpandedGroups(new Set(Array.from(groups.keys())));
|
||||
return result;
|
||||
}, [wiList, groupBy]);
|
||||
|
||||
const toggleGroup = (key: string) => {
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key); else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// ════════ 엑셀 ════════
|
||||
|
||||
const handleExcel = async () => {
|
||||
if (!selectedWi || processData.length === 0) {
|
||||
toast.error("다운로드할 데이터가 없습니다.");
|
||||
return;
|
||||
}
|
||||
const data = processData.map((r, i) => ({
|
||||
No: i + 1,
|
||||
공정: r.process_name || "-",
|
||||
설비: r.equipment_code || "-",
|
||||
생산수량: Number(r.input_qty) || 0,
|
||||
양품수량: Number(r.good_qty) || 0,
|
||||
불량수량: Number(r.defect_qty) || 0,
|
||||
"불량률(%)": Number(r.input_qty) > 0 ? ((Number(r.defect_qty) / Number(r.input_qty)) * 100).toFixed(1) : "0.0",
|
||||
시작: r.started_at || "",
|
||||
완료: r.completed_at || "",
|
||||
비고: r.result_note || r.remark || "",
|
||||
}));
|
||||
await exportToExcel(data, `생산실적_${selectedWi.work_instruction_no || ""}.xlsx`, "실적내역");
|
||||
toast.success("다운로드 완료");
|
||||
};
|
||||
|
||||
// defect_detail JSON 파싱
|
||||
const parseDefectDetail = (raw: any) => {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch { return []; }
|
||||
};
|
||||
|
||||
// ════════ 렌더 ════════
|
||||
|
||||
const wiKey = (r: any) => r.wi_id || r.id;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 p-3">
|
||||
{/* 검색 */}
|
||||
<DynamicSearchFilter
|
||||
tableName={WI_TABLE}
|
||||
filterId="c16-production-result"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={wiList.length}
|
||||
/>
|
||||
|
||||
{/* 메인 */}
|
||||
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card">
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
{/* ────── 좌측: 작업지시 목록 ────── */}
|
||||
<ResizablePanel defaultSize={45} minSize={25}>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold">작업지시 목록</span>
|
||||
<Badge variant="secondary" className="font-mono text-xs">{wiList.length}건</Badge>
|
||||
</div>
|
||||
<Select value={groupBy} onValueChange={(v) => { setGroupBy(v); setExpandedGroups(new Set()); }}>
|
||||
<SelectTrigger className="h-8 w-[120px] text-xs">
|
||||
<SelectValue placeholder="그룹없음" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">그룹없음</SelectItem>
|
||||
<SelectItem value="progress_status">진행상태별</SelectItem>
|
||||
<SelectItem value="equipment_name">설비별</SelectItem>
|
||||
<SelectItem value="work_team">작업조별</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{wiLoading ? (
|
||||
<div className="flex items-center justify-center h-full"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
|
||||
) : wiList.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||
<Inbox className="w-10 h-10 opacity-30" />
|
||||
<span className="text-sm">작업지시가 없어요</span>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[130px] text-[11px]">작업지시번호</TableHead>
|
||||
<TableHead className="w-[60px] text-center text-[11px]">상태</TableHead>
|
||||
<TableHead className="w-[60px] text-center text-[11px]">진행</TableHead>
|
||||
<TableHead className="w-[140px] text-[11px]">품목명</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px]">지시수량</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px]">진행률</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px]">설비</TableHead>
|
||||
<TableHead className="w-[60px] text-center text-[11px]">작업조</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px]">시작일</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px]">완료일</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groupedData.map((row, idx) => {
|
||||
if (row._group) {
|
||||
const expanded = expandedGroups.has(row._key);
|
||||
return (
|
||||
<TableRow key={`g-${row._key}`} className="bg-muted/60 cursor-pointer hover:bg-muted" onClick={() => toggleGroup(row._key)}>
|
||||
<TableCell colSpan={10} className="py-2 px-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
{expanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
{row._key}
|
||||
<Badge variant="outline" className="text-[10px]">{row._count}건</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
// 그룹 접힘 체크
|
||||
if (groupBy !== "none") {
|
||||
let gk = row[groupBy] || "미지정";
|
||||
if (groupBy === "progress_status") gk = PROGRESS_LABEL[gk] || gk;
|
||||
if (!expandedGroups.has(gk)) return null;
|
||||
}
|
||||
const id = wiKey(row);
|
||||
const selected = id === selectedWiId;
|
||||
const barColor = row._rate >= 100 ? "bg-emerald-500" : row._rate >= 50 ? "bg-primary" : "bg-amber-500";
|
||||
const statusLabel = resolveCategory("status", row.status) || row.status || "-";
|
||||
const progressLabel = PROGRESS_LABEL[row.progress_status] || row.progress_status || "-";
|
||||
return (
|
||||
<TableRow
|
||||
key={id || idx}
|
||||
className={cn("cursor-pointer", selected ? "bg-primary/5 border-l-[3px] border-l-primary" : "hover:bg-accent/50")}
|
||||
onClick={() => setSelectedWiId(id)}
|
||||
>
|
||||
<TableCell className="text-[13px] font-mono text-primary font-semibold">{row.work_instruction_no}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full", STATUS_CLS[statusLabel] || "bg-muted text-foreground")}>{statusLabel}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full", PROGRESS_CLS[row.progress_status] || "bg-muted text-foreground")}>{progressLabel}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[13px] truncate max-w-[140px]">{row.item_name || "-"}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono">{fmtNum(row._qty)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className={cn("h-full rounded-full", barColor)} style={{ width: `${Math.min(row._rate, 100)}%` }} />
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground w-7 text-right">{row._rate}%</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground truncate">{row.equipment_name || "-"}</TableCell>
|
||||
<TableCell className="text-[12px] text-center">{resolveCategory("work_team", row.work_team) || "-"}</TableCell>
|
||||
<TableCell className="text-[12px]">{fmtDate(row.start_date)}</TableCell>
|
||||
<TableCell className="text-[12px]">{fmtDate(row.end_date)}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* ────── 우측: 실적 데이터 ────── */}
|
||||
<ResizablePanel defaultSize={55} minSize={30}>
|
||||
{!selectedWiId ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
|
||||
<Package className="w-12 h-12 opacity-30" />
|
||||
<span className="text-sm">좌측에서 작업지시를 선택해주세요</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="w-4 h-4 text-primary" />
|
||||
<span className="text-sm font-semibold">생산실적(PC)</span>
|
||||
{selectedWi && <span className="text-xs text-muted-foreground">{selectedWi.item_name} / {selectedWi.work_instruction_no}</span>}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleExcel} disabled={processData.length === 0}>
|
||||
<Download className="w-3.5 h-3.5 mr-1" /> 엑셀
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 요약 카드 */}
|
||||
<div className="grid grid-cols-4 gap-3 px-4 py-3 border-b bg-muted/20 shrink-0">
|
||||
{[
|
||||
{ label: "지시수량", value: fmtNum(summary.instructionQty), icon: Package, color: "text-primary" },
|
||||
{ label: "양품수량", value: fmtNum(summary.goodQty), icon: CheckCircle2, color: "text-emerald-600" },
|
||||
{ label: "불량수량", value: fmtNum(summary.defectQty), icon: AlertTriangle, color: "text-destructive" },
|
||||
{ label: "달성률", value: `${summary.rate}%`, icon: BarChart3, color: "text-violet-600" },
|
||||
].map((card) => (
|
||||
<div key={card.label} className="bg-card border rounded-lg px-3 py-2.5 text-center">
|
||||
<div className="text-[10px] font-semibold text-muted-foreground mb-1">{card.label}</div>
|
||||
<div className={cn("text-lg font-bold", card.color)}>{card.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 탭 */}
|
||||
<div className="flex border-b shrink-0">
|
||||
{(["result", "defect"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
className={cn(
|
||||
"px-4 py-2.5 text-xs font-semibold border-b-2 transition-colors",
|
||||
rightTab === tab ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setRightTab(tab)}
|
||||
>
|
||||
{tab === "result" ? "📊 실적내역" : "⚠️ 불량내역"}
|
||||
{tab === "defect" && defectRows.length > 0 && (
|
||||
<Badge variant="destructive" className="ml-1.5 text-[9px] px-1.5 py-0">{defectRows.length}</Badge>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 탭 콘텐츠 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{processLoading ? (
|
||||
<div className="flex items-center justify-center h-full"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>
|
||||
) : processData.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||
<Inbox className="w-8 h-8 opacity-30" />
|
||||
<span className="text-sm">등록된 실적이 없습니다</span>
|
||||
<span className="text-xs">POP에서 실적을 등록해주세요</span>
|
||||
</div>
|
||||
) : rightTab === "result" ? (
|
||||
/* ── 실적내역 ── */
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center text-[11px]">No</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px]">공정</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px]">설비</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px]">생산수량</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px]">양품수량</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px]">불량수량</TableHead>
|
||||
<TableHead className="w-[60px] text-center text-[11px]">불량률</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px]">작업시간</TableHead>
|
||||
<TableHead className="w-[60px] text-[11px]">상태</TableHead>
|
||||
<TableHead className="text-[11px]">비고</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{processData.map((r, i) => {
|
||||
const inputQty = Number(r.input_qty) || 0;
|
||||
const defectQty = Number(r.defect_qty) || 0;
|
||||
const defRate = inputQty > 0 ? ((defectQty / inputQty) * 100).toFixed(1) : "0.0";
|
||||
return (
|
||||
<TableRow key={r.id || i} className="cursor-pointer hover:bg-accent/50" onClick={() => { setDetailRow(r); setDetailOpen(true); }}>
|
||||
<TableCell className="text-center text-[13px]">{i + 1}</TableCell>
|
||||
<TableCell className="text-[13px]">
|
||||
<span className="px-2 py-0.5 rounded bg-amber-50 text-amber-700 text-[11px] font-medium">{r.process_name || "-"}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground">{r.equipment_code || "-"}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono font-semibold">{fmtNum(r.input_qty)}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono text-emerald-600 font-semibold">{fmtNum(r.good_qty)}</TableCell>
|
||||
<TableCell className={cn("text-[13px] text-right font-mono font-semibold", defectQty > 0 ? "text-destructive" : "text-muted-foreground")}>{fmtNum(r.defect_qty)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full", parseFloat(defRate) > 2 ? "bg-red-100 text-red-700" : "bg-emerald-100 text-emerald-700")}>{defRate}%</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px]">{fmtDateTime(r.started_at)} ~ {fmtDateTime(r.completed_at)}</TableCell>
|
||||
<TableCell className="text-[12px]">
|
||||
<span className={cn("text-[10px] px-2 py-0.5 rounded-full font-medium", r.status === "completed" ? "bg-emerald-100 text-emerald-700" : r.status === "in_progress" ? "bg-blue-100 text-blue-700" : "bg-muted text-muted-foreground")}>{r.status || "-"}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground truncate max-w-[120px]">{r.result_note || r.remark || "-"}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{/* 합계 */}
|
||||
<TableRow className="bg-muted/60 font-bold border-t-2">
|
||||
<TableCell colSpan={3} className="text-center text-[13px]">합계</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px]">{fmtNum(totals.input_qty)}</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px] text-emerald-600">{fmtNum(totals.good_qty)}</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px] text-destructive">{fmtNum(totals.defect_qty)}</TableCell>
|
||||
<TableCell className="text-center text-[12px]">
|
||||
{totals.input_qty > 0 ? ((totals.defect_qty / totals.input_qty) * 100).toFixed(1) : "0.0"}%
|
||||
</TableCell>
|
||||
<TableCell colSpan={3} />
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
/* ── 불량내역 ── */
|
||||
defectRows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||
<CheckCircle2 className="w-8 h-8 opacity-30" />
|
||||
<span className="text-sm">불량 내역이 없습니다</span>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center text-[11px]">No</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px]">공정</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px]">설비</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px]">불량수량</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px]">불량유형</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px]">불량원인</TableHead>
|
||||
<TableHead className="text-[11px]">비고</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{defectRows.map((r, i) => {
|
||||
const details = parseDefectDetail(r.defect_detail);
|
||||
const defType = details.map((d: any) => d.defect_name || d.defect_code).filter(Boolean).join(", ") || "-";
|
||||
return (
|
||||
<TableRow key={r.id || i} className="cursor-pointer hover:bg-accent/50" onClick={() => { setDetailRow(r); setDetailOpen(true); }}>
|
||||
<TableCell className="text-center text-[13px]">{i + 1}</TableCell>
|
||||
<TableCell className="text-[13px]">
|
||||
<span className="px-2 py-0.5 rounded bg-amber-50 text-amber-700 text-[11px] font-medium">{r.process_name || "-"}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground">{r.equipment_code || "-"}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono font-bold text-destructive">{fmtNum(r.defect_qty)}</TableCell>
|
||||
<TableCell className="text-[12px]">
|
||||
<span className="px-2 py-0.5 rounded bg-amber-50 text-amber-700 text-[11px]">{defType}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground">{r.result_note || "-"}</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground truncate">{r.remark || "-"}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
{/* ── 상세 모달 ── */}
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent className="max-w-[600px] max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2"><BarChart3 className="w-5 h-5" /> 실적 상세</DialogTitle>
|
||||
<DialogDescription>생산 실적의 상세 정보입니다</DialogDescription>
|
||||
</DialogHeader>
|
||||
{detailRow && (
|
||||
<div className="space-y-4 pt-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 pb-1.5 border-b">기본 정보</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label className="text-xs">공정</Label><Input value={detailRow.process_name || "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
<div><Label className="text-xs">설비</Label><Input value={detailRow.equipment_code || "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
<div><Label className="text-xs">순번</Label><Input value={detailRow.seq_no || "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
<div><Label className="text-xs">상태</Label><Input value={detailRow.status || "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 pb-1.5 border-b">수량 정보</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div><Label className="text-xs">생산수량</Label><Input value={fmtNum(detailRow.input_qty)} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
<div><Label className="text-xs">양품수량</Label><Input value={fmtNum(detailRow.good_qty)} readOnly className="h-8 text-xs bg-muted/50 text-emerald-600 font-semibold" /></div>
|
||||
<div><Label className="text-xs">불량수량</Label><Input value={fmtNum(detailRow.defect_qty)} readOnly className={cn("h-8 text-xs bg-muted/50 font-semibold", Number(detailRow.defect_qty) > 0 ? "text-destructive" : "")} /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 pb-1.5 border-b">작업 시간</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label className="text-xs">시작</Label><Input value={detailRow.started_at ? String(detailRow.started_at).replace("T", " ") : "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
<div><Label className="text-xs">완료</Label><Input value={detailRow.completed_at ? String(detailRow.completed_at).replace("T", " ") : "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
</div>
|
||||
</div>
|
||||
{Number(detailRow.defect_qty) > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 pb-1.5 border-b">불량 정보</p>
|
||||
{parseDefectDetail(detailRow.defect_detail).length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{parseDefectDetail(detailRow.defect_detail).map((d: any, i: number) => (
|
||||
<div key={i} className="flex items-center gap-3 text-xs bg-red-50 px-3 py-1.5 rounded">
|
||||
<span className="font-medium">{d.defect_name || d.defect_code || "-"}</span>
|
||||
<span className="text-muted-foreground">수량: {d.qty || 0}</span>
|
||||
{d.disposition && <span className="text-muted-foreground">처리: {d.disposition}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">상세 불량 데이터 없음</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(detailRow.result_note || detailRow.remark) && (
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 pb-1.5 border-b">비고</p>
|
||||
<p className="text-xs bg-muted/50 p-2 rounded">{detailRow.result_note || detailRow.remark}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 생산실적관리(PC) — 하드코딩 페이지
|
||||
*
|
||||
* 좌측: 작업지시 목록 (work_instruction + detail + item_info JOIN)
|
||||
* 우측: 선택된 작업지시의 공정별 실적 (work_order_process)
|
||||
* - 요약 카드 (지시수량/양품/불량/달성률)
|
||||
* - 탭: 실적내역 / 불량내역
|
||||
* - 상세 모달
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import {
|
||||
Download, Loader2, Inbox, Search, BarChart3, AlertTriangle,
|
||||
CheckCircle2, Package, ChevronDown, ChevronRight, ClipboardList,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { toast } from "sonner";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
|
||||
const WI_TABLE = "work_instruction";
|
||||
const WOP_TABLE = "work_order_process";
|
||||
|
||||
const fmtNum = (v: any) => {
|
||||
const n = Number(v);
|
||||
return isNaN(n) ? "0" : n.toLocaleString();
|
||||
};
|
||||
|
||||
const fmtDate = (v: any) => {
|
||||
if (!v) return "-";
|
||||
const s = String(v);
|
||||
// "2026-04-06 19:29:07+09" or "2026-04-06T19:29:07"
|
||||
return s.split(/[T ]/)[0] || "-";
|
||||
};
|
||||
const fmtDateTime = (v: any) => {
|
||||
if (!v) return "-";
|
||||
const s = String(v);
|
||||
// "2026-04-06 19:29:07.049337+09" → "04-06 19:29"
|
||||
const match = s.match(/(\d{2})(\d{2})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/);
|
||||
if (match) return `${match[2]}-${match[3]}-${match[4]} ${match[5]}:${match[6]}`;
|
||||
return s.substring(0, 16);
|
||||
};
|
||||
|
||||
// 상태 뱃지
|
||||
const STATUS_CLS: Record<string, string> = {
|
||||
"일반": "bg-muted text-foreground",
|
||||
"긴급": "bg-destructive/10 text-destructive",
|
||||
};
|
||||
const PROGRESS_CLS: Record<string, string> = {
|
||||
"대기": "bg-amber-100 text-amber-700",
|
||||
"진행중": "bg-blue-100 text-blue-700",
|
||||
"완료": "bg-emerald-100 text-emerald-700",
|
||||
"acceptable": "bg-blue-100 text-blue-700",
|
||||
"completed": "bg-emerald-100 text-emerald-700",
|
||||
};
|
||||
const PROGRESS_LABEL: Record<string, string> = {
|
||||
"대기": "대기",
|
||||
"진행중": "진행중",
|
||||
"완료": "완료",
|
||||
"acceptable": "진행중",
|
||||
"completed": "완료",
|
||||
};
|
||||
|
||||
export default function ProductionResultPage() {
|
||||
const { user } = useAuth();
|
||||
|
||||
// ── 좌측: 작업지시 ──
|
||||
const [wiList, setWiList] = useState<any[]>([]);
|
||||
const [wiLoading, setWiLoading] = useState(false);
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
const [selectedWiId, setSelectedWiId] = useState<string | null>(null);
|
||||
const [groupBy, setGroupBy] = useState("none");
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
|
||||
// ── 우측: 실적 ──
|
||||
const [rightTab, setRightTab] = useState<"result" | "defect">("result");
|
||||
const [processData, setProcessData] = useState<any[]>([]);
|
||||
const [processLoading, setProcessLoading] = useState(false);
|
||||
|
||||
// ── 카테고리 ──
|
||||
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||
|
||||
// ── 상세 모달 ──
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detailRow, setDetailRow] = useState<any>(null);
|
||||
|
||||
// ════════ 카테고리 로드 ════════
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const opts: Record<string, { code: string; label: string }[]> = {};
|
||||
const flatten = (vals: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
for (const v of vals) {
|
||||
result.push({ code: v.valueCode || v.value || v.code, label: v.valueLabel || v.label || v.value });
|
||||
if (v.children?.length) result.push(...flatten(v.children));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
for (const col of ["status", "work_team"]) {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${WI_TABLE}/${col}/values`);
|
||||
if (res.data?.success && res.data.data?.length > 0) opts[col] = flatten(res.data.data);
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
setCatOptions(opts);
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const resolveCategory = (col: string, code: string) => {
|
||||
if (!code) return code;
|
||||
return catOptions[col]?.find((o) => o.code === code)?.label || code;
|
||||
};
|
||||
|
||||
// ════════ 데이터 로드 ════════
|
||||
|
||||
const fetchWiList = useCallback(async () => {
|
||||
setWiLoading(true);
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
for (const f of searchFilters) {
|
||||
if (f.value) {
|
||||
if (f.columnName === "progress_status") params.progressStatus = f.value;
|
||||
else if (f.columnName === "status") params.status = f.value;
|
||||
else params.keyword = f.value;
|
||||
}
|
||||
}
|
||||
const res = await apiClient.get("/work-instruction/list", { params });
|
||||
const raw: any[] = res.data?.data || [];
|
||||
|
||||
// work_instruction_no 기준 중복 제거 (detail JOIN으로 여러 행 반환)
|
||||
const seen = new Set<string>();
|
||||
const deduped = raw.filter((r) => {
|
||||
const key = r.work_instruction_no;
|
||||
if (!key || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
// 진행률 계산
|
||||
const enriched = deduped.map((r) => {
|
||||
const qty = Number(r.total_qty || r.qty) || 0;
|
||||
const completed = Number(r.completed_qty) || 0;
|
||||
return {
|
||||
...r,
|
||||
_qty: qty,
|
||||
_completed: completed,
|
||||
_rate: qty > 0 ? Math.round((completed / qty) * 100) : 0,
|
||||
};
|
||||
});
|
||||
setWiList(enriched);
|
||||
} catch {
|
||||
toast.error("작업지시 목록 조회 실패");
|
||||
} finally {
|
||||
setWiLoading(false);
|
||||
}
|
||||
}, [searchFilters]);
|
||||
|
||||
useEffect(() => { fetchWiList(); }, [fetchWiList]);
|
||||
|
||||
// 실적 로드
|
||||
useEffect(() => {
|
||||
if (!selectedWiId) { setProcessData([]); return; }
|
||||
const load = async () => {
|
||||
setProcessLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${WOP_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
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 || []);
|
||||
} catch { setProcessData([]); }
|
||||
finally { setProcessLoading(false); }
|
||||
};
|
||||
load();
|
||||
}, [selectedWiId]);
|
||||
|
||||
// ════════ 계산 ════════
|
||||
|
||||
const selectedWi = wiList.find((w) => (w.wi_id || w.id) === selectedWiId);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const instructionQty = selectedWi?._qty || 0;
|
||||
const goodQty = processData.reduce((s, r) => s + (Number(r.good_qty) || 0), 0);
|
||||
const defectQty = processData.reduce((s, r) => s + (Number(r.defect_qty) || 0), 0);
|
||||
const rate = instructionQty > 0 ? Math.round((goodQty / instructionQty) * 100) : 0;
|
||||
return { instructionQty, goodQty, defectQty, rate };
|
||||
}, [selectedWi, processData]);
|
||||
|
||||
const defectRows = useMemo(() => processData.filter((r) => Number(r.defect_qty) > 0), [processData]);
|
||||
|
||||
const totals = useMemo(() => ({
|
||||
input_qty: processData.reduce((s, r) => s + (Number(r.input_qty) || 0), 0),
|
||||
good_qty: processData.reduce((s, r) => s + (Number(r.good_qty) || 0), 0),
|
||||
defect_qty: processData.reduce((s, r) => s + (Number(r.defect_qty) || 0), 0),
|
||||
}), [processData]);
|
||||
|
||||
// ════════ 그룹핑 ════════
|
||||
|
||||
const groupedData = useMemo(() => {
|
||||
if (groupBy === "none") return wiList;
|
||||
const groups = new Map<string, any[]>();
|
||||
for (const wi of wiList) {
|
||||
let key = wi[groupBy] || "미지정";
|
||||
if (groupBy === "progress_status") key = PROGRESS_LABEL[key] || key;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(wi);
|
||||
}
|
||||
const result: any[] = [];
|
||||
groups.forEach((items, gk) => {
|
||||
result.push({ _group: true, _key: gk, _count: items.length });
|
||||
result.push(...items);
|
||||
});
|
||||
// 기본 전개
|
||||
if (expandedGroups.size === 0) setExpandedGroups(new Set(Array.from(groups.keys())));
|
||||
return result;
|
||||
}, [wiList, groupBy]);
|
||||
|
||||
const toggleGroup = (key: string) => {
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key); else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// ════════ 엑셀 ════════
|
||||
|
||||
const handleExcel = async () => {
|
||||
if (!selectedWi || processData.length === 0) {
|
||||
toast.error("다운로드할 데이터가 없습니다.");
|
||||
return;
|
||||
}
|
||||
const data = processData.map((r, i) => ({
|
||||
No: i + 1,
|
||||
공정: r.process_name || "-",
|
||||
설비: r.equipment_code || "-",
|
||||
생산수량: Number(r.input_qty) || 0,
|
||||
양품수량: Number(r.good_qty) || 0,
|
||||
불량수량: Number(r.defect_qty) || 0,
|
||||
"불량률(%)": Number(r.input_qty) > 0 ? ((Number(r.defect_qty) / Number(r.input_qty)) * 100).toFixed(1) : "0.0",
|
||||
시작: r.started_at || "",
|
||||
완료: r.completed_at || "",
|
||||
비고: r.result_note || r.remark || "",
|
||||
}));
|
||||
await exportToExcel(data, `생산실적_${selectedWi.work_instruction_no || ""}.xlsx`, "실적내역");
|
||||
toast.success("다운로드 완료");
|
||||
};
|
||||
|
||||
// defect_detail JSON 파싱
|
||||
const parseDefectDetail = (raw: any) => {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch { return []; }
|
||||
};
|
||||
|
||||
// ════════ 렌더 ════════
|
||||
|
||||
const wiKey = (r: any) => r.wi_id || r.id;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 p-3">
|
||||
{/* 검색 */}
|
||||
<DynamicSearchFilter
|
||||
tableName={WI_TABLE}
|
||||
filterId="c16-production-result"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={wiList.length}
|
||||
/>
|
||||
|
||||
{/* 메인 */}
|
||||
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card">
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
{/* ────── 좌측: 작업지시 목록 ────── */}
|
||||
<ResizablePanel defaultSize={45} minSize={25}>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold">작업지시 목록</span>
|
||||
<Badge variant="secondary" className="font-mono text-xs">{wiList.length}건</Badge>
|
||||
</div>
|
||||
<Select value={groupBy} onValueChange={(v) => { setGroupBy(v); setExpandedGroups(new Set()); }}>
|
||||
<SelectTrigger className="h-8 w-[120px] text-xs">
|
||||
<SelectValue placeholder="그룹없음" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">그룹없음</SelectItem>
|
||||
<SelectItem value="progress_status">진행상태별</SelectItem>
|
||||
<SelectItem value="equipment_name">설비별</SelectItem>
|
||||
<SelectItem value="work_team">작업조별</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{wiLoading ? (
|
||||
<div className="flex items-center justify-center h-full"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
|
||||
) : wiList.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||
<Inbox className="w-10 h-10 opacity-30" />
|
||||
<span className="text-sm">작업지시가 없어요</span>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[130px] text-[11px]">작업지시번호</TableHead>
|
||||
<TableHead className="w-[60px] text-center text-[11px]">상태</TableHead>
|
||||
<TableHead className="w-[60px] text-center text-[11px]">진행</TableHead>
|
||||
<TableHead className="w-[140px] text-[11px]">품목명</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px]">지시수량</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px]">진행률</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px]">설비</TableHead>
|
||||
<TableHead className="w-[60px] text-center text-[11px]">작업조</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px]">시작일</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px]">완료일</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groupedData.map((row, idx) => {
|
||||
if (row._group) {
|
||||
const expanded = expandedGroups.has(row._key);
|
||||
return (
|
||||
<TableRow key={`g-${row._key}`} className="bg-muted/60 cursor-pointer hover:bg-muted" onClick={() => toggleGroup(row._key)}>
|
||||
<TableCell colSpan={10} className="py-2 px-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
{expanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
{row._key}
|
||||
<Badge variant="outline" className="text-[10px]">{row._count}건</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
// 그룹 접힘 체크
|
||||
if (groupBy !== "none") {
|
||||
let gk = row[groupBy] || "미지정";
|
||||
if (groupBy === "progress_status") gk = PROGRESS_LABEL[gk] || gk;
|
||||
if (!expandedGroups.has(gk)) return null;
|
||||
}
|
||||
const id = wiKey(row);
|
||||
const selected = id === selectedWiId;
|
||||
const barColor = row._rate >= 100 ? "bg-emerald-500" : row._rate >= 50 ? "bg-primary" : "bg-amber-500";
|
||||
const statusLabel = resolveCategory("status", row.status) || row.status || "-";
|
||||
const progressLabel = PROGRESS_LABEL[row.progress_status] || row.progress_status || "-";
|
||||
return (
|
||||
<TableRow
|
||||
key={id || idx}
|
||||
className={cn("cursor-pointer", selected ? "bg-primary/5 border-l-[3px] border-l-primary" : "hover:bg-accent/50")}
|
||||
onClick={() => setSelectedWiId(id)}
|
||||
>
|
||||
<TableCell className="text-[13px] font-mono text-primary font-semibold">{row.work_instruction_no}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full", STATUS_CLS[statusLabel] || "bg-muted text-foreground")}>{statusLabel}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full", PROGRESS_CLS[row.progress_status] || "bg-muted text-foreground")}>{progressLabel}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[13px] truncate max-w-[140px]">{row.item_name || "-"}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono">{fmtNum(row._qty)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className={cn("h-full rounded-full", barColor)} style={{ width: `${Math.min(row._rate, 100)}%` }} />
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground w-7 text-right">{row._rate}%</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground truncate">{row.equipment_name || "-"}</TableCell>
|
||||
<TableCell className="text-[12px] text-center">{resolveCategory("work_team", row.work_team) || "-"}</TableCell>
|
||||
<TableCell className="text-[12px]">{fmtDate(row.start_date)}</TableCell>
|
||||
<TableCell className="text-[12px]">{fmtDate(row.end_date)}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* ────── 우측: 실적 데이터 ────── */}
|
||||
<ResizablePanel defaultSize={55} minSize={30}>
|
||||
{!selectedWiId ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
|
||||
<Package className="w-12 h-12 opacity-30" />
|
||||
<span className="text-sm">좌측에서 작업지시를 선택해주세요</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="w-4 h-4 text-primary" />
|
||||
<span className="text-sm font-semibold">생산실적(PC)</span>
|
||||
{selectedWi && <span className="text-xs text-muted-foreground">{selectedWi.item_name} / {selectedWi.work_instruction_no}</span>}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleExcel} disabled={processData.length === 0}>
|
||||
<Download className="w-3.5 h-3.5 mr-1" /> 엑셀
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 요약 카드 */}
|
||||
<div className="grid grid-cols-4 gap-3 px-4 py-3 border-b bg-muted/20 shrink-0">
|
||||
{[
|
||||
{ label: "지시수량", value: fmtNum(summary.instructionQty), icon: Package, color: "text-primary" },
|
||||
{ label: "양품수량", value: fmtNum(summary.goodQty), icon: CheckCircle2, color: "text-emerald-600" },
|
||||
{ label: "불량수량", value: fmtNum(summary.defectQty), icon: AlertTriangle, color: "text-destructive" },
|
||||
{ label: "달성률", value: `${summary.rate}%`, icon: BarChart3, color: "text-violet-600" },
|
||||
].map((card) => (
|
||||
<div key={card.label} className="bg-card border rounded-lg px-3 py-2.5 text-center">
|
||||
<div className="text-[10px] font-semibold text-muted-foreground mb-1">{card.label}</div>
|
||||
<div className={cn("text-lg font-bold", card.color)}>{card.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 탭 */}
|
||||
<div className="flex border-b shrink-0">
|
||||
{(["result", "defect"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
className={cn(
|
||||
"px-4 py-2.5 text-xs font-semibold border-b-2 transition-colors",
|
||||
rightTab === tab ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setRightTab(tab)}
|
||||
>
|
||||
{tab === "result" ? "📊 실적내역" : "⚠️ 불량내역"}
|
||||
{tab === "defect" && defectRows.length > 0 && (
|
||||
<Badge variant="destructive" className="ml-1.5 text-[9px] px-1.5 py-0">{defectRows.length}</Badge>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 탭 콘텐츠 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{processLoading ? (
|
||||
<div className="flex items-center justify-center h-full"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>
|
||||
) : processData.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||
<Inbox className="w-8 h-8 opacity-30" />
|
||||
<span className="text-sm">등록된 실적이 없습니다</span>
|
||||
<span className="text-xs">POP에서 실적을 등록해주세요</span>
|
||||
</div>
|
||||
) : rightTab === "result" ? (
|
||||
/* ── 실적내역 ── */
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center text-[11px]">No</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px]">공정</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px]">설비</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px]">생산수량</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px]">양품수량</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px]">불량수량</TableHead>
|
||||
<TableHead className="w-[60px] text-center text-[11px]">불량률</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px]">작업시간</TableHead>
|
||||
<TableHead className="w-[60px] text-[11px]">상태</TableHead>
|
||||
<TableHead className="text-[11px]">비고</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{processData.map((r, i) => {
|
||||
const inputQty = Number(r.input_qty) || 0;
|
||||
const defectQty = Number(r.defect_qty) || 0;
|
||||
const defRate = inputQty > 0 ? ((defectQty / inputQty) * 100).toFixed(1) : "0.0";
|
||||
return (
|
||||
<TableRow key={r.id || i} className="cursor-pointer hover:bg-accent/50" onClick={() => { setDetailRow(r); setDetailOpen(true); }}>
|
||||
<TableCell className="text-center text-[13px]">{i + 1}</TableCell>
|
||||
<TableCell className="text-[13px]">
|
||||
<span className="px-2 py-0.5 rounded bg-amber-50 text-amber-700 text-[11px] font-medium">{r.process_name || "-"}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground">{r.equipment_code || "-"}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono font-semibold">{fmtNum(r.input_qty)}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono text-emerald-600 font-semibold">{fmtNum(r.good_qty)}</TableCell>
|
||||
<TableCell className={cn("text-[13px] text-right font-mono font-semibold", defectQty > 0 ? "text-destructive" : "text-muted-foreground")}>{fmtNum(r.defect_qty)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full", parseFloat(defRate) > 2 ? "bg-red-100 text-red-700" : "bg-emerald-100 text-emerald-700")}>{defRate}%</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px]">{fmtDateTime(r.started_at)} ~ {fmtDateTime(r.completed_at)}</TableCell>
|
||||
<TableCell className="text-[12px]">
|
||||
<span className={cn("text-[10px] px-2 py-0.5 rounded-full font-medium", r.status === "completed" ? "bg-emerald-100 text-emerald-700" : r.status === "in_progress" ? "bg-blue-100 text-blue-700" : "bg-muted text-muted-foreground")}>{r.status || "-"}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground truncate max-w-[120px]">{r.result_note || r.remark || "-"}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{/* 합계 */}
|
||||
<TableRow className="bg-muted/60 font-bold border-t-2">
|
||||
<TableCell colSpan={3} className="text-center text-[13px]">합계</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px]">{fmtNum(totals.input_qty)}</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px] text-emerald-600">{fmtNum(totals.good_qty)}</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px] text-destructive">{fmtNum(totals.defect_qty)}</TableCell>
|
||||
<TableCell className="text-center text-[12px]">
|
||||
{totals.input_qty > 0 ? ((totals.defect_qty / totals.input_qty) * 100).toFixed(1) : "0.0"}%
|
||||
</TableCell>
|
||||
<TableCell colSpan={3} />
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
/* ── 불량내역 ── */
|
||||
defectRows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||
<CheckCircle2 className="w-8 h-8 opacity-30" />
|
||||
<span className="text-sm">불량 내역이 없습니다</span>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center text-[11px]">No</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px]">공정</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px]">설비</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px]">불량수량</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px]">불량유형</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px]">불량원인</TableHead>
|
||||
<TableHead className="text-[11px]">비고</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{defectRows.map((r, i) => {
|
||||
const details = parseDefectDetail(r.defect_detail);
|
||||
const defType = details.map((d: any) => d.defect_name || d.defect_code).filter(Boolean).join(", ") || "-";
|
||||
return (
|
||||
<TableRow key={r.id || i} className="cursor-pointer hover:bg-accent/50" onClick={() => { setDetailRow(r); setDetailOpen(true); }}>
|
||||
<TableCell className="text-center text-[13px]">{i + 1}</TableCell>
|
||||
<TableCell className="text-[13px]">
|
||||
<span className="px-2 py-0.5 rounded bg-amber-50 text-amber-700 text-[11px] font-medium">{r.process_name || "-"}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground">{r.equipment_code || "-"}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono font-bold text-destructive">{fmtNum(r.defect_qty)}</TableCell>
|
||||
<TableCell className="text-[12px]">
|
||||
<span className="px-2 py-0.5 rounded bg-amber-50 text-amber-700 text-[11px]">{defType}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground">{r.result_note || "-"}</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground truncate">{r.remark || "-"}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
{/* ── 상세 모달 ── */}
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent className="max-w-[600px] max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2"><BarChart3 className="w-5 h-5" /> 실적 상세</DialogTitle>
|
||||
<DialogDescription>생산 실적의 상세 정보입니다</DialogDescription>
|
||||
</DialogHeader>
|
||||
{detailRow && (
|
||||
<div className="space-y-4 pt-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 pb-1.5 border-b">기본 정보</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label className="text-xs">공정</Label><Input value={detailRow.process_name || "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
<div><Label className="text-xs">설비</Label><Input value={detailRow.equipment_code || "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
<div><Label className="text-xs">순번</Label><Input value={detailRow.seq_no || "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
<div><Label className="text-xs">상태</Label><Input value={detailRow.status || "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 pb-1.5 border-b">수량 정보</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div><Label className="text-xs">생산수량</Label><Input value={fmtNum(detailRow.input_qty)} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
<div><Label className="text-xs">양품수량</Label><Input value={fmtNum(detailRow.good_qty)} readOnly className="h-8 text-xs bg-muted/50 text-emerald-600 font-semibold" /></div>
|
||||
<div><Label className="text-xs">불량수량</Label><Input value={fmtNum(detailRow.defect_qty)} readOnly className={cn("h-8 text-xs bg-muted/50 font-semibold", Number(detailRow.defect_qty) > 0 ? "text-destructive" : "")} /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 pb-1.5 border-b">작업 시간</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label className="text-xs">시작</Label><Input value={detailRow.started_at ? String(detailRow.started_at).replace("T", " ") : "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
<div><Label className="text-xs">완료</Label><Input value={detailRow.completed_at ? String(detailRow.completed_at).replace("T", " ") : "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
</div>
|
||||
</div>
|
||||
{Number(detailRow.defect_qty) > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 pb-1.5 border-b">불량 정보</p>
|
||||
{parseDefectDetail(detailRow.defect_detail).length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{parseDefectDetail(detailRow.defect_detail).map((d: any, i: number) => (
|
||||
<div key={i} className="flex items-center gap-3 text-xs bg-red-50 px-3 py-1.5 rounded">
|
||||
<span className="font-medium">{d.defect_name || d.defect_code || "-"}</span>
|
||||
<span className="text-muted-foreground">수량: {d.qty || 0}</span>
|
||||
{d.disposition && <span className="text-muted-foreground">처리: {d.disposition}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">상세 불량 데이터 없음</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(detailRow.result_note || detailRow.remark) && (
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 pb-1.5 border-b">비고</p>
|
||||
<p className="text-xs bg-muted/50 p-2 rounded">{detailRow.result_note || detailRow.remark}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 생산실적관리(PC) — 하드코딩 페이지
|
||||
*
|
||||
* 좌측: 작업지시 목록 (work_instruction + detail + item_info JOIN)
|
||||
* 우측: 선택된 작업지시의 공정별 실적 (work_order_process)
|
||||
* - 요약 카드 (지시수량/양품/불량/달성률)
|
||||
* - 탭: 실적내역 / 불량내역
|
||||
* - 상세 모달
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import {
|
||||
Download, Loader2, Inbox, Search, BarChart3, AlertTriangle,
|
||||
CheckCircle2, Package, ChevronDown, ChevronRight, ClipboardList,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { toast } from "sonner";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
|
||||
const WI_TABLE = "work_instruction";
|
||||
const WOP_TABLE = "work_order_process";
|
||||
|
||||
const fmtNum = (v: any) => {
|
||||
const n = Number(v);
|
||||
return isNaN(n) ? "0" : n.toLocaleString();
|
||||
};
|
||||
|
||||
const fmtDate = (v: any) => {
|
||||
if (!v) return "-";
|
||||
const s = String(v);
|
||||
// "2026-04-06 19:29:07+09" or "2026-04-06T19:29:07"
|
||||
return s.split(/[T ]/)[0] || "-";
|
||||
};
|
||||
const fmtDateTime = (v: any) => {
|
||||
if (!v) return "-";
|
||||
const s = String(v);
|
||||
// "2026-04-06 19:29:07.049337+09" → "04-06 19:29"
|
||||
const match = s.match(/(\d{2})(\d{2})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/);
|
||||
if (match) return `${match[2]}-${match[3]}-${match[4]} ${match[5]}:${match[6]}`;
|
||||
return s.substring(0, 16);
|
||||
};
|
||||
|
||||
// 상태 뱃지
|
||||
const STATUS_CLS: Record<string, string> = {
|
||||
"일반": "bg-muted text-foreground",
|
||||
"긴급": "bg-destructive/10 text-destructive",
|
||||
};
|
||||
const PROGRESS_CLS: Record<string, string> = {
|
||||
"대기": "bg-amber-100 text-amber-700",
|
||||
"진행중": "bg-blue-100 text-blue-700",
|
||||
"완료": "bg-emerald-100 text-emerald-700",
|
||||
"acceptable": "bg-blue-100 text-blue-700",
|
||||
"completed": "bg-emerald-100 text-emerald-700",
|
||||
};
|
||||
const PROGRESS_LABEL: Record<string, string> = {
|
||||
"대기": "대기",
|
||||
"진행중": "진행중",
|
||||
"완료": "완료",
|
||||
"acceptable": "진행중",
|
||||
"completed": "완료",
|
||||
};
|
||||
|
||||
export default function ProductionResultPage() {
|
||||
const { user } = useAuth();
|
||||
|
||||
// ── 좌측: 작업지시 ──
|
||||
const [wiList, setWiList] = useState<any[]>([]);
|
||||
const [wiLoading, setWiLoading] = useState(false);
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
const [selectedWiId, setSelectedWiId] = useState<string | null>(null);
|
||||
const [groupBy, setGroupBy] = useState("none");
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
|
||||
// ── 우측: 실적 ──
|
||||
const [rightTab, setRightTab] = useState<"result" | "defect">("result");
|
||||
const [processData, setProcessData] = useState<any[]>([]);
|
||||
const [processLoading, setProcessLoading] = useState(false);
|
||||
|
||||
// ── 카테고리 ──
|
||||
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||
|
||||
// ── 상세 모달 ──
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detailRow, setDetailRow] = useState<any>(null);
|
||||
|
||||
// ════════ 카테고리 로드 ════════
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const opts: Record<string, { code: string; label: string }[]> = {};
|
||||
const flatten = (vals: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
for (const v of vals) {
|
||||
result.push({ code: v.valueCode || v.value || v.code, label: v.valueLabel || v.label || v.value });
|
||||
if (v.children?.length) result.push(...flatten(v.children));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
for (const col of ["status", "work_team"]) {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${WI_TABLE}/${col}/values`);
|
||||
if (res.data?.success && res.data.data?.length > 0) opts[col] = flatten(res.data.data);
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
setCatOptions(opts);
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const resolveCategory = (col: string, code: string) => {
|
||||
if (!code) return code;
|
||||
return catOptions[col]?.find((o) => o.code === code)?.label || code;
|
||||
};
|
||||
|
||||
// ════════ 데이터 로드 ════════
|
||||
|
||||
const fetchWiList = useCallback(async () => {
|
||||
setWiLoading(true);
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
for (const f of searchFilters) {
|
||||
if (f.value) {
|
||||
if (f.columnName === "progress_status") params.progressStatus = f.value;
|
||||
else if (f.columnName === "status") params.status = f.value;
|
||||
else params.keyword = f.value;
|
||||
}
|
||||
}
|
||||
const res = await apiClient.get("/work-instruction/list", { params });
|
||||
const raw: any[] = res.data?.data || [];
|
||||
|
||||
// work_instruction_no 기준 중복 제거 (detail JOIN으로 여러 행 반환)
|
||||
const seen = new Set<string>();
|
||||
const deduped = raw.filter((r) => {
|
||||
const key = r.work_instruction_no;
|
||||
if (!key || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
// 진행률 계산
|
||||
const enriched = deduped.map((r) => {
|
||||
const qty = Number(r.total_qty || r.qty) || 0;
|
||||
const completed = Number(r.completed_qty) || 0;
|
||||
return {
|
||||
...r,
|
||||
_qty: qty,
|
||||
_completed: completed,
|
||||
_rate: qty > 0 ? Math.round((completed / qty) * 100) : 0,
|
||||
};
|
||||
});
|
||||
setWiList(enriched);
|
||||
} catch {
|
||||
toast.error("작업지시 목록 조회 실패");
|
||||
} finally {
|
||||
setWiLoading(false);
|
||||
}
|
||||
}, [searchFilters]);
|
||||
|
||||
useEffect(() => { fetchWiList(); }, [fetchWiList]);
|
||||
|
||||
// 실적 로드
|
||||
useEffect(() => {
|
||||
if (!selectedWiId) { setProcessData([]); return; }
|
||||
const load = async () => {
|
||||
setProcessLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${WOP_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
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 || []);
|
||||
} catch { setProcessData([]); }
|
||||
finally { setProcessLoading(false); }
|
||||
};
|
||||
load();
|
||||
}, [selectedWiId]);
|
||||
|
||||
// ════════ 계산 ════════
|
||||
|
||||
const selectedWi = wiList.find((w) => (w.wi_id || w.id) === selectedWiId);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const instructionQty = selectedWi?._qty || 0;
|
||||
const goodQty = processData.reduce((s, r) => s + (Number(r.good_qty) || 0), 0);
|
||||
const defectQty = processData.reduce((s, r) => s + (Number(r.defect_qty) || 0), 0);
|
||||
const rate = instructionQty > 0 ? Math.round((goodQty / instructionQty) * 100) : 0;
|
||||
return { instructionQty, goodQty, defectQty, rate };
|
||||
}, [selectedWi, processData]);
|
||||
|
||||
const defectRows = useMemo(() => processData.filter((r) => Number(r.defect_qty) > 0), [processData]);
|
||||
|
||||
const totals = useMemo(() => ({
|
||||
input_qty: processData.reduce((s, r) => s + (Number(r.input_qty) || 0), 0),
|
||||
good_qty: processData.reduce((s, r) => s + (Number(r.good_qty) || 0), 0),
|
||||
defect_qty: processData.reduce((s, r) => s + (Number(r.defect_qty) || 0), 0),
|
||||
}), [processData]);
|
||||
|
||||
// ════════ 그룹핑 ════════
|
||||
|
||||
const groupedData = useMemo(() => {
|
||||
if (groupBy === "none") return wiList;
|
||||
const groups = new Map<string, any[]>();
|
||||
for (const wi of wiList) {
|
||||
let key = wi[groupBy] || "미지정";
|
||||
if (groupBy === "progress_status") key = PROGRESS_LABEL[key] || key;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(wi);
|
||||
}
|
||||
const result: any[] = [];
|
||||
groups.forEach((items, gk) => {
|
||||
result.push({ _group: true, _key: gk, _count: items.length });
|
||||
result.push(...items);
|
||||
});
|
||||
// 기본 전개
|
||||
if (expandedGroups.size === 0) setExpandedGroups(new Set(Array.from(groups.keys())));
|
||||
return result;
|
||||
}, [wiList, groupBy]);
|
||||
|
||||
const toggleGroup = (key: string) => {
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key); else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// ════════ 엑셀 ════════
|
||||
|
||||
const handleExcel = async () => {
|
||||
if (!selectedWi || processData.length === 0) {
|
||||
toast.error("다운로드할 데이터가 없습니다.");
|
||||
return;
|
||||
}
|
||||
const data = processData.map((r, i) => ({
|
||||
No: i + 1,
|
||||
공정: r.process_name || "-",
|
||||
설비: r.equipment_code || "-",
|
||||
생산수량: Number(r.input_qty) || 0,
|
||||
양품수량: Number(r.good_qty) || 0,
|
||||
불량수량: Number(r.defect_qty) || 0,
|
||||
"불량률(%)": Number(r.input_qty) > 0 ? ((Number(r.defect_qty) / Number(r.input_qty)) * 100).toFixed(1) : "0.0",
|
||||
시작: r.started_at || "",
|
||||
완료: r.completed_at || "",
|
||||
비고: r.result_note || r.remark || "",
|
||||
}));
|
||||
await exportToExcel(data, `생산실적_${selectedWi.work_instruction_no || ""}.xlsx`, "실적내역");
|
||||
toast.success("다운로드 완료");
|
||||
};
|
||||
|
||||
// defect_detail JSON 파싱
|
||||
const parseDefectDetail = (raw: any) => {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch { return []; }
|
||||
};
|
||||
|
||||
// ════════ 렌더 ════════
|
||||
|
||||
const wiKey = (r: any) => r.wi_id || r.id;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 p-3">
|
||||
{/* 검색 */}
|
||||
<DynamicSearchFilter
|
||||
tableName={WI_TABLE}
|
||||
filterId="c16-production-result"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={wiList.length}
|
||||
/>
|
||||
|
||||
{/* 메인 */}
|
||||
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card">
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
{/* ────── 좌측: 작업지시 목록 ────── */}
|
||||
<ResizablePanel defaultSize={45} minSize={25}>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold">작업지시 목록</span>
|
||||
<Badge variant="secondary" className="font-mono text-xs">{wiList.length}건</Badge>
|
||||
</div>
|
||||
<Select value={groupBy} onValueChange={(v) => { setGroupBy(v); setExpandedGroups(new Set()); }}>
|
||||
<SelectTrigger className="h-8 w-[120px] text-xs">
|
||||
<SelectValue placeholder="그룹없음" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">그룹없음</SelectItem>
|
||||
<SelectItem value="progress_status">진행상태별</SelectItem>
|
||||
<SelectItem value="equipment_name">설비별</SelectItem>
|
||||
<SelectItem value="work_team">작업조별</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{wiLoading ? (
|
||||
<div className="flex items-center justify-center h-full"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
|
||||
) : wiList.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||
<Inbox className="w-10 h-10 opacity-30" />
|
||||
<span className="text-sm">작업지시가 없어요</span>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[130px] text-[11px]">작업지시번호</TableHead>
|
||||
<TableHead className="w-[60px] text-center text-[11px]">상태</TableHead>
|
||||
<TableHead className="w-[60px] text-center text-[11px]">진행</TableHead>
|
||||
<TableHead className="w-[140px] text-[11px]">품목명</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px]">지시수량</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px]">진행률</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px]">설비</TableHead>
|
||||
<TableHead className="w-[60px] text-center text-[11px]">작업조</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px]">시작일</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px]">완료일</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groupedData.map((row, idx) => {
|
||||
if (row._group) {
|
||||
const expanded = expandedGroups.has(row._key);
|
||||
return (
|
||||
<TableRow key={`g-${row._key}`} className="bg-muted/60 cursor-pointer hover:bg-muted" onClick={() => toggleGroup(row._key)}>
|
||||
<TableCell colSpan={10} className="py-2 px-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
{expanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
{row._key}
|
||||
<Badge variant="outline" className="text-[10px]">{row._count}건</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
// 그룹 접힘 체크
|
||||
if (groupBy !== "none") {
|
||||
let gk = row[groupBy] || "미지정";
|
||||
if (groupBy === "progress_status") gk = PROGRESS_LABEL[gk] || gk;
|
||||
if (!expandedGroups.has(gk)) return null;
|
||||
}
|
||||
const id = wiKey(row);
|
||||
const selected = id === selectedWiId;
|
||||
const barColor = row._rate >= 100 ? "bg-emerald-500" : row._rate >= 50 ? "bg-primary" : "bg-amber-500";
|
||||
const statusLabel = resolveCategory("status", row.status) || row.status || "-";
|
||||
const progressLabel = PROGRESS_LABEL[row.progress_status] || row.progress_status || "-";
|
||||
return (
|
||||
<TableRow
|
||||
key={id || idx}
|
||||
className={cn("cursor-pointer", selected ? "bg-primary/5 border-l-[3px] border-l-primary" : "hover:bg-accent/50")}
|
||||
onClick={() => setSelectedWiId(id)}
|
||||
>
|
||||
<TableCell className="text-[13px] font-mono text-primary font-semibold">{row.work_instruction_no}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full", STATUS_CLS[statusLabel] || "bg-muted text-foreground")}>{statusLabel}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full", PROGRESS_CLS[row.progress_status] || "bg-muted text-foreground")}>{progressLabel}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[13px] truncate max-w-[140px]">{row.item_name || "-"}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono">{fmtNum(row._qty)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className={cn("h-full rounded-full", barColor)} style={{ width: `${Math.min(row._rate, 100)}%` }} />
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground w-7 text-right">{row._rate}%</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground truncate">{row.equipment_name || "-"}</TableCell>
|
||||
<TableCell className="text-[12px] text-center">{resolveCategory("work_team", row.work_team) || "-"}</TableCell>
|
||||
<TableCell className="text-[12px]">{fmtDate(row.start_date)}</TableCell>
|
||||
<TableCell className="text-[12px]">{fmtDate(row.end_date)}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* ────── 우측: 실적 데이터 ────── */}
|
||||
<ResizablePanel defaultSize={55} minSize={30}>
|
||||
{!selectedWiId ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
|
||||
<Package className="w-12 h-12 opacity-30" />
|
||||
<span className="text-sm">좌측에서 작업지시를 선택해주세요</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="w-4 h-4 text-primary" />
|
||||
<span className="text-sm font-semibold">생산실적(PC)</span>
|
||||
{selectedWi && <span className="text-xs text-muted-foreground">{selectedWi.item_name} / {selectedWi.work_instruction_no}</span>}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleExcel} disabled={processData.length === 0}>
|
||||
<Download className="w-3.5 h-3.5 mr-1" /> 엑셀
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 요약 카드 */}
|
||||
<div className="grid grid-cols-4 gap-3 px-4 py-3 border-b bg-muted/20 shrink-0">
|
||||
{[
|
||||
{ label: "지시수량", value: fmtNum(summary.instructionQty), icon: Package, color: "text-primary" },
|
||||
{ label: "양품수량", value: fmtNum(summary.goodQty), icon: CheckCircle2, color: "text-emerald-600" },
|
||||
{ label: "불량수량", value: fmtNum(summary.defectQty), icon: AlertTriangle, color: "text-destructive" },
|
||||
{ label: "달성률", value: `${summary.rate}%`, icon: BarChart3, color: "text-violet-600" },
|
||||
].map((card) => (
|
||||
<div key={card.label} className="bg-card border rounded-lg px-3 py-2.5 text-center">
|
||||
<div className="text-[10px] font-semibold text-muted-foreground mb-1">{card.label}</div>
|
||||
<div className={cn("text-lg font-bold", card.color)}>{card.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 탭 */}
|
||||
<div className="flex border-b shrink-0">
|
||||
{(["result", "defect"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
className={cn(
|
||||
"px-4 py-2.5 text-xs font-semibold border-b-2 transition-colors",
|
||||
rightTab === tab ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setRightTab(tab)}
|
||||
>
|
||||
{tab === "result" ? "📊 실적내역" : "⚠️ 불량내역"}
|
||||
{tab === "defect" && defectRows.length > 0 && (
|
||||
<Badge variant="destructive" className="ml-1.5 text-[9px] px-1.5 py-0">{defectRows.length}</Badge>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 탭 콘텐츠 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{processLoading ? (
|
||||
<div className="flex items-center justify-center h-full"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>
|
||||
) : processData.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||
<Inbox className="w-8 h-8 opacity-30" />
|
||||
<span className="text-sm">등록된 실적이 없습니다</span>
|
||||
<span className="text-xs">POP에서 실적을 등록해주세요</span>
|
||||
</div>
|
||||
) : rightTab === "result" ? (
|
||||
/* ── 실적내역 ── */
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center text-[11px]">No</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px]">공정</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px]">설비</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px]">생산수량</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px]">양품수량</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px]">불량수량</TableHead>
|
||||
<TableHead className="w-[60px] text-center text-[11px]">불량률</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px]">작업시간</TableHead>
|
||||
<TableHead className="w-[60px] text-[11px]">상태</TableHead>
|
||||
<TableHead className="text-[11px]">비고</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{processData.map((r, i) => {
|
||||
const inputQty = Number(r.input_qty) || 0;
|
||||
const defectQty = Number(r.defect_qty) || 0;
|
||||
const defRate = inputQty > 0 ? ((defectQty / inputQty) * 100).toFixed(1) : "0.0";
|
||||
return (
|
||||
<TableRow key={r.id || i} className="cursor-pointer hover:bg-accent/50" onClick={() => { setDetailRow(r); setDetailOpen(true); }}>
|
||||
<TableCell className="text-center text-[13px]">{i + 1}</TableCell>
|
||||
<TableCell className="text-[13px]">
|
||||
<span className="px-2 py-0.5 rounded bg-amber-50 text-amber-700 text-[11px] font-medium">{r.process_name || "-"}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground">{r.equipment_code || "-"}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono font-semibold">{fmtNum(r.input_qty)}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono text-emerald-600 font-semibold">{fmtNum(r.good_qty)}</TableCell>
|
||||
<TableCell className={cn("text-[13px] text-right font-mono font-semibold", defectQty > 0 ? "text-destructive" : "text-muted-foreground")}>{fmtNum(r.defect_qty)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full", parseFloat(defRate) > 2 ? "bg-red-100 text-red-700" : "bg-emerald-100 text-emerald-700")}>{defRate}%</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px]">{fmtDateTime(r.started_at)} ~ {fmtDateTime(r.completed_at)}</TableCell>
|
||||
<TableCell className="text-[12px]">
|
||||
<span className={cn("text-[10px] px-2 py-0.5 rounded-full font-medium", r.status === "completed" ? "bg-emerald-100 text-emerald-700" : r.status === "in_progress" ? "bg-blue-100 text-blue-700" : "bg-muted text-muted-foreground")}>{r.status || "-"}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground truncate max-w-[120px]">{r.result_note || r.remark || "-"}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{/* 합계 */}
|
||||
<TableRow className="bg-muted/60 font-bold border-t-2">
|
||||
<TableCell colSpan={3} className="text-center text-[13px]">합계</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px]">{fmtNum(totals.input_qty)}</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px] text-emerald-600">{fmtNum(totals.good_qty)}</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px] text-destructive">{fmtNum(totals.defect_qty)}</TableCell>
|
||||
<TableCell className="text-center text-[12px]">
|
||||
{totals.input_qty > 0 ? ((totals.defect_qty / totals.input_qty) * 100).toFixed(1) : "0.0"}%
|
||||
</TableCell>
|
||||
<TableCell colSpan={3} />
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
/* ── 불량내역 ── */
|
||||
defectRows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||
<CheckCircle2 className="w-8 h-8 opacity-30" />
|
||||
<span className="text-sm">불량 내역이 없습니다</span>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center text-[11px]">No</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px]">공정</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px]">설비</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px]">불량수량</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px]">불량유형</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px]">불량원인</TableHead>
|
||||
<TableHead className="text-[11px]">비고</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{defectRows.map((r, i) => {
|
||||
const details = parseDefectDetail(r.defect_detail);
|
||||
const defType = details.map((d: any) => d.defect_name || d.defect_code).filter(Boolean).join(", ") || "-";
|
||||
return (
|
||||
<TableRow key={r.id || i} className="cursor-pointer hover:bg-accent/50" onClick={() => { setDetailRow(r); setDetailOpen(true); }}>
|
||||
<TableCell className="text-center text-[13px]">{i + 1}</TableCell>
|
||||
<TableCell className="text-[13px]">
|
||||
<span className="px-2 py-0.5 rounded bg-amber-50 text-amber-700 text-[11px] font-medium">{r.process_name || "-"}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground">{r.equipment_code || "-"}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono font-bold text-destructive">{fmtNum(r.defect_qty)}</TableCell>
|
||||
<TableCell className="text-[12px]">
|
||||
<span className="px-2 py-0.5 rounded bg-amber-50 text-amber-700 text-[11px]">{defType}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground">{r.result_note || "-"}</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground truncate">{r.remark || "-"}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
{/* ── 상세 모달 ── */}
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent className="max-w-[600px] max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2"><BarChart3 className="w-5 h-5" /> 실적 상세</DialogTitle>
|
||||
<DialogDescription>생산 실적의 상세 정보입니다</DialogDescription>
|
||||
</DialogHeader>
|
||||
{detailRow && (
|
||||
<div className="space-y-4 pt-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 pb-1.5 border-b">기본 정보</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label className="text-xs">공정</Label><Input value={detailRow.process_name || "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
<div><Label className="text-xs">설비</Label><Input value={detailRow.equipment_code || "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
<div><Label className="text-xs">순번</Label><Input value={detailRow.seq_no || "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
<div><Label className="text-xs">상태</Label><Input value={detailRow.status || "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 pb-1.5 border-b">수량 정보</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div><Label className="text-xs">생산수량</Label><Input value={fmtNum(detailRow.input_qty)} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
<div><Label className="text-xs">양품수량</Label><Input value={fmtNum(detailRow.good_qty)} readOnly className="h-8 text-xs bg-muted/50 text-emerald-600 font-semibold" /></div>
|
||||
<div><Label className="text-xs">불량수량</Label><Input value={fmtNum(detailRow.defect_qty)} readOnly className={cn("h-8 text-xs bg-muted/50 font-semibold", Number(detailRow.defect_qty) > 0 ? "text-destructive" : "")} /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 pb-1.5 border-b">작업 시간</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label className="text-xs">시작</Label><Input value={detailRow.started_at ? String(detailRow.started_at).replace("T", " ") : "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
<div><Label className="text-xs">완료</Label><Input value={detailRow.completed_at ? String(detailRow.completed_at).replace("T", " ") : "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
</div>
|
||||
</div>
|
||||
{Number(detailRow.defect_qty) > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 pb-1.5 border-b">불량 정보</p>
|
||||
{parseDefectDetail(detailRow.defect_detail).length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{parseDefectDetail(detailRow.defect_detail).map((d: any, i: number) => (
|
||||
<div key={i} className="flex items-center gap-3 text-xs bg-red-50 px-3 py-1.5 rounded">
|
||||
<span className="font-medium">{d.defect_name || d.defect_code || "-"}</span>
|
||||
<span className="text-muted-foreground">수량: {d.qty || 0}</span>
|
||||
{d.disposition && <span className="text-muted-foreground">처리: {d.disposition}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">상세 불량 데이터 없음</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(detailRow.result_note || detailRow.remark) && (
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 pb-1.5 border-b">비고</p>
|
||||
<p className="text-xs bg-muted/50 p-2 rounded">{detailRow.result_note || detailRow.remark}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 생산실적관리(PC) — 하드코딩 페이지
|
||||
*
|
||||
* 좌측: 작업지시 목록 (work_instruction + detail + item_info JOIN)
|
||||
* 우측: 선택된 작업지시의 공정별 실적 (work_order_process)
|
||||
* - 요약 카드 (지시수량/양품/불량/달성률)
|
||||
* - 탭: 실적내역 / 불량내역
|
||||
* - 상세 모달
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import {
|
||||
Download, Loader2, Inbox, Search, BarChart3, AlertTriangle,
|
||||
CheckCircle2, Package, ChevronDown, ChevronRight, ClipboardList,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { toast } from "sonner";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
|
||||
const WI_TABLE = "work_instruction";
|
||||
const WOP_TABLE = "work_order_process";
|
||||
|
||||
const fmtNum = (v: any) => {
|
||||
const n = Number(v);
|
||||
return isNaN(n) ? "0" : n.toLocaleString();
|
||||
};
|
||||
|
||||
const fmtDate = (v: any) => {
|
||||
if (!v) return "-";
|
||||
const s = String(v);
|
||||
// "2026-04-06 19:29:07+09" or "2026-04-06T19:29:07"
|
||||
return s.split(/[T ]/)[0] || "-";
|
||||
};
|
||||
const fmtDateTime = (v: any) => {
|
||||
if (!v) return "-";
|
||||
const s = String(v);
|
||||
// "2026-04-06 19:29:07.049337+09" → "04-06 19:29"
|
||||
const match = s.match(/(\d{2})(\d{2})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/);
|
||||
if (match) return `${match[2]}-${match[3]}-${match[4]} ${match[5]}:${match[6]}`;
|
||||
return s.substring(0, 16);
|
||||
};
|
||||
|
||||
// 상태 뱃지
|
||||
const STATUS_CLS: Record<string, string> = {
|
||||
"일반": "bg-muted text-foreground",
|
||||
"긴급": "bg-destructive/10 text-destructive",
|
||||
};
|
||||
const PROGRESS_CLS: Record<string, string> = {
|
||||
"대기": "bg-amber-100 text-amber-700",
|
||||
"진행중": "bg-blue-100 text-blue-700",
|
||||
"완료": "bg-emerald-100 text-emerald-700",
|
||||
"acceptable": "bg-blue-100 text-blue-700",
|
||||
"completed": "bg-emerald-100 text-emerald-700",
|
||||
};
|
||||
const PROGRESS_LABEL: Record<string, string> = {
|
||||
"대기": "대기",
|
||||
"진행중": "진행중",
|
||||
"완료": "완료",
|
||||
"acceptable": "진행중",
|
||||
"completed": "완료",
|
||||
};
|
||||
|
||||
export default function ProductionResultPage() {
|
||||
const { user } = useAuth();
|
||||
|
||||
// ── 좌측: 작업지시 ──
|
||||
const [wiList, setWiList] = useState<any[]>([]);
|
||||
const [wiLoading, setWiLoading] = useState(false);
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
const [selectedWiId, setSelectedWiId] = useState<string | null>(null);
|
||||
const [groupBy, setGroupBy] = useState("none");
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
|
||||
// ── 우측: 실적 ──
|
||||
const [rightTab, setRightTab] = useState<"result" | "defect">("result");
|
||||
const [processData, setProcessData] = useState<any[]>([]);
|
||||
const [processLoading, setProcessLoading] = useState(false);
|
||||
|
||||
// ── 카테고리 ──
|
||||
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||
|
||||
// ── 상세 모달 ──
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detailRow, setDetailRow] = useState<any>(null);
|
||||
|
||||
// ════════ 카테고리 로드 ════════
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const opts: Record<string, { code: string; label: string }[]> = {};
|
||||
const flatten = (vals: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
for (const v of vals) {
|
||||
result.push({ code: v.valueCode || v.value || v.code, label: v.valueLabel || v.label || v.value });
|
||||
if (v.children?.length) result.push(...flatten(v.children));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
for (const col of ["status", "work_team"]) {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${WI_TABLE}/${col}/values`);
|
||||
if (res.data?.success && res.data.data?.length > 0) opts[col] = flatten(res.data.data);
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
setCatOptions(opts);
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const resolveCategory = (col: string, code: string) => {
|
||||
if (!code) return code;
|
||||
return catOptions[col]?.find((o) => o.code === code)?.label || code;
|
||||
};
|
||||
|
||||
// ════════ 데이터 로드 ════════
|
||||
|
||||
const fetchWiList = useCallback(async () => {
|
||||
setWiLoading(true);
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
for (const f of searchFilters) {
|
||||
if (f.value) {
|
||||
if (f.columnName === "progress_status") params.progressStatus = f.value;
|
||||
else if (f.columnName === "status") params.status = f.value;
|
||||
else params.keyword = f.value;
|
||||
}
|
||||
}
|
||||
const res = await apiClient.get("/work-instruction/list", { params });
|
||||
const raw: any[] = res.data?.data || [];
|
||||
|
||||
// work_instruction_no 기준 중복 제거 (detail JOIN으로 여러 행 반환)
|
||||
const seen = new Set<string>();
|
||||
const deduped = raw.filter((r) => {
|
||||
const key = r.work_instruction_no;
|
||||
if (!key || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
// 진행률 계산
|
||||
const enriched = deduped.map((r) => {
|
||||
const qty = Number(r.total_qty || r.qty) || 0;
|
||||
const completed = Number(r.completed_qty) || 0;
|
||||
return {
|
||||
...r,
|
||||
_qty: qty,
|
||||
_completed: completed,
|
||||
_rate: qty > 0 ? Math.round((completed / qty) * 100) : 0,
|
||||
};
|
||||
});
|
||||
setWiList(enriched);
|
||||
} catch {
|
||||
toast.error("작업지시 목록 조회 실패");
|
||||
} finally {
|
||||
setWiLoading(false);
|
||||
}
|
||||
}, [searchFilters]);
|
||||
|
||||
useEffect(() => { fetchWiList(); }, [fetchWiList]);
|
||||
|
||||
// 실적 로드
|
||||
useEffect(() => {
|
||||
if (!selectedWiId) { setProcessData([]); return; }
|
||||
const load = async () => {
|
||||
setProcessLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${WOP_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
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 || []);
|
||||
} catch { setProcessData([]); }
|
||||
finally { setProcessLoading(false); }
|
||||
};
|
||||
load();
|
||||
}, [selectedWiId]);
|
||||
|
||||
// ════════ 계산 ════════
|
||||
|
||||
const selectedWi = wiList.find((w) => (w.wi_id || w.id) === selectedWiId);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const instructionQty = selectedWi?._qty || 0;
|
||||
const goodQty = processData.reduce((s, r) => s + (Number(r.good_qty) || 0), 0);
|
||||
const defectQty = processData.reduce((s, r) => s + (Number(r.defect_qty) || 0), 0);
|
||||
const rate = instructionQty > 0 ? Math.round((goodQty / instructionQty) * 100) : 0;
|
||||
return { instructionQty, goodQty, defectQty, rate };
|
||||
}, [selectedWi, processData]);
|
||||
|
||||
const defectRows = useMemo(() => processData.filter((r) => Number(r.defect_qty) > 0), [processData]);
|
||||
|
||||
const totals = useMemo(() => ({
|
||||
input_qty: processData.reduce((s, r) => s + (Number(r.input_qty) || 0), 0),
|
||||
good_qty: processData.reduce((s, r) => s + (Number(r.good_qty) || 0), 0),
|
||||
defect_qty: processData.reduce((s, r) => s + (Number(r.defect_qty) || 0), 0),
|
||||
}), [processData]);
|
||||
|
||||
// ════════ 그룹핑 ════════
|
||||
|
||||
const groupedData = useMemo(() => {
|
||||
if (groupBy === "none") return wiList;
|
||||
const groups = new Map<string, any[]>();
|
||||
for (const wi of wiList) {
|
||||
let key = wi[groupBy] || "미지정";
|
||||
if (groupBy === "progress_status") key = PROGRESS_LABEL[key] || key;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(wi);
|
||||
}
|
||||
const result: any[] = [];
|
||||
groups.forEach((items, gk) => {
|
||||
result.push({ _group: true, _key: gk, _count: items.length });
|
||||
result.push(...items);
|
||||
});
|
||||
// 기본 전개
|
||||
if (expandedGroups.size === 0) setExpandedGroups(new Set(Array.from(groups.keys())));
|
||||
return result;
|
||||
}, [wiList, groupBy]);
|
||||
|
||||
const toggleGroup = (key: string) => {
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key); else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// ════════ 엑셀 ════════
|
||||
|
||||
const handleExcel = async () => {
|
||||
if (!selectedWi || processData.length === 0) {
|
||||
toast.error("다운로드할 데이터가 없습니다.");
|
||||
return;
|
||||
}
|
||||
const data = processData.map((r, i) => ({
|
||||
No: i + 1,
|
||||
공정: r.process_name || "-",
|
||||
설비: r.equipment_code || "-",
|
||||
생산수량: Number(r.input_qty) || 0,
|
||||
양품수량: Number(r.good_qty) || 0,
|
||||
불량수량: Number(r.defect_qty) || 0,
|
||||
"불량률(%)": Number(r.input_qty) > 0 ? ((Number(r.defect_qty) / Number(r.input_qty)) * 100).toFixed(1) : "0.0",
|
||||
시작: r.started_at || "",
|
||||
완료: r.completed_at || "",
|
||||
비고: r.result_note || r.remark || "",
|
||||
}));
|
||||
await exportToExcel(data, `생산실적_${selectedWi.work_instruction_no || ""}.xlsx`, "실적내역");
|
||||
toast.success("다운로드 완료");
|
||||
};
|
||||
|
||||
// defect_detail JSON 파싱
|
||||
const parseDefectDetail = (raw: any) => {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch { return []; }
|
||||
};
|
||||
|
||||
// ════════ 렌더 ════════
|
||||
|
||||
const wiKey = (r: any) => r.wi_id || r.id;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 p-3">
|
||||
{/* 검색 */}
|
||||
<DynamicSearchFilter
|
||||
tableName={WI_TABLE}
|
||||
filterId="c16-production-result"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={wiList.length}
|
||||
/>
|
||||
|
||||
{/* 메인 */}
|
||||
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card">
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
{/* ────── 좌측: 작업지시 목록 ────── */}
|
||||
<ResizablePanel defaultSize={45} minSize={25}>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold">작업지시 목록</span>
|
||||
<Badge variant="secondary" className="font-mono text-xs">{wiList.length}건</Badge>
|
||||
</div>
|
||||
<Select value={groupBy} onValueChange={(v) => { setGroupBy(v); setExpandedGroups(new Set()); }}>
|
||||
<SelectTrigger className="h-8 w-[120px] text-xs">
|
||||
<SelectValue placeholder="그룹없음" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">그룹없음</SelectItem>
|
||||
<SelectItem value="progress_status">진행상태별</SelectItem>
|
||||
<SelectItem value="equipment_name">설비별</SelectItem>
|
||||
<SelectItem value="work_team">작업조별</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{wiLoading ? (
|
||||
<div className="flex items-center justify-center h-full"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
|
||||
) : wiList.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||
<Inbox className="w-10 h-10 opacity-30" />
|
||||
<span className="text-sm">작업지시가 없어요</span>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[130px] text-[11px]">작업지시번호</TableHead>
|
||||
<TableHead className="w-[60px] text-center text-[11px]">상태</TableHead>
|
||||
<TableHead className="w-[60px] text-center text-[11px]">진행</TableHead>
|
||||
<TableHead className="w-[140px] text-[11px]">품목명</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px]">지시수량</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px]">진행률</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px]">설비</TableHead>
|
||||
<TableHead className="w-[60px] text-center text-[11px]">작업조</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px]">시작일</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px]">완료일</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groupedData.map((row, idx) => {
|
||||
if (row._group) {
|
||||
const expanded = expandedGroups.has(row._key);
|
||||
return (
|
||||
<TableRow key={`g-${row._key}`} className="bg-muted/60 cursor-pointer hover:bg-muted" onClick={() => toggleGroup(row._key)}>
|
||||
<TableCell colSpan={10} className="py-2 px-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
{expanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
{row._key}
|
||||
<Badge variant="outline" className="text-[10px]">{row._count}건</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
// 그룹 접힘 체크
|
||||
if (groupBy !== "none") {
|
||||
let gk = row[groupBy] || "미지정";
|
||||
if (groupBy === "progress_status") gk = PROGRESS_LABEL[gk] || gk;
|
||||
if (!expandedGroups.has(gk)) return null;
|
||||
}
|
||||
const id = wiKey(row);
|
||||
const selected = id === selectedWiId;
|
||||
const barColor = row._rate >= 100 ? "bg-emerald-500" : row._rate >= 50 ? "bg-primary" : "bg-amber-500";
|
||||
const statusLabel = resolveCategory("status", row.status) || row.status || "-";
|
||||
const progressLabel = PROGRESS_LABEL[row.progress_status] || row.progress_status || "-";
|
||||
return (
|
||||
<TableRow
|
||||
key={id || idx}
|
||||
className={cn("cursor-pointer", selected ? "bg-primary/5 border-l-[3px] border-l-primary" : "hover:bg-accent/50")}
|
||||
onClick={() => setSelectedWiId(id)}
|
||||
>
|
||||
<TableCell className="text-[13px] font-mono text-primary font-semibold">{row.work_instruction_no}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full", STATUS_CLS[statusLabel] || "bg-muted text-foreground")}>{statusLabel}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full", PROGRESS_CLS[row.progress_status] || "bg-muted text-foreground")}>{progressLabel}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[13px] truncate max-w-[140px]">{row.item_name || "-"}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono">{fmtNum(row._qty)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className={cn("h-full rounded-full", barColor)} style={{ width: `${Math.min(row._rate, 100)}%` }} />
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground w-7 text-right">{row._rate}%</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground truncate">{row.equipment_name || "-"}</TableCell>
|
||||
<TableCell className="text-[12px] text-center">{resolveCategory("work_team", row.work_team) || "-"}</TableCell>
|
||||
<TableCell className="text-[12px]">{fmtDate(row.start_date)}</TableCell>
|
||||
<TableCell className="text-[12px]">{fmtDate(row.end_date)}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* ────── 우측: 실적 데이터 ────── */}
|
||||
<ResizablePanel defaultSize={55} minSize={30}>
|
||||
{!selectedWiId ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
|
||||
<Package className="w-12 h-12 opacity-30" />
|
||||
<span className="text-sm">좌측에서 작업지시를 선택해주세요</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="w-4 h-4 text-primary" />
|
||||
<span className="text-sm font-semibold">생산실적(PC)</span>
|
||||
{selectedWi && <span className="text-xs text-muted-foreground">{selectedWi.item_name} / {selectedWi.work_instruction_no}</span>}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleExcel} disabled={processData.length === 0}>
|
||||
<Download className="w-3.5 h-3.5 mr-1" /> 엑셀
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 요약 카드 */}
|
||||
<div className="grid grid-cols-4 gap-3 px-4 py-3 border-b bg-muted/20 shrink-0">
|
||||
{[
|
||||
{ label: "지시수량", value: fmtNum(summary.instructionQty), icon: Package, color: "text-primary" },
|
||||
{ label: "양품수량", value: fmtNum(summary.goodQty), icon: CheckCircle2, color: "text-emerald-600" },
|
||||
{ label: "불량수량", value: fmtNum(summary.defectQty), icon: AlertTriangle, color: "text-destructive" },
|
||||
{ label: "달성률", value: `${summary.rate}%`, icon: BarChart3, color: "text-violet-600" },
|
||||
].map((card) => (
|
||||
<div key={card.label} className="bg-card border rounded-lg px-3 py-2.5 text-center">
|
||||
<div className="text-[10px] font-semibold text-muted-foreground mb-1">{card.label}</div>
|
||||
<div className={cn("text-lg font-bold", card.color)}>{card.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 탭 */}
|
||||
<div className="flex border-b shrink-0">
|
||||
{(["result", "defect"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
className={cn(
|
||||
"px-4 py-2.5 text-xs font-semibold border-b-2 transition-colors",
|
||||
rightTab === tab ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setRightTab(tab)}
|
||||
>
|
||||
{tab === "result" ? "📊 실적내역" : "⚠️ 불량내역"}
|
||||
{tab === "defect" && defectRows.length > 0 && (
|
||||
<Badge variant="destructive" className="ml-1.5 text-[9px] px-1.5 py-0">{defectRows.length}</Badge>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 탭 콘텐츠 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{processLoading ? (
|
||||
<div className="flex items-center justify-center h-full"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>
|
||||
) : processData.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||
<Inbox className="w-8 h-8 opacity-30" />
|
||||
<span className="text-sm">등록된 실적이 없습니다</span>
|
||||
<span className="text-xs">POP에서 실적을 등록해주세요</span>
|
||||
</div>
|
||||
) : rightTab === "result" ? (
|
||||
/* ── 실적내역 ── */
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center text-[11px]">No</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px]">공정</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px]">설비</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px]">생산수량</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px]">양품수량</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px]">불량수량</TableHead>
|
||||
<TableHead className="w-[60px] text-center text-[11px]">불량률</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px]">작업시간</TableHead>
|
||||
<TableHead className="w-[60px] text-[11px]">상태</TableHead>
|
||||
<TableHead className="text-[11px]">비고</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{processData.map((r, i) => {
|
||||
const inputQty = Number(r.input_qty) || 0;
|
||||
const defectQty = Number(r.defect_qty) || 0;
|
||||
const defRate = inputQty > 0 ? ((defectQty / inputQty) * 100).toFixed(1) : "0.0";
|
||||
return (
|
||||
<TableRow key={r.id || i} className="cursor-pointer hover:bg-accent/50" onClick={() => { setDetailRow(r); setDetailOpen(true); }}>
|
||||
<TableCell className="text-center text-[13px]">{i + 1}</TableCell>
|
||||
<TableCell className="text-[13px]">
|
||||
<span className="px-2 py-0.5 rounded bg-amber-50 text-amber-700 text-[11px] font-medium">{r.process_name || "-"}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground">{r.equipment_code || "-"}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono font-semibold">{fmtNum(r.input_qty)}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono text-emerald-600 font-semibold">{fmtNum(r.good_qty)}</TableCell>
|
||||
<TableCell className={cn("text-[13px] text-right font-mono font-semibold", defectQty > 0 ? "text-destructive" : "text-muted-foreground")}>{fmtNum(r.defect_qty)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full", parseFloat(defRate) > 2 ? "bg-red-100 text-red-700" : "bg-emerald-100 text-emerald-700")}>{defRate}%</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px]">{fmtDateTime(r.started_at)} ~ {fmtDateTime(r.completed_at)}</TableCell>
|
||||
<TableCell className="text-[12px]">
|
||||
<span className={cn("text-[10px] px-2 py-0.5 rounded-full font-medium", r.status === "completed" ? "bg-emerald-100 text-emerald-700" : r.status === "in_progress" ? "bg-blue-100 text-blue-700" : "bg-muted text-muted-foreground")}>{r.status || "-"}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground truncate max-w-[120px]">{r.result_note || r.remark || "-"}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{/* 합계 */}
|
||||
<TableRow className="bg-muted/60 font-bold border-t-2">
|
||||
<TableCell colSpan={3} className="text-center text-[13px]">합계</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px]">{fmtNum(totals.input_qty)}</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px] text-emerald-600">{fmtNum(totals.good_qty)}</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px] text-destructive">{fmtNum(totals.defect_qty)}</TableCell>
|
||||
<TableCell className="text-center text-[12px]">
|
||||
{totals.input_qty > 0 ? ((totals.defect_qty / totals.input_qty) * 100).toFixed(1) : "0.0"}%
|
||||
</TableCell>
|
||||
<TableCell colSpan={3} />
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
/* ── 불량내역 ── */
|
||||
defectRows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||
<CheckCircle2 className="w-8 h-8 opacity-30" />
|
||||
<span className="text-sm">불량 내역이 없습니다</span>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center text-[11px]">No</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px]">공정</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px]">설비</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px]">불량수량</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px]">불량유형</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px]">불량원인</TableHead>
|
||||
<TableHead className="text-[11px]">비고</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{defectRows.map((r, i) => {
|
||||
const details = parseDefectDetail(r.defect_detail);
|
||||
const defType = details.map((d: any) => d.defect_name || d.defect_code).filter(Boolean).join(", ") || "-";
|
||||
return (
|
||||
<TableRow key={r.id || i} className="cursor-pointer hover:bg-accent/50" onClick={() => { setDetailRow(r); setDetailOpen(true); }}>
|
||||
<TableCell className="text-center text-[13px]">{i + 1}</TableCell>
|
||||
<TableCell className="text-[13px]">
|
||||
<span className="px-2 py-0.5 rounded bg-amber-50 text-amber-700 text-[11px] font-medium">{r.process_name || "-"}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground">{r.equipment_code || "-"}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono font-bold text-destructive">{fmtNum(r.defect_qty)}</TableCell>
|
||||
<TableCell className="text-[12px]">
|
||||
<span className="px-2 py-0.5 rounded bg-amber-50 text-amber-700 text-[11px]">{defType}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground">{r.result_note || "-"}</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground truncate">{r.remark || "-"}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
{/* ── 상세 모달 ── */}
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent className="max-w-[600px] max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2"><BarChart3 className="w-5 h-5" /> 실적 상세</DialogTitle>
|
||||
<DialogDescription>생산 실적의 상세 정보입니다</DialogDescription>
|
||||
</DialogHeader>
|
||||
{detailRow && (
|
||||
<div className="space-y-4 pt-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 pb-1.5 border-b">기본 정보</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label className="text-xs">공정</Label><Input value={detailRow.process_name || "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
<div><Label className="text-xs">설비</Label><Input value={detailRow.equipment_code || "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
<div><Label className="text-xs">순번</Label><Input value={detailRow.seq_no || "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
<div><Label className="text-xs">상태</Label><Input value={detailRow.status || "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 pb-1.5 border-b">수량 정보</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div><Label className="text-xs">생산수량</Label><Input value={fmtNum(detailRow.input_qty)} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
<div><Label className="text-xs">양품수량</Label><Input value={fmtNum(detailRow.good_qty)} readOnly className="h-8 text-xs bg-muted/50 text-emerald-600 font-semibold" /></div>
|
||||
<div><Label className="text-xs">불량수량</Label><Input value={fmtNum(detailRow.defect_qty)} readOnly className={cn("h-8 text-xs bg-muted/50 font-semibold", Number(detailRow.defect_qty) > 0 ? "text-destructive" : "")} /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 pb-1.5 border-b">작업 시간</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label className="text-xs">시작</Label><Input value={detailRow.started_at ? String(detailRow.started_at).replace("T", " ") : "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
<div><Label className="text-xs">완료</Label><Input value={detailRow.completed_at ? String(detailRow.completed_at).replace("T", " ") : "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
</div>
|
||||
</div>
|
||||
{Number(detailRow.defect_qty) > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 pb-1.5 border-b">불량 정보</p>
|
||||
{parseDefectDetail(detailRow.defect_detail).length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{parseDefectDetail(detailRow.defect_detail).map((d: any, i: number) => (
|
||||
<div key={i} className="flex items-center gap-3 text-xs bg-red-50 px-3 py-1.5 rounded">
|
||||
<span className="font-medium">{d.defect_name || d.defect_code || "-"}</span>
|
||||
<span className="text-muted-foreground">수량: {d.qty || 0}</span>
|
||||
{d.disposition && <span className="text-muted-foreground">처리: {d.disposition}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">상세 불량 데이터 없음</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(detailRow.result_note || detailRow.remark) && (
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 pb-1.5 border-b">비고</p>
|
||||
<p className="text-xs bg-muted/50 p-2 rounded">{detailRow.result_note || detailRow.remark}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 생산실적관리(PC) — 하드코딩 페이지
|
||||
*
|
||||
* 좌측: 작업지시 목록 (work_instruction + detail + item_info JOIN)
|
||||
* 우측: 선택된 작업지시의 공정별 실적 (work_order_process)
|
||||
* - 요약 카드 (지시수량/양품/불량/달성률)
|
||||
* - 탭: 실적내역 / 불량내역
|
||||
* - 상세 모달
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import {
|
||||
Download, Loader2, Inbox, Search, BarChart3, AlertTriangle,
|
||||
CheckCircle2, Package, ChevronDown, ChevronRight, ClipboardList,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { toast } from "sonner";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
|
||||
const WI_TABLE = "work_instruction";
|
||||
const WOP_TABLE = "work_order_process";
|
||||
|
||||
const fmtNum = (v: any) => {
|
||||
const n = Number(v);
|
||||
return isNaN(n) ? "0" : n.toLocaleString();
|
||||
};
|
||||
|
||||
const fmtDate = (v: any) => {
|
||||
if (!v) return "-";
|
||||
const s = String(v);
|
||||
// "2026-04-06 19:29:07+09" or "2026-04-06T19:29:07"
|
||||
return s.split(/[T ]/)[0] || "-";
|
||||
};
|
||||
const fmtDateTime = (v: any) => {
|
||||
if (!v) return "-";
|
||||
const s = String(v);
|
||||
// "2026-04-06 19:29:07.049337+09" → "04-06 19:29"
|
||||
const match = s.match(/(\d{2})(\d{2})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/);
|
||||
if (match) return `${match[2]}-${match[3]}-${match[4]} ${match[5]}:${match[6]}`;
|
||||
return s.substring(0, 16);
|
||||
};
|
||||
|
||||
// 상태 뱃지
|
||||
const STATUS_CLS: Record<string, string> = {
|
||||
"일반": "bg-muted text-foreground",
|
||||
"긴급": "bg-destructive/10 text-destructive",
|
||||
};
|
||||
const PROGRESS_CLS: Record<string, string> = {
|
||||
"대기": "bg-amber-100 text-amber-700",
|
||||
"진행중": "bg-blue-100 text-blue-700",
|
||||
"완료": "bg-emerald-100 text-emerald-700",
|
||||
"acceptable": "bg-blue-100 text-blue-700",
|
||||
"completed": "bg-emerald-100 text-emerald-700",
|
||||
};
|
||||
const PROGRESS_LABEL: Record<string, string> = {
|
||||
"대기": "대기",
|
||||
"진행중": "진행중",
|
||||
"완료": "완료",
|
||||
"acceptable": "진행중",
|
||||
"completed": "완료",
|
||||
};
|
||||
|
||||
export default function ProductionResultPage() {
|
||||
const { user } = useAuth();
|
||||
|
||||
// ── 좌측: 작업지시 ──
|
||||
const [wiList, setWiList] = useState<any[]>([]);
|
||||
const [wiLoading, setWiLoading] = useState(false);
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
const [selectedWiId, setSelectedWiId] = useState<string | null>(null);
|
||||
const [groupBy, setGroupBy] = useState("none");
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
|
||||
// ── 우측: 실적 ──
|
||||
const [rightTab, setRightTab] = useState<"result" | "defect">("result");
|
||||
const [processData, setProcessData] = useState<any[]>([]);
|
||||
const [processLoading, setProcessLoading] = useState(false);
|
||||
|
||||
// ── 카테고리 ──
|
||||
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||
|
||||
// ── 상세 모달 ──
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detailRow, setDetailRow] = useState<any>(null);
|
||||
|
||||
// ════════ 카테고리 로드 ════════
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const opts: Record<string, { code: string; label: string }[]> = {};
|
||||
const flatten = (vals: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
for (const v of vals) {
|
||||
result.push({ code: v.valueCode || v.value || v.code, label: v.valueLabel || v.label || v.value });
|
||||
if (v.children?.length) result.push(...flatten(v.children));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
for (const col of ["status", "work_team"]) {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${WI_TABLE}/${col}/values`);
|
||||
if (res.data?.success && res.data.data?.length > 0) opts[col] = flatten(res.data.data);
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
setCatOptions(opts);
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const resolveCategory = (col: string, code: string) => {
|
||||
if (!code) return code;
|
||||
return catOptions[col]?.find((o) => o.code === code)?.label || code;
|
||||
};
|
||||
|
||||
// ════════ 데이터 로드 ════════
|
||||
|
||||
const fetchWiList = useCallback(async () => {
|
||||
setWiLoading(true);
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
for (const f of searchFilters) {
|
||||
if (f.value) {
|
||||
if (f.columnName === "progress_status") params.progressStatus = f.value;
|
||||
else if (f.columnName === "status") params.status = f.value;
|
||||
else params.keyword = f.value;
|
||||
}
|
||||
}
|
||||
const res = await apiClient.get("/work-instruction/list", { params });
|
||||
const raw: any[] = res.data?.data || [];
|
||||
|
||||
// work_instruction_no 기준 중복 제거 (detail JOIN으로 여러 행 반환)
|
||||
const seen = new Set<string>();
|
||||
const deduped = raw.filter((r) => {
|
||||
const key = r.work_instruction_no;
|
||||
if (!key || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
// 진행률 계산
|
||||
const enriched = deduped.map((r) => {
|
||||
const qty = Number(r.total_qty || r.qty) || 0;
|
||||
const completed = Number(r.completed_qty) || 0;
|
||||
return {
|
||||
...r,
|
||||
_qty: qty,
|
||||
_completed: completed,
|
||||
_rate: qty > 0 ? Math.round((completed / qty) * 100) : 0,
|
||||
};
|
||||
});
|
||||
setWiList(enriched);
|
||||
} catch {
|
||||
toast.error("작업지시 목록 조회 실패");
|
||||
} finally {
|
||||
setWiLoading(false);
|
||||
}
|
||||
}, [searchFilters]);
|
||||
|
||||
useEffect(() => { fetchWiList(); }, [fetchWiList]);
|
||||
|
||||
// 실적 로드
|
||||
useEffect(() => {
|
||||
if (!selectedWiId) { setProcessData([]); return; }
|
||||
const load = async () => {
|
||||
setProcessLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${WOP_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
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 || []);
|
||||
} catch { setProcessData([]); }
|
||||
finally { setProcessLoading(false); }
|
||||
};
|
||||
load();
|
||||
}, [selectedWiId]);
|
||||
|
||||
// ════════ 계산 ════════
|
||||
|
||||
const selectedWi = wiList.find((w) => (w.wi_id || w.id) === selectedWiId);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const instructionQty = selectedWi?._qty || 0;
|
||||
const goodQty = processData.reduce((s, r) => s + (Number(r.good_qty) || 0), 0);
|
||||
const defectQty = processData.reduce((s, r) => s + (Number(r.defect_qty) || 0), 0);
|
||||
const rate = instructionQty > 0 ? Math.round((goodQty / instructionQty) * 100) : 0;
|
||||
return { instructionQty, goodQty, defectQty, rate };
|
||||
}, [selectedWi, processData]);
|
||||
|
||||
const defectRows = useMemo(() => processData.filter((r) => Number(r.defect_qty) > 0), [processData]);
|
||||
|
||||
const totals = useMemo(() => ({
|
||||
input_qty: processData.reduce((s, r) => s + (Number(r.input_qty) || 0), 0),
|
||||
good_qty: processData.reduce((s, r) => s + (Number(r.good_qty) || 0), 0),
|
||||
defect_qty: processData.reduce((s, r) => s + (Number(r.defect_qty) || 0), 0),
|
||||
}), [processData]);
|
||||
|
||||
// ════════ 그룹핑 ════════
|
||||
|
||||
const groupedData = useMemo(() => {
|
||||
if (groupBy === "none") return wiList;
|
||||
const groups = new Map<string, any[]>();
|
||||
for (const wi of wiList) {
|
||||
let key = wi[groupBy] || "미지정";
|
||||
if (groupBy === "progress_status") key = PROGRESS_LABEL[key] || key;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(wi);
|
||||
}
|
||||
const result: any[] = [];
|
||||
groups.forEach((items, gk) => {
|
||||
result.push({ _group: true, _key: gk, _count: items.length });
|
||||
result.push(...items);
|
||||
});
|
||||
// 기본 전개
|
||||
if (expandedGroups.size === 0) setExpandedGroups(new Set(Array.from(groups.keys())));
|
||||
return result;
|
||||
}, [wiList, groupBy]);
|
||||
|
||||
const toggleGroup = (key: string) => {
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key); else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// ════════ 엑셀 ════════
|
||||
|
||||
const handleExcel = async () => {
|
||||
if (!selectedWi || processData.length === 0) {
|
||||
toast.error("다운로드할 데이터가 없습니다.");
|
||||
return;
|
||||
}
|
||||
const data = processData.map((r, i) => ({
|
||||
No: i + 1,
|
||||
공정: r.process_name || "-",
|
||||
설비: r.equipment_code || "-",
|
||||
생산수량: Number(r.input_qty) || 0,
|
||||
양품수량: Number(r.good_qty) || 0,
|
||||
불량수량: Number(r.defect_qty) || 0,
|
||||
"불량률(%)": Number(r.input_qty) > 0 ? ((Number(r.defect_qty) / Number(r.input_qty)) * 100).toFixed(1) : "0.0",
|
||||
시작: r.started_at || "",
|
||||
완료: r.completed_at || "",
|
||||
비고: r.result_note || r.remark || "",
|
||||
}));
|
||||
await exportToExcel(data, `생산실적_${selectedWi.work_instruction_no || ""}.xlsx`, "실적내역");
|
||||
toast.success("다운로드 완료");
|
||||
};
|
||||
|
||||
// defect_detail JSON 파싱
|
||||
const parseDefectDetail = (raw: any) => {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch { return []; }
|
||||
};
|
||||
|
||||
// ════════ 렌더 ════════
|
||||
|
||||
const wiKey = (r: any) => r.wi_id || r.id;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 p-3">
|
||||
{/* 검색 */}
|
||||
<DynamicSearchFilter
|
||||
tableName={WI_TABLE}
|
||||
filterId="c16-production-result"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={wiList.length}
|
||||
/>
|
||||
|
||||
{/* 메인 */}
|
||||
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card">
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
{/* ────── 좌측: 작업지시 목록 ────── */}
|
||||
<ResizablePanel defaultSize={45} minSize={25}>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold">작업지시 목록</span>
|
||||
<Badge variant="secondary" className="font-mono text-xs">{wiList.length}건</Badge>
|
||||
</div>
|
||||
<Select value={groupBy} onValueChange={(v) => { setGroupBy(v); setExpandedGroups(new Set()); }}>
|
||||
<SelectTrigger className="h-8 w-[120px] text-xs">
|
||||
<SelectValue placeholder="그룹없음" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">그룹없음</SelectItem>
|
||||
<SelectItem value="progress_status">진행상태별</SelectItem>
|
||||
<SelectItem value="equipment_name">설비별</SelectItem>
|
||||
<SelectItem value="work_team">작업조별</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{wiLoading ? (
|
||||
<div className="flex items-center justify-center h-full"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
|
||||
) : wiList.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||
<Inbox className="w-10 h-10 opacity-30" />
|
||||
<span className="text-sm">작업지시가 없어요</span>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[130px] text-[11px]">작업지시번호</TableHead>
|
||||
<TableHead className="w-[60px] text-center text-[11px]">상태</TableHead>
|
||||
<TableHead className="w-[60px] text-center text-[11px]">진행</TableHead>
|
||||
<TableHead className="w-[140px] text-[11px]">품목명</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px]">지시수량</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px]">진행률</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px]">설비</TableHead>
|
||||
<TableHead className="w-[60px] text-center text-[11px]">작업조</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px]">시작일</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px]">완료일</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groupedData.map((row, idx) => {
|
||||
if (row._group) {
|
||||
const expanded = expandedGroups.has(row._key);
|
||||
return (
|
||||
<TableRow key={`g-${row._key}`} className="bg-muted/60 cursor-pointer hover:bg-muted" onClick={() => toggleGroup(row._key)}>
|
||||
<TableCell colSpan={10} className="py-2 px-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
{expanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
{row._key}
|
||||
<Badge variant="outline" className="text-[10px]">{row._count}건</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
// 그룹 접힘 체크
|
||||
if (groupBy !== "none") {
|
||||
let gk = row[groupBy] || "미지정";
|
||||
if (groupBy === "progress_status") gk = PROGRESS_LABEL[gk] || gk;
|
||||
if (!expandedGroups.has(gk)) return null;
|
||||
}
|
||||
const id = wiKey(row);
|
||||
const selected = id === selectedWiId;
|
||||
const barColor = row._rate >= 100 ? "bg-emerald-500" : row._rate >= 50 ? "bg-primary" : "bg-amber-500";
|
||||
const statusLabel = resolveCategory("status", row.status) || row.status || "-";
|
||||
const progressLabel = PROGRESS_LABEL[row.progress_status] || row.progress_status || "-";
|
||||
return (
|
||||
<TableRow
|
||||
key={id || idx}
|
||||
className={cn("cursor-pointer", selected ? "bg-primary/5 border-l-[3px] border-l-primary" : "hover:bg-accent/50")}
|
||||
onClick={() => setSelectedWiId(id)}
|
||||
>
|
||||
<TableCell className="text-[13px] font-mono text-primary font-semibold">{row.work_instruction_no}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full", STATUS_CLS[statusLabel] || "bg-muted text-foreground")}>{statusLabel}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full", PROGRESS_CLS[row.progress_status] || "bg-muted text-foreground")}>{progressLabel}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[13px] truncate max-w-[140px]">{row.item_name || "-"}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono">{fmtNum(row._qty)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className={cn("h-full rounded-full", barColor)} style={{ width: `${Math.min(row._rate, 100)}%` }} />
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground w-7 text-right">{row._rate}%</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground truncate">{row.equipment_name || "-"}</TableCell>
|
||||
<TableCell className="text-[12px] text-center">{resolveCategory("work_team", row.work_team) || "-"}</TableCell>
|
||||
<TableCell className="text-[12px]">{fmtDate(row.start_date)}</TableCell>
|
||||
<TableCell className="text-[12px]">{fmtDate(row.end_date)}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* ────── 우측: 실적 데이터 ────── */}
|
||||
<ResizablePanel defaultSize={55} minSize={30}>
|
||||
{!selectedWiId ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
|
||||
<Package className="w-12 h-12 opacity-30" />
|
||||
<span className="text-sm">좌측에서 작업지시를 선택해주세요</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="w-4 h-4 text-primary" />
|
||||
<span className="text-sm font-semibold">생산실적(PC)</span>
|
||||
{selectedWi && <span className="text-xs text-muted-foreground">{selectedWi.item_name} / {selectedWi.work_instruction_no}</span>}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleExcel} disabled={processData.length === 0}>
|
||||
<Download className="w-3.5 h-3.5 mr-1" /> 엑셀
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 요약 카드 */}
|
||||
<div className="grid grid-cols-4 gap-3 px-4 py-3 border-b bg-muted/20 shrink-0">
|
||||
{[
|
||||
{ label: "지시수량", value: fmtNum(summary.instructionQty), icon: Package, color: "text-primary" },
|
||||
{ label: "양품수량", value: fmtNum(summary.goodQty), icon: CheckCircle2, color: "text-emerald-600" },
|
||||
{ label: "불량수량", value: fmtNum(summary.defectQty), icon: AlertTriangle, color: "text-destructive" },
|
||||
{ label: "달성률", value: `${summary.rate}%`, icon: BarChart3, color: "text-violet-600" },
|
||||
].map((card) => (
|
||||
<div key={card.label} className="bg-card border rounded-lg px-3 py-2.5 text-center">
|
||||
<div className="text-[10px] font-semibold text-muted-foreground mb-1">{card.label}</div>
|
||||
<div className={cn("text-lg font-bold", card.color)}>{card.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 탭 */}
|
||||
<div className="flex border-b shrink-0">
|
||||
{(["result", "defect"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
className={cn(
|
||||
"px-4 py-2.5 text-xs font-semibold border-b-2 transition-colors",
|
||||
rightTab === tab ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setRightTab(tab)}
|
||||
>
|
||||
{tab === "result" ? "📊 실적내역" : "⚠️ 불량내역"}
|
||||
{tab === "defect" && defectRows.length > 0 && (
|
||||
<Badge variant="destructive" className="ml-1.5 text-[9px] px-1.5 py-0">{defectRows.length}</Badge>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 탭 콘텐츠 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{processLoading ? (
|
||||
<div className="flex items-center justify-center h-full"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>
|
||||
) : processData.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||
<Inbox className="w-8 h-8 opacity-30" />
|
||||
<span className="text-sm">등록된 실적이 없습니다</span>
|
||||
<span className="text-xs">POP에서 실적을 등록해주세요</span>
|
||||
</div>
|
||||
) : rightTab === "result" ? (
|
||||
/* ── 실적내역 ── */
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center text-[11px]">No</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px]">공정</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px]">설비</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px]">생산수량</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px]">양품수량</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px]">불량수량</TableHead>
|
||||
<TableHead className="w-[60px] text-center text-[11px]">불량률</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px]">작업시간</TableHead>
|
||||
<TableHead className="w-[60px] text-[11px]">상태</TableHead>
|
||||
<TableHead className="text-[11px]">비고</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{processData.map((r, i) => {
|
||||
const inputQty = Number(r.input_qty) || 0;
|
||||
const defectQty = Number(r.defect_qty) || 0;
|
||||
const defRate = inputQty > 0 ? ((defectQty / inputQty) * 100).toFixed(1) : "0.0";
|
||||
return (
|
||||
<TableRow key={r.id || i} className="cursor-pointer hover:bg-accent/50" onClick={() => { setDetailRow(r); setDetailOpen(true); }}>
|
||||
<TableCell className="text-center text-[13px]">{i + 1}</TableCell>
|
||||
<TableCell className="text-[13px]">
|
||||
<span className="px-2 py-0.5 rounded bg-amber-50 text-amber-700 text-[11px] font-medium">{r.process_name || "-"}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground">{r.equipment_code || "-"}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono font-semibold">{fmtNum(r.input_qty)}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono text-emerald-600 font-semibold">{fmtNum(r.good_qty)}</TableCell>
|
||||
<TableCell className={cn("text-[13px] text-right font-mono font-semibold", defectQty > 0 ? "text-destructive" : "text-muted-foreground")}>{fmtNum(r.defect_qty)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full", parseFloat(defRate) > 2 ? "bg-red-100 text-red-700" : "bg-emerald-100 text-emerald-700")}>{defRate}%</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px]">{fmtDateTime(r.started_at)} ~ {fmtDateTime(r.completed_at)}</TableCell>
|
||||
<TableCell className="text-[12px]">
|
||||
<span className={cn("text-[10px] px-2 py-0.5 rounded-full font-medium", r.status === "completed" ? "bg-emerald-100 text-emerald-700" : r.status === "in_progress" ? "bg-blue-100 text-blue-700" : "bg-muted text-muted-foreground")}>{r.status || "-"}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground truncate max-w-[120px]">{r.result_note || r.remark || "-"}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{/* 합계 */}
|
||||
<TableRow className="bg-muted/60 font-bold border-t-2">
|
||||
<TableCell colSpan={3} className="text-center text-[13px]">합계</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px]">{fmtNum(totals.input_qty)}</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px] text-emerald-600">{fmtNum(totals.good_qty)}</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px] text-destructive">{fmtNum(totals.defect_qty)}</TableCell>
|
||||
<TableCell className="text-center text-[12px]">
|
||||
{totals.input_qty > 0 ? ((totals.defect_qty / totals.input_qty) * 100).toFixed(1) : "0.0"}%
|
||||
</TableCell>
|
||||
<TableCell colSpan={3} />
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
/* ── 불량내역 ── */
|
||||
defectRows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||
<CheckCircle2 className="w-8 h-8 opacity-30" />
|
||||
<span className="text-sm">불량 내역이 없습니다</span>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center text-[11px]">No</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px]">공정</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px]">설비</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px]">불량수량</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px]">불량유형</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px]">불량원인</TableHead>
|
||||
<TableHead className="text-[11px]">비고</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{defectRows.map((r, i) => {
|
||||
const details = parseDefectDetail(r.defect_detail);
|
||||
const defType = details.map((d: any) => d.defect_name || d.defect_code).filter(Boolean).join(", ") || "-";
|
||||
return (
|
||||
<TableRow key={r.id || i} className="cursor-pointer hover:bg-accent/50" onClick={() => { setDetailRow(r); setDetailOpen(true); }}>
|
||||
<TableCell className="text-center text-[13px]">{i + 1}</TableCell>
|
||||
<TableCell className="text-[13px]">
|
||||
<span className="px-2 py-0.5 rounded bg-amber-50 text-amber-700 text-[11px] font-medium">{r.process_name || "-"}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground">{r.equipment_code || "-"}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono font-bold text-destructive">{fmtNum(r.defect_qty)}</TableCell>
|
||||
<TableCell className="text-[12px]">
|
||||
<span className="px-2 py-0.5 rounded bg-amber-50 text-amber-700 text-[11px]">{defType}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground">{r.result_note || "-"}</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground truncate">{r.remark || "-"}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
{/* ── 상세 모달 ── */}
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent className="max-w-[600px] max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2"><BarChart3 className="w-5 h-5" /> 실적 상세</DialogTitle>
|
||||
<DialogDescription>생산 실적의 상세 정보입니다</DialogDescription>
|
||||
</DialogHeader>
|
||||
{detailRow && (
|
||||
<div className="space-y-4 pt-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 pb-1.5 border-b">기본 정보</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label className="text-xs">공정</Label><Input value={detailRow.process_name || "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
<div><Label className="text-xs">설비</Label><Input value={detailRow.equipment_code || "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
<div><Label className="text-xs">순번</Label><Input value={detailRow.seq_no || "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
<div><Label className="text-xs">상태</Label><Input value={detailRow.status || "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 pb-1.5 border-b">수량 정보</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div><Label className="text-xs">생산수량</Label><Input value={fmtNum(detailRow.input_qty)} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
<div><Label className="text-xs">양품수량</Label><Input value={fmtNum(detailRow.good_qty)} readOnly className="h-8 text-xs bg-muted/50 text-emerald-600 font-semibold" /></div>
|
||||
<div><Label className="text-xs">불량수량</Label><Input value={fmtNum(detailRow.defect_qty)} readOnly className={cn("h-8 text-xs bg-muted/50 font-semibold", Number(detailRow.defect_qty) > 0 ? "text-destructive" : "")} /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 pb-1.5 border-b">작업 시간</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label className="text-xs">시작</Label><Input value={detailRow.started_at ? String(detailRow.started_at).replace("T", " ") : "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
<div><Label className="text-xs">완료</Label><Input value={detailRow.completed_at ? String(detailRow.completed_at).replace("T", " ") : "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
</div>
|
||||
</div>
|
||||
{Number(detailRow.defect_qty) > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 pb-1.5 border-b">불량 정보</p>
|
||||
{parseDefectDetail(detailRow.defect_detail).length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{parseDefectDetail(detailRow.defect_detail).map((d: any, i: number) => (
|
||||
<div key={i} className="flex items-center gap-3 text-xs bg-red-50 px-3 py-1.5 rounded">
|
||||
<span className="font-medium">{d.defect_name || d.defect_code || "-"}</span>
|
||||
<span className="text-muted-foreground">수량: {d.qty || 0}</span>
|
||||
{d.disposition && <span className="text-muted-foreground">처리: {d.disposition}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">상세 불량 데이터 없음</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(detailRow.result_note || detailRow.remark) && (
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 pb-1.5 border-b">비고</p>
|
||||
<p className="text-xs bg-muted/50 p-2 rounded">{detailRow.result_note || detailRow.remark}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 생산실적관리(PC) — 하드코딩 페이지
|
||||
*
|
||||
* 좌측: 작업지시 목록 (work_instruction + detail + item_info JOIN)
|
||||
* 우측: 선택된 작업지시의 공정별 실적 (work_order_process)
|
||||
* - 요약 카드 (지시수량/양품/불량/달성률)
|
||||
* - 탭: 실적내역 / 불량내역
|
||||
* - 상세 모달
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import {
|
||||
Download, Loader2, Inbox, Search, BarChart3, AlertTriangle,
|
||||
CheckCircle2, Package, ChevronDown, ChevronRight, ClipboardList,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { toast } from "sonner";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
|
||||
const WI_TABLE = "work_instruction";
|
||||
const WOP_TABLE = "work_order_process";
|
||||
|
||||
const fmtNum = (v: any) => {
|
||||
const n = Number(v);
|
||||
return isNaN(n) ? "0" : n.toLocaleString();
|
||||
};
|
||||
|
||||
const fmtDate = (v: any) => {
|
||||
if (!v) return "-";
|
||||
const s = String(v);
|
||||
// "2026-04-06 19:29:07+09" or "2026-04-06T19:29:07"
|
||||
return s.split(/[T ]/)[0] || "-";
|
||||
};
|
||||
const fmtDateTime = (v: any) => {
|
||||
if (!v) return "-";
|
||||
const s = String(v);
|
||||
// "2026-04-06 19:29:07.049337+09" → "04-06 19:29"
|
||||
const match = s.match(/(\d{2})(\d{2})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/);
|
||||
if (match) return `${match[2]}-${match[3]}-${match[4]} ${match[5]}:${match[6]}`;
|
||||
return s.substring(0, 16);
|
||||
};
|
||||
|
||||
// 상태 뱃지
|
||||
const STATUS_CLS: Record<string, string> = {
|
||||
"일반": "bg-muted text-foreground",
|
||||
"긴급": "bg-destructive/10 text-destructive",
|
||||
};
|
||||
const PROGRESS_CLS: Record<string, string> = {
|
||||
"대기": "bg-amber-100 text-amber-700",
|
||||
"진행중": "bg-blue-100 text-blue-700",
|
||||
"완료": "bg-emerald-100 text-emerald-700",
|
||||
"acceptable": "bg-blue-100 text-blue-700",
|
||||
"completed": "bg-emerald-100 text-emerald-700",
|
||||
};
|
||||
const PROGRESS_LABEL: Record<string, string> = {
|
||||
"대기": "대기",
|
||||
"진행중": "진행중",
|
||||
"완료": "완료",
|
||||
"acceptable": "진행중",
|
||||
"completed": "완료",
|
||||
};
|
||||
|
||||
export default function ProductionResultPage() {
|
||||
const { user } = useAuth();
|
||||
|
||||
// ── 좌측: 작업지시 ──
|
||||
const [wiList, setWiList] = useState<any[]>([]);
|
||||
const [wiLoading, setWiLoading] = useState(false);
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
const [selectedWiId, setSelectedWiId] = useState<string | null>(null);
|
||||
const [groupBy, setGroupBy] = useState("none");
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
|
||||
// ── 우측: 실적 ──
|
||||
const [rightTab, setRightTab] = useState<"result" | "defect">("result");
|
||||
const [processData, setProcessData] = useState<any[]>([]);
|
||||
const [processLoading, setProcessLoading] = useState(false);
|
||||
|
||||
// ── 카테고리 ──
|
||||
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||
|
||||
// ── 상세 모달 ──
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detailRow, setDetailRow] = useState<any>(null);
|
||||
|
||||
// ════════ 카테고리 로드 ════════
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const opts: Record<string, { code: string; label: string }[]> = {};
|
||||
const flatten = (vals: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
for (const v of vals) {
|
||||
result.push({ code: v.valueCode || v.value || v.code, label: v.valueLabel || v.label || v.value });
|
||||
if (v.children?.length) result.push(...flatten(v.children));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
for (const col of ["status", "work_team"]) {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${WI_TABLE}/${col}/values`);
|
||||
if (res.data?.success && res.data.data?.length > 0) opts[col] = flatten(res.data.data);
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
setCatOptions(opts);
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const resolveCategory = (col: string, code: string) => {
|
||||
if (!code) return code;
|
||||
return catOptions[col]?.find((o) => o.code === code)?.label || code;
|
||||
};
|
||||
|
||||
// ════════ 데이터 로드 ════════
|
||||
|
||||
const fetchWiList = useCallback(async () => {
|
||||
setWiLoading(true);
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
for (const f of searchFilters) {
|
||||
if (f.value) {
|
||||
if (f.columnName === "progress_status") params.progressStatus = f.value;
|
||||
else if (f.columnName === "status") params.status = f.value;
|
||||
else params.keyword = f.value;
|
||||
}
|
||||
}
|
||||
const res = await apiClient.get("/work-instruction/list", { params });
|
||||
const raw: any[] = res.data?.data || [];
|
||||
|
||||
// work_instruction_no 기준 중복 제거 (detail JOIN으로 여러 행 반환)
|
||||
const seen = new Set<string>();
|
||||
const deduped = raw.filter((r) => {
|
||||
const key = r.work_instruction_no;
|
||||
if (!key || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
// 진행률 계산
|
||||
const enriched = deduped.map((r) => {
|
||||
const qty = Number(r.total_qty || r.qty) || 0;
|
||||
const completed = Number(r.completed_qty) || 0;
|
||||
return {
|
||||
...r,
|
||||
_qty: qty,
|
||||
_completed: completed,
|
||||
_rate: qty > 0 ? Math.round((completed / qty) * 100) : 0,
|
||||
};
|
||||
});
|
||||
setWiList(enriched);
|
||||
} catch {
|
||||
toast.error("작업지시 목록 조회 실패");
|
||||
} finally {
|
||||
setWiLoading(false);
|
||||
}
|
||||
}, [searchFilters]);
|
||||
|
||||
useEffect(() => { fetchWiList(); }, [fetchWiList]);
|
||||
|
||||
// 실적 로드
|
||||
useEffect(() => {
|
||||
if (!selectedWiId) { setProcessData([]); return; }
|
||||
const load = async () => {
|
||||
setProcessLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${WOP_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
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 || []);
|
||||
} catch { setProcessData([]); }
|
||||
finally { setProcessLoading(false); }
|
||||
};
|
||||
load();
|
||||
}, [selectedWiId]);
|
||||
|
||||
// ════════ 계산 ════════
|
||||
|
||||
const selectedWi = wiList.find((w) => (w.wi_id || w.id) === selectedWiId);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const instructionQty = selectedWi?._qty || 0;
|
||||
const goodQty = processData.reduce((s, r) => s + (Number(r.good_qty) || 0), 0);
|
||||
const defectQty = processData.reduce((s, r) => s + (Number(r.defect_qty) || 0), 0);
|
||||
const rate = instructionQty > 0 ? Math.round((goodQty / instructionQty) * 100) : 0;
|
||||
return { instructionQty, goodQty, defectQty, rate };
|
||||
}, [selectedWi, processData]);
|
||||
|
||||
const defectRows = useMemo(() => processData.filter((r) => Number(r.defect_qty) > 0), [processData]);
|
||||
|
||||
const totals = useMemo(() => ({
|
||||
input_qty: processData.reduce((s, r) => s + (Number(r.input_qty) || 0), 0),
|
||||
good_qty: processData.reduce((s, r) => s + (Number(r.good_qty) || 0), 0),
|
||||
defect_qty: processData.reduce((s, r) => s + (Number(r.defect_qty) || 0), 0),
|
||||
}), [processData]);
|
||||
|
||||
// ════════ 그룹핑 ════════
|
||||
|
||||
const groupedData = useMemo(() => {
|
||||
if (groupBy === "none") return wiList;
|
||||
const groups = new Map<string, any[]>();
|
||||
for (const wi of wiList) {
|
||||
let key = wi[groupBy] || "미지정";
|
||||
if (groupBy === "progress_status") key = PROGRESS_LABEL[key] || key;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(wi);
|
||||
}
|
||||
const result: any[] = [];
|
||||
groups.forEach((items, gk) => {
|
||||
result.push({ _group: true, _key: gk, _count: items.length });
|
||||
result.push(...items);
|
||||
});
|
||||
// 기본 전개
|
||||
if (expandedGroups.size === 0) setExpandedGroups(new Set(Array.from(groups.keys())));
|
||||
return result;
|
||||
}, [wiList, groupBy]);
|
||||
|
||||
const toggleGroup = (key: string) => {
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key); else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// ════════ 엑셀 ════════
|
||||
|
||||
const handleExcel = async () => {
|
||||
if (!selectedWi || processData.length === 0) {
|
||||
toast.error("다운로드할 데이터가 없습니다.");
|
||||
return;
|
||||
}
|
||||
const data = processData.map((r, i) => ({
|
||||
No: i + 1,
|
||||
공정: r.process_name || "-",
|
||||
설비: r.equipment_code || "-",
|
||||
생산수량: Number(r.input_qty) || 0,
|
||||
양품수량: Number(r.good_qty) || 0,
|
||||
불량수량: Number(r.defect_qty) || 0,
|
||||
"불량률(%)": Number(r.input_qty) > 0 ? ((Number(r.defect_qty) / Number(r.input_qty)) * 100).toFixed(1) : "0.0",
|
||||
시작: r.started_at || "",
|
||||
완료: r.completed_at || "",
|
||||
비고: r.result_note || r.remark || "",
|
||||
}));
|
||||
await exportToExcel(data, `생산실적_${selectedWi.work_instruction_no || ""}.xlsx`, "실적내역");
|
||||
toast.success("다운로드 완료");
|
||||
};
|
||||
|
||||
// defect_detail JSON 파싱
|
||||
const parseDefectDetail = (raw: any) => {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch { return []; }
|
||||
};
|
||||
|
||||
// ════════ 렌더 ════════
|
||||
|
||||
const wiKey = (r: any) => r.wi_id || r.id;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 p-3">
|
||||
{/* 검색 */}
|
||||
<DynamicSearchFilter
|
||||
tableName={WI_TABLE}
|
||||
filterId="c16-production-result"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={wiList.length}
|
||||
/>
|
||||
|
||||
{/* 메인 */}
|
||||
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card">
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
{/* ────── 좌측: 작업지시 목록 ────── */}
|
||||
<ResizablePanel defaultSize={45} minSize={25}>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold">작업지시 목록</span>
|
||||
<Badge variant="secondary" className="font-mono text-xs">{wiList.length}건</Badge>
|
||||
</div>
|
||||
<Select value={groupBy} onValueChange={(v) => { setGroupBy(v); setExpandedGroups(new Set()); }}>
|
||||
<SelectTrigger className="h-8 w-[120px] text-xs">
|
||||
<SelectValue placeholder="그룹없음" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">그룹없음</SelectItem>
|
||||
<SelectItem value="progress_status">진행상태별</SelectItem>
|
||||
<SelectItem value="equipment_name">설비별</SelectItem>
|
||||
<SelectItem value="work_team">작업조별</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{wiLoading ? (
|
||||
<div className="flex items-center justify-center h-full"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
|
||||
) : wiList.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||
<Inbox className="w-10 h-10 opacity-30" />
|
||||
<span className="text-sm">작업지시가 없어요</span>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[130px] text-[11px]">작업지시번호</TableHead>
|
||||
<TableHead className="w-[60px] text-center text-[11px]">상태</TableHead>
|
||||
<TableHead className="w-[60px] text-center text-[11px]">진행</TableHead>
|
||||
<TableHead className="w-[140px] text-[11px]">품목명</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px]">지시수량</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px]">진행률</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px]">설비</TableHead>
|
||||
<TableHead className="w-[60px] text-center text-[11px]">작업조</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px]">시작일</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px]">완료일</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groupedData.map((row, idx) => {
|
||||
if (row._group) {
|
||||
const expanded = expandedGroups.has(row._key);
|
||||
return (
|
||||
<TableRow key={`g-${row._key}`} className="bg-muted/60 cursor-pointer hover:bg-muted" onClick={() => toggleGroup(row._key)}>
|
||||
<TableCell colSpan={10} className="py-2 px-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
{expanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
{row._key}
|
||||
<Badge variant="outline" className="text-[10px]">{row._count}건</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
// 그룹 접힘 체크
|
||||
if (groupBy !== "none") {
|
||||
let gk = row[groupBy] || "미지정";
|
||||
if (groupBy === "progress_status") gk = PROGRESS_LABEL[gk] || gk;
|
||||
if (!expandedGroups.has(gk)) return null;
|
||||
}
|
||||
const id = wiKey(row);
|
||||
const selected = id === selectedWiId;
|
||||
const barColor = row._rate >= 100 ? "bg-emerald-500" : row._rate >= 50 ? "bg-primary" : "bg-amber-500";
|
||||
const statusLabel = resolveCategory("status", row.status) || row.status || "-";
|
||||
const progressLabel = PROGRESS_LABEL[row.progress_status] || row.progress_status || "-";
|
||||
return (
|
||||
<TableRow
|
||||
key={id || idx}
|
||||
className={cn("cursor-pointer", selected ? "bg-primary/5 border-l-[3px] border-l-primary" : "hover:bg-accent/50")}
|
||||
onClick={() => setSelectedWiId(id)}
|
||||
>
|
||||
<TableCell className="text-[13px] font-mono text-primary font-semibold">{row.work_instruction_no}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full", STATUS_CLS[statusLabel] || "bg-muted text-foreground")}>{statusLabel}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full", PROGRESS_CLS[row.progress_status] || "bg-muted text-foreground")}>{progressLabel}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[13px] truncate max-w-[140px]">{row.item_name || "-"}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono">{fmtNum(row._qty)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className={cn("h-full rounded-full", barColor)} style={{ width: `${Math.min(row._rate, 100)}%` }} />
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground w-7 text-right">{row._rate}%</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground truncate">{row.equipment_name || "-"}</TableCell>
|
||||
<TableCell className="text-[12px] text-center">{resolveCategory("work_team", row.work_team) || "-"}</TableCell>
|
||||
<TableCell className="text-[12px]">{fmtDate(row.start_date)}</TableCell>
|
||||
<TableCell className="text-[12px]">{fmtDate(row.end_date)}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* ────── 우측: 실적 데이터 ────── */}
|
||||
<ResizablePanel defaultSize={55} minSize={30}>
|
||||
{!selectedWiId ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
|
||||
<Package className="w-12 h-12 opacity-30" />
|
||||
<span className="text-sm">좌측에서 작업지시를 선택해주세요</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="w-4 h-4 text-primary" />
|
||||
<span className="text-sm font-semibold">생산실적(PC)</span>
|
||||
{selectedWi && <span className="text-xs text-muted-foreground">{selectedWi.item_name} / {selectedWi.work_instruction_no}</span>}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleExcel} disabled={processData.length === 0}>
|
||||
<Download className="w-3.5 h-3.5 mr-1" /> 엑셀
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 요약 카드 */}
|
||||
<div className="grid grid-cols-4 gap-3 px-4 py-3 border-b bg-muted/20 shrink-0">
|
||||
{[
|
||||
{ label: "지시수량", value: fmtNum(summary.instructionQty), icon: Package, color: "text-primary" },
|
||||
{ label: "양품수량", value: fmtNum(summary.goodQty), icon: CheckCircle2, color: "text-emerald-600" },
|
||||
{ label: "불량수량", value: fmtNum(summary.defectQty), icon: AlertTriangle, color: "text-destructive" },
|
||||
{ label: "달성률", value: `${summary.rate}%`, icon: BarChart3, color: "text-violet-600" },
|
||||
].map((card) => (
|
||||
<div key={card.label} className="bg-card border rounded-lg px-3 py-2.5 text-center">
|
||||
<div className="text-[10px] font-semibold text-muted-foreground mb-1">{card.label}</div>
|
||||
<div className={cn("text-lg font-bold", card.color)}>{card.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 탭 */}
|
||||
<div className="flex border-b shrink-0">
|
||||
{(["result", "defect"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
className={cn(
|
||||
"px-4 py-2.5 text-xs font-semibold border-b-2 transition-colors",
|
||||
rightTab === tab ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setRightTab(tab)}
|
||||
>
|
||||
{tab === "result" ? "📊 실적내역" : "⚠️ 불량내역"}
|
||||
{tab === "defect" && defectRows.length > 0 && (
|
||||
<Badge variant="destructive" className="ml-1.5 text-[9px] px-1.5 py-0">{defectRows.length}</Badge>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 탭 콘텐츠 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{processLoading ? (
|
||||
<div className="flex items-center justify-center h-full"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>
|
||||
) : processData.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||
<Inbox className="w-8 h-8 opacity-30" />
|
||||
<span className="text-sm">등록된 실적이 없습니다</span>
|
||||
<span className="text-xs">POP에서 실적을 등록해주세요</span>
|
||||
</div>
|
||||
) : rightTab === "result" ? (
|
||||
/* ── 실적내역 ── */
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center text-[11px]">No</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px]">공정</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px]">설비</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px]">생산수량</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px]">양품수량</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px]">불량수량</TableHead>
|
||||
<TableHead className="w-[60px] text-center text-[11px]">불량률</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px]">작업시간</TableHead>
|
||||
<TableHead className="w-[60px] text-[11px]">상태</TableHead>
|
||||
<TableHead className="text-[11px]">비고</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{processData.map((r, i) => {
|
||||
const inputQty = Number(r.input_qty) || 0;
|
||||
const defectQty = Number(r.defect_qty) || 0;
|
||||
const defRate = inputQty > 0 ? ((defectQty / inputQty) * 100).toFixed(1) : "0.0";
|
||||
return (
|
||||
<TableRow key={r.id || i} className="cursor-pointer hover:bg-accent/50" onClick={() => { setDetailRow(r); setDetailOpen(true); }}>
|
||||
<TableCell className="text-center text-[13px]">{i + 1}</TableCell>
|
||||
<TableCell className="text-[13px]">
|
||||
<span className="px-2 py-0.5 rounded bg-amber-50 text-amber-700 text-[11px] font-medium">{r.process_name || "-"}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground">{r.equipment_code || "-"}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono font-semibold">{fmtNum(r.input_qty)}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono text-emerald-600 font-semibold">{fmtNum(r.good_qty)}</TableCell>
|
||||
<TableCell className={cn("text-[13px] text-right font-mono font-semibold", defectQty > 0 ? "text-destructive" : "text-muted-foreground")}>{fmtNum(r.defect_qty)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full", parseFloat(defRate) > 2 ? "bg-red-100 text-red-700" : "bg-emerald-100 text-emerald-700")}>{defRate}%</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px]">{fmtDateTime(r.started_at)} ~ {fmtDateTime(r.completed_at)}</TableCell>
|
||||
<TableCell className="text-[12px]">
|
||||
<span className={cn("text-[10px] px-2 py-0.5 rounded-full font-medium", r.status === "completed" ? "bg-emerald-100 text-emerald-700" : r.status === "in_progress" ? "bg-blue-100 text-blue-700" : "bg-muted text-muted-foreground")}>{r.status || "-"}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground truncate max-w-[120px]">{r.result_note || r.remark || "-"}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{/* 합계 */}
|
||||
<TableRow className="bg-muted/60 font-bold border-t-2">
|
||||
<TableCell colSpan={3} className="text-center text-[13px]">합계</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px]">{fmtNum(totals.input_qty)}</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px] text-emerald-600">{fmtNum(totals.good_qty)}</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px] text-destructive">{fmtNum(totals.defect_qty)}</TableCell>
|
||||
<TableCell className="text-center text-[12px]">
|
||||
{totals.input_qty > 0 ? ((totals.defect_qty / totals.input_qty) * 100).toFixed(1) : "0.0"}%
|
||||
</TableCell>
|
||||
<TableCell colSpan={3} />
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
/* ── 불량내역 ── */
|
||||
defectRows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||
<CheckCircle2 className="w-8 h-8 opacity-30" />
|
||||
<span className="text-sm">불량 내역이 없습니다</span>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center text-[11px]">No</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px]">공정</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px]">설비</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px]">불량수량</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px]">불량유형</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px]">불량원인</TableHead>
|
||||
<TableHead className="text-[11px]">비고</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{defectRows.map((r, i) => {
|
||||
const details = parseDefectDetail(r.defect_detail);
|
||||
const defType = details.map((d: any) => d.defect_name || d.defect_code).filter(Boolean).join(", ") || "-";
|
||||
return (
|
||||
<TableRow key={r.id || i} className="cursor-pointer hover:bg-accent/50" onClick={() => { setDetailRow(r); setDetailOpen(true); }}>
|
||||
<TableCell className="text-center text-[13px]">{i + 1}</TableCell>
|
||||
<TableCell className="text-[13px]">
|
||||
<span className="px-2 py-0.5 rounded bg-amber-50 text-amber-700 text-[11px] font-medium">{r.process_name || "-"}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground">{r.equipment_code || "-"}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono font-bold text-destructive">{fmtNum(r.defect_qty)}</TableCell>
|
||||
<TableCell className="text-[12px]">
|
||||
<span className="px-2 py-0.5 rounded bg-amber-50 text-amber-700 text-[11px]">{defType}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground">{r.result_note || "-"}</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground truncate">{r.remark || "-"}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
{/* ── 상세 모달 ── */}
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent className="max-w-[600px] max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2"><BarChart3 className="w-5 h-5" /> 실적 상세</DialogTitle>
|
||||
<DialogDescription>생산 실적의 상세 정보입니다</DialogDescription>
|
||||
</DialogHeader>
|
||||
{detailRow && (
|
||||
<div className="space-y-4 pt-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 pb-1.5 border-b">기본 정보</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label className="text-xs">공정</Label><Input value={detailRow.process_name || "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
<div><Label className="text-xs">설비</Label><Input value={detailRow.equipment_code || "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
<div><Label className="text-xs">순번</Label><Input value={detailRow.seq_no || "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
<div><Label className="text-xs">상태</Label><Input value={detailRow.status || "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 pb-1.5 border-b">수량 정보</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div><Label className="text-xs">생산수량</Label><Input value={fmtNum(detailRow.input_qty)} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
<div><Label className="text-xs">양품수량</Label><Input value={fmtNum(detailRow.good_qty)} readOnly className="h-8 text-xs bg-muted/50 text-emerald-600 font-semibold" /></div>
|
||||
<div><Label className="text-xs">불량수량</Label><Input value={fmtNum(detailRow.defect_qty)} readOnly className={cn("h-8 text-xs bg-muted/50 font-semibold", Number(detailRow.defect_qty) > 0 ? "text-destructive" : "")} /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 pb-1.5 border-b">작업 시간</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label className="text-xs">시작</Label><Input value={detailRow.started_at ? String(detailRow.started_at).replace("T", " ") : "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
<div><Label className="text-xs">완료</Label><Input value={detailRow.completed_at ? String(detailRow.completed_at).replace("T", " ") : "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
</div>
|
||||
</div>
|
||||
{Number(detailRow.defect_qty) > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 pb-1.5 border-b">불량 정보</p>
|
||||
{parseDefectDetail(detailRow.defect_detail).length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{parseDefectDetail(detailRow.defect_detail).map((d: any, i: number) => (
|
||||
<div key={i} className="flex items-center gap-3 text-xs bg-red-50 px-3 py-1.5 rounded">
|
||||
<span className="font-medium">{d.defect_name || d.defect_code || "-"}</span>
|
||||
<span className="text-muted-foreground">수량: {d.qty || 0}</span>
|
||||
{d.disposition && <span className="text-muted-foreground">처리: {d.disposition}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">상세 불량 데이터 없음</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(detailRow.result_note || detailRow.remark) && (
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 pb-1.5 border-b">비고</p>
|
||||
<p className="text-xs bg-muted/50 p-2 rounded">{detailRow.result_note || detailRow.remark}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 생산실적관리(PC) — 하드코딩 페이지
|
||||
*
|
||||
* 좌측: 작업지시 목록 (work_instruction + detail + item_info JOIN)
|
||||
* 우측: 선택된 작업지시의 공정별 실적 (work_order_process)
|
||||
* - 요약 카드 (지시수량/양품/불량/달성률)
|
||||
* - 탭: 실적내역 / 불량내역
|
||||
* - 상세 모달
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import {
|
||||
Download, Loader2, Inbox, Search, BarChart3, AlertTriangle,
|
||||
CheckCircle2, Package, ChevronDown, ChevronRight, ClipboardList,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { toast } from "sonner";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
|
||||
const WI_TABLE = "work_instruction";
|
||||
const WOP_TABLE = "work_order_process";
|
||||
|
||||
const fmtNum = (v: any) => {
|
||||
const n = Number(v);
|
||||
return isNaN(n) ? "0" : n.toLocaleString();
|
||||
};
|
||||
|
||||
const fmtDate = (v: any) => {
|
||||
if (!v) return "-";
|
||||
const s = String(v);
|
||||
// "2026-04-06 19:29:07+09" or "2026-04-06T19:29:07"
|
||||
return s.split(/[T ]/)[0] || "-";
|
||||
};
|
||||
const fmtDateTime = (v: any) => {
|
||||
if (!v) return "-";
|
||||
const s = String(v);
|
||||
// "2026-04-06 19:29:07.049337+09" → "04-06 19:29"
|
||||
const match = s.match(/(\d{2})(\d{2})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/);
|
||||
if (match) return `${match[2]}-${match[3]}-${match[4]} ${match[5]}:${match[6]}`;
|
||||
return s.substring(0, 16);
|
||||
};
|
||||
|
||||
// 상태 뱃지
|
||||
const STATUS_CLS: Record<string, string> = {
|
||||
"일반": "bg-muted text-foreground",
|
||||
"긴급": "bg-destructive/10 text-destructive",
|
||||
};
|
||||
const PROGRESS_CLS: Record<string, string> = {
|
||||
"대기": "bg-amber-100 text-amber-700",
|
||||
"진행중": "bg-blue-100 text-blue-700",
|
||||
"완료": "bg-emerald-100 text-emerald-700",
|
||||
"acceptable": "bg-blue-100 text-blue-700",
|
||||
"completed": "bg-emerald-100 text-emerald-700",
|
||||
};
|
||||
const PROGRESS_LABEL: Record<string, string> = {
|
||||
"대기": "대기",
|
||||
"진행중": "진행중",
|
||||
"완료": "완료",
|
||||
"acceptable": "진행중",
|
||||
"completed": "완료",
|
||||
};
|
||||
|
||||
export default function ProductionResultPage() {
|
||||
const { user } = useAuth();
|
||||
|
||||
// ── 좌측: 작업지시 ──
|
||||
const [wiList, setWiList] = useState<any[]>([]);
|
||||
const [wiLoading, setWiLoading] = useState(false);
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
const [selectedWiId, setSelectedWiId] = useState<string | null>(null);
|
||||
const [groupBy, setGroupBy] = useState("none");
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
|
||||
// ── 우측: 실적 ──
|
||||
const [rightTab, setRightTab] = useState<"result" | "defect">("result");
|
||||
const [processData, setProcessData] = useState<any[]>([]);
|
||||
const [processLoading, setProcessLoading] = useState(false);
|
||||
|
||||
// ── 카테고리 ──
|
||||
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||
|
||||
// ── 상세 모달 ──
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detailRow, setDetailRow] = useState<any>(null);
|
||||
|
||||
// ════════ 카테고리 로드 ════════
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const opts: Record<string, { code: string; label: string }[]> = {};
|
||||
const flatten = (vals: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
for (const v of vals) {
|
||||
result.push({ code: v.valueCode || v.value || v.code, label: v.valueLabel || v.label || v.value });
|
||||
if (v.children?.length) result.push(...flatten(v.children));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
for (const col of ["status", "work_team"]) {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${WI_TABLE}/${col}/values`);
|
||||
if (res.data?.success && res.data.data?.length > 0) opts[col] = flatten(res.data.data);
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
setCatOptions(opts);
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const resolveCategory = (col: string, code: string) => {
|
||||
if (!code) return code;
|
||||
return catOptions[col]?.find((o) => o.code === code)?.label || code;
|
||||
};
|
||||
|
||||
// ════════ 데이터 로드 ════════
|
||||
|
||||
const fetchWiList = useCallback(async () => {
|
||||
setWiLoading(true);
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
for (const f of searchFilters) {
|
||||
if (f.value) {
|
||||
if (f.columnName === "progress_status") params.progressStatus = f.value;
|
||||
else if (f.columnName === "status") params.status = f.value;
|
||||
else params.keyword = f.value;
|
||||
}
|
||||
}
|
||||
const res = await apiClient.get("/work-instruction/list", { params });
|
||||
const raw: any[] = res.data?.data || [];
|
||||
|
||||
// work_instruction_no 기준 중복 제거 (detail JOIN으로 여러 행 반환)
|
||||
const seen = new Set<string>();
|
||||
const deduped = raw.filter((r) => {
|
||||
const key = r.work_instruction_no;
|
||||
if (!key || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
// 진행률 계산
|
||||
const enriched = deduped.map((r) => {
|
||||
const qty = Number(r.total_qty || r.qty) || 0;
|
||||
const completed = Number(r.completed_qty) || 0;
|
||||
return {
|
||||
...r,
|
||||
_qty: qty,
|
||||
_completed: completed,
|
||||
_rate: qty > 0 ? Math.round((completed / qty) * 100) : 0,
|
||||
};
|
||||
});
|
||||
setWiList(enriched);
|
||||
} catch {
|
||||
toast.error("작업지시 목록 조회 실패");
|
||||
} finally {
|
||||
setWiLoading(false);
|
||||
}
|
||||
}, [searchFilters]);
|
||||
|
||||
useEffect(() => { fetchWiList(); }, [fetchWiList]);
|
||||
|
||||
// 실적 로드
|
||||
useEffect(() => {
|
||||
if (!selectedWiId) { setProcessData([]); return; }
|
||||
const load = async () => {
|
||||
setProcessLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${WOP_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
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 || []);
|
||||
} catch { setProcessData([]); }
|
||||
finally { setProcessLoading(false); }
|
||||
};
|
||||
load();
|
||||
}, [selectedWiId]);
|
||||
|
||||
// ════════ 계산 ════════
|
||||
|
||||
const selectedWi = wiList.find((w) => (w.wi_id || w.id) === selectedWiId);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const instructionQty = selectedWi?._qty || 0;
|
||||
const goodQty = processData.reduce((s, r) => s + (Number(r.good_qty) || 0), 0);
|
||||
const defectQty = processData.reduce((s, r) => s + (Number(r.defect_qty) || 0), 0);
|
||||
const rate = instructionQty > 0 ? Math.round((goodQty / instructionQty) * 100) : 0;
|
||||
return { instructionQty, goodQty, defectQty, rate };
|
||||
}, [selectedWi, processData]);
|
||||
|
||||
const defectRows = useMemo(() => processData.filter((r) => Number(r.defect_qty) > 0), [processData]);
|
||||
|
||||
const totals = useMemo(() => ({
|
||||
input_qty: processData.reduce((s, r) => s + (Number(r.input_qty) || 0), 0),
|
||||
good_qty: processData.reduce((s, r) => s + (Number(r.good_qty) || 0), 0),
|
||||
defect_qty: processData.reduce((s, r) => s + (Number(r.defect_qty) || 0), 0),
|
||||
}), [processData]);
|
||||
|
||||
// ════════ 그룹핑 ════════
|
||||
|
||||
const groupedData = useMemo(() => {
|
||||
if (groupBy === "none") return wiList;
|
||||
const groups = new Map<string, any[]>();
|
||||
for (const wi of wiList) {
|
||||
let key = wi[groupBy] || "미지정";
|
||||
if (groupBy === "progress_status") key = PROGRESS_LABEL[key] || key;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(wi);
|
||||
}
|
||||
const result: any[] = [];
|
||||
groups.forEach((items, gk) => {
|
||||
result.push({ _group: true, _key: gk, _count: items.length });
|
||||
result.push(...items);
|
||||
});
|
||||
// 기본 전개
|
||||
if (expandedGroups.size === 0) setExpandedGroups(new Set(Array.from(groups.keys())));
|
||||
return result;
|
||||
}, [wiList, groupBy]);
|
||||
|
||||
const toggleGroup = (key: string) => {
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key); else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// ════════ 엑셀 ════════
|
||||
|
||||
const handleExcel = async () => {
|
||||
if (!selectedWi || processData.length === 0) {
|
||||
toast.error("다운로드할 데이터가 없습니다.");
|
||||
return;
|
||||
}
|
||||
const data = processData.map((r, i) => ({
|
||||
No: i + 1,
|
||||
공정: r.process_name || "-",
|
||||
설비: r.equipment_code || "-",
|
||||
생산수량: Number(r.input_qty) || 0,
|
||||
양품수량: Number(r.good_qty) || 0,
|
||||
불량수량: Number(r.defect_qty) || 0,
|
||||
"불량률(%)": Number(r.input_qty) > 0 ? ((Number(r.defect_qty) / Number(r.input_qty)) * 100).toFixed(1) : "0.0",
|
||||
시작: r.started_at || "",
|
||||
완료: r.completed_at || "",
|
||||
비고: r.result_note || r.remark || "",
|
||||
}));
|
||||
await exportToExcel(data, `생산실적_${selectedWi.work_instruction_no || ""}.xlsx`, "실적내역");
|
||||
toast.success("다운로드 완료");
|
||||
};
|
||||
|
||||
// defect_detail JSON 파싱
|
||||
const parseDefectDetail = (raw: any) => {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch { return []; }
|
||||
};
|
||||
|
||||
// ════════ 렌더 ════════
|
||||
|
||||
const wiKey = (r: any) => r.wi_id || r.id;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 p-3">
|
||||
{/* 검색 */}
|
||||
<DynamicSearchFilter
|
||||
tableName={WI_TABLE}
|
||||
filterId="c16-production-result"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={wiList.length}
|
||||
/>
|
||||
|
||||
{/* 메인 */}
|
||||
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card">
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
{/* ────── 좌측: 작업지시 목록 ────── */}
|
||||
<ResizablePanel defaultSize={45} minSize={25}>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold">작업지시 목록</span>
|
||||
<Badge variant="secondary" className="font-mono text-xs">{wiList.length}건</Badge>
|
||||
</div>
|
||||
<Select value={groupBy} onValueChange={(v) => { setGroupBy(v); setExpandedGroups(new Set()); }}>
|
||||
<SelectTrigger className="h-8 w-[120px] text-xs">
|
||||
<SelectValue placeholder="그룹없음" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">그룹없음</SelectItem>
|
||||
<SelectItem value="progress_status">진행상태별</SelectItem>
|
||||
<SelectItem value="equipment_name">설비별</SelectItem>
|
||||
<SelectItem value="work_team">작업조별</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{wiLoading ? (
|
||||
<div className="flex items-center justify-center h-full"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
|
||||
) : wiList.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||
<Inbox className="w-10 h-10 opacity-30" />
|
||||
<span className="text-sm">작업지시가 없어요</span>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[130px] text-[11px]">작업지시번호</TableHead>
|
||||
<TableHead className="w-[60px] text-center text-[11px]">상태</TableHead>
|
||||
<TableHead className="w-[60px] text-center text-[11px]">진행</TableHead>
|
||||
<TableHead className="w-[140px] text-[11px]">품목명</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px]">지시수량</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px]">진행률</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px]">설비</TableHead>
|
||||
<TableHead className="w-[60px] text-center text-[11px]">작업조</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px]">시작일</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px]">완료일</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groupedData.map((row, idx) => {
|
||||
if (row._group) {
|
||||
const expanded = expandedGroups.has(row._key);
|
||||
return (
|
||||
<TableRow key={`g-${row._key}`} className="bg-muted/60 cursor-pointer hover:bg-muted" onClick={() => toggleGroup(row._key)}>
|
||||
<TableCell colSpan={10} className="py-2 px-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
{expanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
{row._key}
|
||||
<Badge variant="outline" className="text-[10px]">{row._count}건</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
// 그룹 접힘 체크
|
||||
if (groupBy !== "none") {
|
||||
let gk = row[groupBy] || "미지정";
|
||||
if (groupBy === "progress_status") gk = PROGRESS_LABEL[gk] || gk;
|
||||
if (!expandedGroups.has(gk)) return null;
|
||||
}
|
||||
const id = wiKey(row);
|
||||
const selected = id === selectedWiId;
|
||||
const barColor = row._rate >= 100 ? "bg-emerald-500" : row._rate >= 50 ? "bg-primary" : "bg-amber-500";
|
||||
const statusLabel = resolveCategory("status", row.status) || row.status || "-";
|
||||
const progressLabel = PROGRESS_LABEL[row.progress_status] || row.progress_status || "-";
|
||||
return (
|
||||
<TableRow
|
||||
key={id || idx}
|
||||
className={cn("cursor-pointer", selected ? "bg-primary/5 border-l-[3px] border-l-primary" : "hover:bg-accent/50")}
|
||||
onClick={() => setSelectedWiId(id)}
|
||||
>
|
||||
<TableCell className="text-[13px] font-mono text-primary font-semibold">{row.work_instruction_no}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full", STATUS_CLS[statusLabel] || "bg-muted text-foreground")}>{statusLabel}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full", PROGRESS_CLS[row.progress_status] || "bg-muted text-foreground")}>{progressLabel}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[13px] truncate max-w-[140px]">{row.item_name || "-"}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono">{fmtNum(row._qty)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className={cn("h-full rounded-full", barColor)} style={{ width: `${Math.min(row._rate, 100)}%` }} />
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground w-7 text-right">{row._rate}%</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground truncate">{row.equipment_name || "-"}</TableCell>
|
||||
<TableCell className="text-[12px] text-center">{resolveCategory("work_team", row.work_team) || "-"}</TableCell>
|
||||
<TableCell className="text-[12px]">{fmtDate(row.start_date)}</TableCell>
|
||||
<TableCell className="text-[12px]">{fmtDate(row.end_date)}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* ────── 우측: 실적 데이터 ────── */}
|
||||
<ResizablePanel defaultSize={55} minSize={30}>
|
||||
{!selectedWiId ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
|
||||
<Package className="w-12 h-12 opacity-30" />
|
||||
<span className="text-sm">좌측에서 작업지시를 선택해주세요</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="w-4 h-4 text-primary" />
|
||||
<span className="text-sm font-semibold">생산실적(PC)</span>
|
||||
{selectedWi && <span className="text-xs text-muted-foreground">{selectedWi.item_name} / {selectedWi.work_instruction_no}</span>}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleExcel} disabled={processData.length === 0}>
|
||||
<Download className="w-3.5 h-3.5 mr-1" /> 엑셀
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 요약 카드 */}
|
||||
<div className="grid grid-cols-4 gap-3 px-4 py-3 border-b bg-muted/20 shrink-0">
|
||||
{[
|
||||
{ label: "지시수량", value: fmtNum(summary.instructionQty), icon: Package, color: "text-primary" },
|
||||
{ label: "양품수량", value: fmtNum(summary.goodQty), icon: CheckCircle2, color: "text-emerald-600" },
|
||||
{ label: "불량수량", value: fmtNum(summary.defectQty), icon: AlertTriangle, color: "text-destructive" },
|
||||
{ label: "달성률", value: `${summary.rate}%`, icon: BarChart3, color: "text-violet-600" },
|
||||
].map((card) => (
|
||||
<div key={card.label} className="bg-card border rounded-lg px-3 py-2.5 text-center">
|
||||
<div className="text-[10px] font-semibold text-muted-foreground mb-1">{card.label}</div>
|
||||
<div className={cn("text-lg font-bold", card.color)}>{card.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 탭 */}
|
||||
<div className="flex border-b shrink-0">
|
||||
{(["result", "defect"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
className={cn(
|
||||
"px-4 py-2.5 text-xs font-semibold border-b-2 transition-colors",
|
||||
rightTab === tab ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setRightTab(tab)}
|
||||
>
|
||||
{tab === "result" ? "📊 실적내역" : "⚠️ 불량내역"}
|
||||
{tab === "defect" && defectRows.length > 0 && (
|
||||
<Badge variant="destructive" className="ml-1.5 text-[9px] px-1.5 py-0">{defectRows.length}</Badge>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 탭 콘텐츠 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{processLoading ? (
|
||||
<div className="flex items-center justify-center h-full"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>
|
||||
) : processData.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||
<Inbox className="w-8 h-8 opacity-30" />
|
||||
<span className="text-sm">등록된 실적이 없습니다</span>
|
||||
<span className="text-xs">POP에서 실적을 등록해주세요</span>
|
||||
</div>
|
||||
) : rightTab === "result" ? (
|
||||
/* ── 실적내역 ── */
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center text-[11px]">No</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px]">공정</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px]">설비</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px]">생산수량</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px]">양품수량</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px]">불량수량</TableHead>
|
||||
<TableHead className="w-[60px] text-center text-[11px]">불량률</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px]">작업시간</TableHead>
|
||||
<TableHead className="w-[60px] text-[11px]">상태</TableHead>
|
||||
<TableHead className="text-[11px]">비고</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{processData.map((r, i) => {
|
||||
const inputQty = Number(r.input_qty) || 0;
|
||||
const defectQty = Number(r.defect_qty) || 0;
|
||||
const defRate = inputQty > 0 ? ((defectQty / inputQty) * 100).toFixed(1) : "0.0";
|
||||
return (
|
||||
<TableRow key={r.id || i} className="cursor-pointer hover:bg-accent/50" onClick={() => { setDetailRow(r); setDetailOpen(true); }}>
|
||||
<TableCell className="text-center text-[13px]">{i + 1}</TableCell>
|
||||
<TableCell className="text-[13px]">
|
||||
<span className="px-2 py-0.5 rounded bg-amber-50 text-amber-700 text-[11px] font-medium">{r.process_name || "-"}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground">{r.equipment_code || "-"}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono font-semibold">{fmtNum(r.input_qty)}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono text-emerald-600 font-semibold">{fmtNum(r.good_qty)}</TableCell>
|
||||
<TableCell className={cn("text-[13px] text-right font-mono font-semibold", defectQty > 0 ? "text-destructive" : "text-muted-foreground")}>{fmtNum(r.defect_qty)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full", parseFloat(defRate) > 2 ? "bg-red-100 text-red-700" : "bg-emerald-100 text-emerald-700")}>{defRate}%</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px]">{fmtDateTime(r.started_at)} ~ {fmtDateTime(r.completed_at)}</TableCell>
|
||||
<TableCell className="text-[12px]">
|
||||
<span className={cn("text-[10px] px-2 py-0.5 rounded-full font-medium", r.status === "completed" ? "bg-emerald-100 text-emerald-700" : r.status === "in_progress" ? "bg-blue-100 text-blue-700" : "bg-muted text-muted-foreground")}>{r.status || "-"}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground truncate max-w-[120px]">{r.result_note || r.remark || "-"}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{/* 합계 */}
|
||||
<TableRow className="bg-muted/60 font-bold border-t-2">
|
||||
<TableCell colSpan={3} className="text-center text-[13px]">합계</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px]">{fmtNum(totals.input_qty)}</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px] text-emerald-600">{fmtNum(totals.good_qty)}</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px] text-destructive">{fmtNum(totals.defect_qty)}</TableCell>
|
||||
<TableCell className="text-center text-[12px]">
|
||||
{totals.input_qty > 0 ? ((totals.defect_qty / totals.input_qty) * 100).toFixed(1) : "0.0"}%
|
||||
</TableCell>
|
||||
<TableCell colSpan={3} />
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
/* ── 불량내역 ── */
|
||||
defectRows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||
<CheckCircle2 className="w-8 h-8 opacity-30" />
|
||||
<span className="text-sm">불량 내역이 없습니다</span>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center text-[11px]">No</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px]">공정</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px]">설비</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px]">불량수량</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px]">불량유형</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px]">불량원인</TableHead>
|
||||
<TableHead className="text-[11px]">비고</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{defectRows.map((r, i) => {
|
||||
const details = parseDefectDetail(r.defect_detail);
|
||||
const defType = details.map((d: any) => d.defect_name || d.defect_code).filter(Boolean).join(", ") || "-";
|
||||
return (
|
||||
<TableRow key={r.id || i} className="cursor-pointer hover:bg-accent/50" onClick={() => { setDetailRow(r); setDetailOpen(true); }}>
|
||||
<TableCell className="text-center text-[13px]">{i + 1}</TableCell>
|
||||
<TableCell className="text-[13px]">
|
||||
<span className="px-2 py-0.5 rounded bg-amber-50 text-amber-700 text-[11px] font-medium">{r.process_name || "-"}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground">{r.equipment_code || "-"}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono font-bold text-destructive">{fmtNum(r.defect_qty)}</TableCell>
|
||||
<TableCell className="text-[12px]">
|
||||
<span className="px-2 py-0.5 rounded bg-amber-50 text-amber-700 text-[11px]">{defType}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground">{r.result_note || "-"}</TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground truncate">{r.remark || "-"}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
{/* ── 상세 모달 ── */}
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent className="max-w-[600px] max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2"><BarChart3 className="w-5 h-5" /> 실적 상세</DialogTitle>
|
||||
<DialogDescription>생산 실적의 상세 정보입니다</DialogDescription>
|
||||
</DialogHeader>
|
||||
{detailRow && (
|
||||
<div className="space-y-4 pt-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 pb-1.5 border-b">기본 정보</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label className="text-xs">공정</Label><Input value={detailRow.process_name || "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
<div><Label className="text-xs">설비</Label><Input value={detailRow.equipment_code || "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
<div><Label className="text-xs">순번</Label><Input value={detailRow.seq_no || "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
<div><Label className="text-xs">상태</Label><Input value={detailRow.status || "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 pb-1.5 border-b">수량 정보</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div><Label className="text-xs">생산수량</Label><Input value={fmtNum(detailRow.input_qty)} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
<div><Label className="text-xs">양품수량</Label><Input value={fmtNum(detailRow.good_qty)} readOnly className="h-8 text-xs bg-muted/50 text-emerald-600 font-semibold" /></div>
|
||||
<div><Label className="text-xs">불량수량</Label><Input value={fmtNum(detailRow.defect_qty)} readOnly className={cn("h-8 text-xs bg-muted/50 font-semibold", Number(detailRow.defect_qty) > 0 ? "text-destructive" : "")} /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 pb-1.5 border-b">작업 시간</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label className="text-xs">시작</Label><Input value={detailRow.started_at ? String(detailRow.started_at).replace("T", " ") : "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
<div><Label className="text-xs">완료</Label><Input value={detailRow.completed_at ? String(detailRow.completed_at).replace("T", " ") : "-"} readOnly className="h-8 text-xs bg-muted/50" /></div>
|
||||
</div>
|
||||
</div>
|
||||
{Number(detailRow.defect_qty) > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 pb-1.5 border-b">불량 정보</p>
|
||||
{parseDefectDetail(detailRow.defect_detail).length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{parseDefectDetail(detailRow.defect_detail).map((d: any, i: number) => (
|
||||
<div key={i} className="flex items-center gap-3 text-xs bg-red-50 px-3 py-1.5 rounded">
|
||||
<span className="font-medium">{d.defect_name || d.defect_code || "-"}</span>
|
||||
<span className="text-muted-foreground">수량: {d.qty || 0}</span>
|
||||
{d.disposition && <span className="text-muted-foreground">처리: {d.disposition}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">상세 불량 데이터 없음</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(detailRow.result_note || detailRow.remark) && (
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 pb-1.5 border-b">비고</p>
|
||||
<p className="text-xs bg-muted/50 p-2 rounded">{detailRow.result_note || detailRow.remark}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -105,6 +105,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
"/COMPANY_7/sales/claim": dynamic(() => import("@/app/(main)/COMPANY_7/sales/claim/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/sales/quote": dynamic(() => import("@/app/(main)/COMPANY_7/sales/quote/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/production/process-info": dynamic(() => import("@/app/(main)/COMPANY_7/production/process-info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/production/result": dynamic(() => import("@/app/(main)/COMPANY_7/production/result/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_7/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/production/plan-management": dynamic(() => import("@/app/(main)/COMPANY_7/production/plan-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/production/bom": dynamic(() => import("@/app/(main)/COMPANY_7/production/bom/page"), { ssr: false, loading: LoadingFallback }),
|
||||
@@ -137,6 +138,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
"/COMPANY_16/sales/claim": dynamic(() => import("@/app/(main)/COMPANY_16/sales/claim/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/sales/quote": dynamic(() => import("@/app/(main)/COMPANY_16/sales/quote/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/production/process-info": dynamic(() => import("@/app/(main)/COMPANY_16/production/process-info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/production/result": dynamic(() => import("@/app/(main)/COMPANY_16/production/result/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_16/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/production/plan-management": dynamic(() => import("@/app/(main)/COMPANY_16/production/plan-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/production/bom": dynamic(() => import("@/app/(main)/COMPANY_16/production/bom/page"), { ssr: false, loading: LoadingFallback }),
|
||||
@@ -187,6 +189,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
"/COMPANY_8/sales/claim": dynamic(() => import("@/app/(main)/COMPANY_8/sales/claim/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_8/sales/quote": dynamic(() => import("@/app/(main)/COMPANY_8/sales/quote/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_8/production/process-info": dynamic(() => import("@/app/(main)/COMPANY_8/production/process-info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_8/production/result": dynamic(() => import("@/app/(main)/COMPANY_8/production/result/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_8/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_8/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_8/production/plan-management": dynamic(() => import("@/app/(main)/COMPANY_8/production/plan-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_8/production/bom": dynamic(() => import("@/app/(main)/COMPANY_8/production/bom/page"), { ssr: false, loading: LoadingFallback }),
|
||||
@@ -228,6 +231,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
"/COMPANY_10/sales/claim": dynamic(() => import("@/app/(main)/COMPANY_10/sales/claim/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_10/sales/quote": dynamic(() => import("@/app/(main)/COMPANY_10/sales/quote/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_10/production/process-info": dynamic(() => import("@/app/(main)/COMPANY_10/production/process-info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_10/production/result": dynamic(() => import("@/app/(main)/COMPANY_10/production/result/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_10/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_10/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_10/production/plan-management": dynamic(() => import("@/app/(main)/COMPANY_10/production/plan-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_10/production/bom": dynamic(() => import("@/app/(main)/COMPANY_10/production/bom/page"), { ssr: false, loading: LoadingFallback }),
|
||||
@@ -269,6 +273,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
"/COMPANY_29/sales/claim": dynamic(() => import("@/app/(main)/COMPANY_29/sales/claim/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/sales/quote": dynamic(() => import("@/app/(main)/COMPANY_29/sales/quote/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/production/process-info": dynamic(() => import("@/app/(main)/COMPANY_29/production/process-info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/production/result": dynamic(() => import("@/app/(main)/COMPANY_29/production/result/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_29/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/production/plan-management": dynamic(() => import("@/app/(main)/COMPANY_29/production/plan-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/production/bom": dynamic(() => import("@/app/(main)/COMPANY_29/production/bom/page"), { ssr: false, loading: LoadingFallback }),
|
||||
@@ -310,6 +315,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
"/COMPANY_9/sales/claim": dynamic(() => import("@/app/(main)/COMPANY_9/sales/claim/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_9/sales/quote": dynamic(() => import("@/app/(main)/COMPANY_9/sales/quote/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_9/production/process-info": dynamic(() => import("@/app/(main)/COMPANY_9/production/process-info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_9/production/result": dynamic(() => import("@/app/(main)/COMPANY_9/production/result/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_9/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_9/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_9/production/plan-management": dynamic(() => import("@/app/(main)/COMPANY_9/production/plan-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_9/production/bom": dynamic(() => import("@/app/(main)/COMPANY_9/production/bom/page"), { ssr: false, loading: LoadingFallback }),
|
||||
@@ -352,6 +358,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
"/COMPANY_30/sales/claim": dynamic(() => import("@/app/(main)/COMPANY_30/sales/claim/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_30/sales/quote": dynamic(() => import("@/app/(main)/COMPANY_30/sales/quote/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_30/production/process-info": dynamic(() => import("@/app/(main)/COMPANY_30/production/process-info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_30/production/result": dynamic(() => import("@/app/(main)/COMPANY_30/production/result/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_30/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_30/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_30/production/plan-management": dynamic(() => import("@/app/(main)/COMPANY_30/production/plan-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_30/production/bom": dynamic(() => import("@/app/(main)/COMPANY_30/production/bom/page"), { ssr: false, loading: LoadingFallback }),
|
||||
@@ -471,6 +478,7 @@ const DYNAMIC_ADMIN_IMPORTS: Record<string, () => Promise<any>> = {
|
||||
"/COMPANY_7/sales/shipping-plan": () => import("@/app/(main)/COMPANY_7/sales/shipping-plan/page"),
|
||||
"/COMPANY_7/sales/claim": () => import("@/app/(main)/COMPANY_7/sales/claim/page"),
|
||||
"/COMPANY_7/production/process-info": () => import("@/app/(main)/COMPANY_7/production/process-info/page"),
|
||||
"/COMPANY_7/production/result": () => import("@/app/(main)/COMPANY_7/production/result/page"),
|
||||
"/COMPANY_7/production/work-instruction": () => import("@/app/(main)/COMPANY_7/production/work-instruction/page"),
|
||||
"/COMPANY_7/production/plan-management": () => import("@/app/(main)/COMPANY_7/production/plan-management/page"),
|
||||
"/COMPANY_7/production/bom": () => import("@/app/(main)/COMPANY_7/production/bom/page"),
|
||||
@@ -500,6 +508,7 @@ const DYNAMIC_ADMIN_IMPORTS: Record<string, () => Promise<any>> = {
|
||||
"/COMPANY_10/sales/shipping-plan": () => import("@/app/(main)/COMPANY_10/sales/shipping-plan/page"),
|
||||
"/COMPANY_10/sales/claim": () => import("@/app/(main)/COMPANY_10/sales/claim/page"),
|
||||
"/COMPANY_10/production/process-info": () => import("@/app/(main)/COMPANY_10/production/process-info/page"),
|
||||
"/COMPANY_10/production/result": () => import("@/app/(main)/COMPANY_10/production/result/page"),
|
||||
"/COMPANY_10/production/work-instruction": () => import("@/app/(main)/COMPANY_10/production/work-instruction/page"),
|
||||
"/COMPANY_10/production/plan-management": () => import("@/app/(main)/COMPANY_10/production/plan-management/page"),
|
||||
"/COMPANY_10/equipment/info": () => import("@/app/(main)/COMPANY_10/equipment/info/page"),
|
||||
@@ -527,6 +536,7 @@ const DYNAMIC_ADMIN_IMPORTS: Record<string, () => Promise<any>> = {
|
||||
"/COMPANY_9/sales/claim": () => import("@/app/(main)/COMPANY_9/sales/claim/page"),
|
||||
"/COMPANY_9/sales/quote": () => import("@/app/(main)/COMPANY_9/sales/quote/page"),
|
||||
"/COMPANY_9/production/process-info": () => import("@/app/(main)/COMPANY_9/production/process-info/page"),
|
||||
"/COMPANY_9/production/result": () => import("@/app/(main)/COMPANY_9/production/result/page"),
|
||||
"/COMPANY_9/production/work-instruction": () => import("@/app/(main)/COMPANY_9/production/work-instruction/page"),
|
||||
"/COMPANY_9/production/plan-management": () => import("@/app/(main)/COMPANY_9/production/plan-management/page"),
|
||||
"/COMPANY_9/production/bom": () => import("@/app/(main)/COMPANY_9/production/bom/page"),
|
||||
@@ -568,6 +578,7 @@ const DYNAMIC_ADMIN_IMPORTS: Record<string, () => Promise<any>> = {
|
||||
"/COMPANY_30/sales/claim": () => import("@/app/(main)/COMPANY_30/sales/claim/page"),
|
||||
"/COMPANY_30/sales/quote": () => import("@/app/(main)/COMPANY_30/sales/quote/page"),
|
||||
"/COMPANY_30/production/process-info": () => import("@/app/(main)/COMPANY_30/production/process-info/page"),
|
||||
"/COMPANY_30/production/result": () => import("@/app/(main)/COMPANY_30/production/result/page"),
|
||||
"/COMPANY_30/production/work-instruction": () => import("@/app/(main)/COMPANY_30/production/work-instruction/page"),
|
||||
"/COMPANY_30/production/plan-management": () => import("@/app/(main)/COMPANY_30/production/plan-management/page"),
|
||||
"/COMPANY_30/production/bom": () => import("@/app/(main)/COMPANY_30/production/bom/page"),
|
||||
@@ -606,6 +617,7 @@ const DYNAMIC_ADMIN_IMPORTS: Record<string, () => Promise<any>> = {
|
||||
"/COMPANY_29/sales/shipping-plan": () => import("@/app/(main)/COMPANY_29/sales/shipping-plan/page"),
|
||||
"/COMPANY_29/sales/claim": () => import("@/app/(main)/COMPANY_29/sales/claim/page"),
|
||||
"/COMPANY_29/production/process-info": () => import("@/app/(main)/COMPANY_29/production/process-info/page"),
|
||||
"/COMPANY_29/production/result": () => import("@/app/(main)/COMPANY_29/production/result/page"),
|
||||
"/COMPANY_29/production/work-instruction": () => import("@/app/(main)/COMPANY_29/production/work-instruction/page"),
|
||||
"/COMPANY_29/production/plan-management": () => import("@/app/(main)/COMPANY_29/production/plan-management/page"),
|
||||
"/COMPANY_29/equipment/info": () => import("@/app/(main)/COMPANY_29/equipment/info/page"),
|
||||
|
||||
Reference in New Issue
Block a user