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:
kjs
2026-04-09 13:51:07 +09:00
parent a9d2df48bf
commit 735cba2936
8 changed files with 4485 additions and 0 deletions
@@ -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"),