fix: work_order_process + process_work_result id NOT NULL 수정

feat: MES 공정실행 구버전 기반 재구현 (타임라인+체크리스트+실적)

- popProductionController: INSERT 3곳 gen_random_uuid() 추가
- WorkOrderList: 공정 타임라인, 탭 카운트, 그룹핑
- ProcessWork: 사이드바+체크리스트+실적+생산입고 (PopWorkDetail 참고)
- 공정 3건 + 체크리스트 3건 생성 성공 (cmux+psql 검증)
This commit is contained in:
SeongHyun Kim
2026-04-02 12:04:37 +09:00
parent 86926e18af
commit 70b555b5bd
3 changed files with 1169 additions and 561 deletions

View File

@@ -29,7 +29,7 @@ async function copyChecklistToSplit(
if (routingDetailId) {
const result = await client.query(
`INSERT INTO process_work_result (
company_code, work_order_process_id,
id, company_code, work_order_process_id,
source_work_item_id, source_detail_id,
work_phase, item_title, item_sort_order,
detail_content, detail_type, detail_sort_order, is_required,
@@ -38,7 +38,7 @@ async function copyChecklistToSplit(
status, writer
)
SELECT
pwi.company_code, $1,
gen_random_uuid()::text, pwi.company_code, $1,
pwi.id, pwd.id,
pwi.work_phase, pwi.title, pwi.sort_order::text,
pwd.content, pwd.detail_type, pwd.sort_order::text, pwd.is_required,
@@ -140,10 +140,10 @@ async function generateWorkProcessesForInstruction(
// 2. work_order_process INSERT
const wopResult = await client.query(
`INSERT INTO work_order_process (
company_code, wo_id, seq_no, process_code, process_name,
id, company_code, wo_id, seq_no, process_code, process_name,
is_required, is_fixed_order, standard_time, plan_qty,
status, routing_detail_id, writer
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id`,
[
companyCode,
@@ -920,13 +920,13 @@ export const saveResult = async (
const masterId = proc.parent_process_id || work_order_process_id;
const reworkInsert = await pool.query(
`INSERT INTO work_order_process (
wo_id, seq_no, process_code, process_name, is_required, is_fixed_order,
id, wo_id, seq_no, process_code, process_name, is_required, is_fixed_order,
standard_time, equipment_code, routing_detail_id,
status, input_qty, good_qty, defect_qty, concession_qty, total_production_qty,
result_status, is_rework, rework_source_id,
parent_process_id, company_code, writer
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9,
gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9,
'acceptable', $10, '0', '0', '0', '0',
'draft', 'Y', $11,
$12, $13, $14
@@ -1642,13 +1642,13 @@ export const acceptProcess = async (req: AuthenticatedRequest, res: Response) =>
// 분할 행 INSERT (원본 행에서 공정 정보 복사)
const result = await pool.query(
`INSERT INTO work_order_process (
wo_id, seq_no, process_code, process_name, is_required, is_fixed_order,
id, wo_id, seq_no, process_code, process_name, is_required, is_fixed_order,
standard_time, equipment_code, routing_detail_id,
status, input_qty, good_qty, defect_qty, total_production_qty,
result_status, accepted_by, accepted_at, started_at,
parent_process_id, company_code, writer
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9,
gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9,
'in_progress', $10, '0', '0', '0',
'draft', $11, NOW()::text, NOW()::text,
$12, $13, $11

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { useRouter } from "next/navigation";
import { apiClient } from "@/lib/api/client";
import { dataApi } from "@/lib/api/data";
import { AcceptProcessModal } from "./AcceptProcessModal";
/* ------------------------------------------------------------------ */
@@ -38,39 +39,100 @@ interface WorkOrderProcess {
input_qty: string;
good_qty: string;
defect_qty: string;
concession_qty: string;
total_production_qty: string;
parent_process_id: string | null;
is_rework: string;
result_status: string;
started_at: string | null;
completed_at: string | null;
}
interface ProcessMaster {
id: string;
process_code: string;
process_name: string;
}
type TabFilter = "all" | "waiting" | "acceptable" | "in_progress" | "completed";
type TabFilter = "all" | "acceptable" | "in_progress" | "waiting" | "completed";
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
const TABS: { key: TabFilter; label: string }[] = [
{ key: "all", label: "전체" },
{ key: "acceptable", label: "접수가능" },
{ key: "in_progress", label: "진행중" },
{ key: "waiting", label: "대기" },
{ key: "completed", label: "완료" },
const TABS: { key: TabFilter; label: string; color: string; bgActive: string }[] = [
{ key: "all", label: "전체", color: "text-gray-700", bgActive: "bg-white" },
{ key: "acceptable", label: "접수가능", color: "text-amber-700", bgActive: "bg-amber-50" },
{ key: "in_progress", label: "진행중", color: "text-blue-700", bgActive: "bg-blue-50" },
{ key: "waiting", label: "대기", color: "text-gray-500", bgActive: "bg-gray-50" },
{ key: "completed", label: "완료", color: "text-green-700", bgActive: "bg-green-50" },
];
const STATUS_BADGE: Record<string, { bg: string; text: string; label: string }> = {
acceptable: { bg: "bg-amber-100", text: "text-amber-700", label: "접수가능" },
waiting: { bg: "bg-gray-100", text: "text-gray-500", label: "대기" },
in_progress: { bg: "bg-blue-100", text: "text-blue-700", label: "진행중" },
completed: { bg: "bg-green-100", text: "text-green-700", label: "완료" },
const STATUS_BADGE: Record<string, { bg: string; text: string; label: string; dot: string }> = {
acceptable: { bg: "bg-amber-100", text: "text-amber-700", label: "접수가능", dot: "bg-amber-500" },
waiting: { bg: "bg-gray-100", text: "text-gray-500", label: "대기", dot: "bg-gray-400" },
in_progress: { bg: "bg-blue-100", text: "text-blue-700", label: "진행중", dot: "bg-blue-500" },
completed: { bg: "bg-green-100", text: "text-green-700", label: "완료", dot: "bg-green-500" },
};
/* ------------------------------------------------------------------ */
/* Timeline Step Component */
/* ------------------------------------------------------------------ */
function ProcessTimeline({
processes,
currentSeqNo,
}: {
processes: WorkOrderProcess[];
currentSeqNo: string | null;
}) {
const sorted = [...processes]
.filter((p) => !p.parent_process_id)
.sort((a, b) => parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10));
if (sorted.length === 0) return null;
return (
<div className="flex items-center gap-0.5 overflow-x-auto no-scrollbar py-1">
{sorted.map((proc, idx) => {
const isCurrent = proc.seq_no === currentSeqNo;
const isCompleted = proc.status === "completed";
const isActive = proc.status === "in_progress" || proc.status === "acceptable";
return (
<React.Fragment key={proc.id}>
{idx > 0 && (
<div
className={`h-0.5 w-3 shrink-0 rounded-full transition-colors ${
isCompleted || (sorted[idx - 1]?.status === "completed")
? "bg-green-400"
: "bg-gray-200"
}`}
/>
)}
<div
className={`flex items-center gap-1 shrink-0 px-2 py-1 rounded-full text-[10px] font-semibold transition-all ${
isCurrent
? "bg-blue-100 text-blue-700 ring-2 ring-blue-300 ring-offset-1"
: isCompleted
? "bg-green-100 text-green-700"
: isActive
? "bg-amber-50 text-amber-700"
: "bg-gray-50 text-gray-400"
}`}
>
{isCompleted ? (
<svg className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth={3} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
) : isCurrent || isActive ? (
<span className={`w-2 h-2 rounded-full ${isActive ? "bg-amber-500 animate-pulse" : "bg-blue-500 animate-pulse"}`} />
) : (
<span className="w-2 h-2 rounded-full bg-gray-300" />
)}
<span className="whitespace-nowrap">{proc.process_name}</span>
</div>
</React.Fragment>
);
})}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
@@ -80,12 +142,10 @@ export function WorkOrderList() {
/* ---- State ---- */
const [instructions, setInstructions] = useState<WorkInstruction[]>([]);
const [processes, setProcesses] = useState<Record<string, WorkOrderProcess[]>>({});
const [processMasters, setProcessMasters] = useState<ProcessMaster[]>([]);
const [allProcesses, setAllProcesses] = useState<WorkOrderProcess[]>([]);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [activeTab, setActiveTab] = useState<TabFilter>("all");
const [processFilter, setProcessFilter] = useState<string>("all");
/* Accept Modal */
const [acceptModal, setAcceptModal] = useState<{
@@ -98,77 +158,17 @@ export function WorkOrderList() {
const [acceptLoading, setAcceptLoading] = useState(false);
/* ---- Data Fetching ---- */
const fetchData = useCallback(async () => {
setLoading(true);
try {
// 1. Fetch work instructions (PC API)
const wiRes = await apiClient.get("/work-instruction/list");
const wiData: WorkInstruction[] = wiRes.data?.data || wiRes.data || [];
setInstructions(wiData);
// 2. Fetch process masters for filter
try {
const pmRes = await apiClient.get("/pop/production/defect-types");
// process_mng is not directly exposed; use defect-types won't work.
// We'll extract unique process names from work_order_process data instead
} catch {
// Not critical
}
// 3. For each instruction with routing, fetch work_order_process
const processMap: Record<string, WorkOrderProcess[]> = {};
const uniqueProcesses = new Map<string, ProcessMaster>();
for (const wi of wiData) {
if (!wi.routing) continue;
try {
const procRes = await apiClient.get("/pop/production/available-qty", {
params: { work_order_process_id: wi.id },
});
// available-qty is per process, not per instruction
// We need a different approach - query the processes directly
} catch {
// silent
}
}
// Alternative: fetch all processes for each instruction via a raw query approach
// Since there's no direct "list processes by instruction" API, we'll sync first
// then list from the sync result
// For now, refetch after sync
setProcesses(processMap);
} catch (error) {
console.error("[WorkOrderList] fetch error:", error);
} finally {
setLoading(false);
}
}, []);
const syncAndFetch = useCallback(async () => {
setSyncing(true);
try {
// Sync any unsynced instructions to create work_order_process
await apiClient.post("/pop/production/sync-work-instructions");
} catch (error) {
console.error("[WorkOrderList] sync error:", error);
} finally {
setSyncing(false);
}
await fetchData();
}, [fetchData]);
/* We fetch instruction list then processes per instruction */
const fetchAll = useCallback(async () => {
setLoading(true);
try {
// 1. Sync first
// 1. Sync unsynced work instructions
try {
await apiClient.post("/pop/production/sync-work-instructions");
} catch {
// Non-fatal
}
// 2. Fetch work instructions (PC API)
// 2. Fetch work instructions
const wiRes = await apiClient.get("/work-instruction/list");
let wiData: WorkInstruction[] = [];
if (wiRes.data?.data) {
@@ -178,40 +178,17 @@ export function WorkOrderList() {
}
setInstructions(wiData);
// 3. For instructions with routing, get their processes
// We don't have a direct "list processes by instruction" endpoint,
// so we'll build our own approach with a select-based query
// Actually there is no such endpoint. Let's create a synthetic query
// via available-qty for each master process.
// Better: we can use the raw instruction data to check if processes exist
// For the prototype, let's query via a direct approach
// We'll create a helper that gets processes
const processMap: Record<string, WorkOrderProcess[]> = {};
const uniqueProcesses = new Map<string, string>();
// Since we don't have a "list all processes" API,
// let's use a workaround via the work-instruction detail
// This is actually queried by the frontend for production screens
// For now we'll use a simple approach with the APIs we have
setProcesses(processMap);
// Extract unique process masters from whatever data we have
const masters: ProcessMaster[] = [];
for (const [, procs] of Object.entries(processMap)) {
for (const p of procs) {
if (!uniqueProcesses.has(p.process_code)) {
uniqueProcesses.set(p.process_code, p.process_name);
masters.push({
id: p.process_code,
process_code: p.process_code,
process_name: p.process_name,
});
}
}
// 3. Fetch all work_order_process for these instructions
const wiIds = wiData.filter((wi) => wi.routing).map((wi) => wi.id);
if (wiIds.length > 0) {
// Fetch all processes in one go (paginated large)
const procRes = await dataApi.getTableData("work_order_process", {
size: 1000,
});
setAllProcesses((procRes.data ?? []) as WorkOrderProcess[]);
} else {
setAllProcesses([]);
}
setProcessMasters(masters);
} catch (error) {
console.error("[WorkOrderList] fetchAll error:", error);
} finally {
@@ -223,6 +200,95 @@ export function WorkOrderList() {
fetchAll();
}, [fetchAll]);
const syncAndFetch = useCallback(async () => {
setSyncing(true);
try {
await apiClient.post("/pop/production/sync-work-instructions");
} catch {
// Non-fatal
} finally {
setSyncing(false);
}
await fetchAll();
}, [fetchAll]);
/* ---- Group processes by work instruction ---- */
const processesByWo = useMemo(() => {
const map: Record<string, WorkOrderProcess[]> = {};
for (const proc of allProcesses) {
if (!proc.wo_id) continue;
if (!map[proc.wo_id]) map[proc.wo_id] = [];
map[proc.wo_id].push(proc);
}
return map;
}, [allProcesses]);
/* ---- Determine current process (first non-completed master) ---- */
const getCurrentProcess = useCallback(
(wiId: string): WorkOrderProcess | null => {
const procs = processesByWo[wiId] || [];
const masters = procs
.filter((p) => !p.parent_process_id)
.sort((a, b) => parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10));
return masters.find((m) => m.status !== "completed") || masters[masters.length - 1] || null;
},
[processesByWo]
);
/* ---- Filter instructions by tab ---- */
const filteredInstructions = useMemo(() => {
return instructions.filter((wi) => {
const procs = processesByWo[wi.id] || [];
const masters = procs.filter((p) => !p.parent_process_id);
if (activeTab === "all") return true;
// Determine the instruction's effective status from its processes
if (masters.length === 0) {
// No processes -> waiting
return activeTab === "waiting";
}
const hasCompleted = masters.every((m) => m.status === "completed");
const hasInProgress = masters.some((m) => m.status === "in_progress");
const hasAcceptable = masters.some((m) => m.status === "acceptable");
if (activeTab === "completed") return hasCompleted;
if (activeTab === "in_progress") return hasInProgress;
if (activeTab === "acceptable") return hasAcceptable;
if (activeTab === "waiting") return !hasCompleted && !hasInProgress && !hasAcceptable;
return true;
});
}, [instructions, activeTab, processesByWo]);
/* ---- Tab counts ---- */
const tabCounts = useMemo(() => {
const counts: Record<TabFilter, number> = {
all: instructions.length,
acceptable: 0,
in_progress: 0,
waiting: 0,
completed: 0,
};
for (const wi of instructions) {
const procs = processesByWo[wi.id] || [];
const masters = procs.filter((p) => !p.parent_process_id);
if (masters.length === 0) {
counts.waiting++;
continue;
}
const allCompleted = masters.every((m) => m.status === "completed");
if (allCompleted) { counts.completed++; continue; }
const hasInProgress = masters.some((m) => m.status === "in_progress");
if (hasInProgress) { counts.in_progress++; continue; }
const hasAcceptable = masters.some((m) => m.status === "acceptable");
if (hasAcceptable) { counts.acceptable++; continue; }
counts.waiting++;
}
return counts;
}, [instructions, processesByWo]);
/* ---- Accept handler ---- */
const openAcceptModal = async (processId: string, processName: string, seqNo: string) => {
try {
@@ -232,8 +298,7 @@ export function WorkOrderList() {
const data = res.data?.data;
const maxQty = data?.availableQty ?? 0;
setAcceptModal({ open: true, processId, processName, seqNo, maxQty });
} catch (error) {
console.error("[WorkOrderList] available-qty error:", error);
} catch {
alert("접수가능량 조회 실패");
}
};
@@ -248,7 +313,6 @@ export function WorkOrderList() {
if (res.data?.success) {
const splitId = res.data.data?.id;
setAcceptModal((m) => ({ ...m, open: false }));
// Navigate to work page with the new split card
if (splitId) {
router.push(`/pop/production/work/${splitId}`);
} else {
@@ -264,7 +328,7 @@ export function WorkOrderList() {
}
};
/* ---- Create processes for an instruction ---- */
/* ---- Create processes ---- */
const handleCreateProcesses = async (wi: WorkInstruction) => {
if (!wi.routing) {
alert("라우팅이 설정되지 않은 작업지시입니다.\nPC에서 라우팅을 먼저 설정해주세요.");
@@ -292,14 +356,6 @@ export function WorkOrderList() {
}
};
/* ---- Computed: filter instructions ---- */
const displayInstructions = useMemo(() => {
return instructions.filter((wi) => {
if (wi.progress_status === "completed" && activeTab !== "all" && activeTab !== "completed") return false;
return true;
});
}, [instructions, activeTab]);
/* ---- Navigate to work ---- */
const goToWork = (processId: string) => {
router.push(`/pop/production/work/${processId}`);
@@ -308,41 +364,8 @@ export function WorkOrderList() {
/* ---- Render ---- */
return (
<div className="flex flex-col gap-4">
{/* Filters row */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
{/* Process filter */}
{processMasters.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-gray-500 shrink-0">:</span>
<div className="flex gap-1.5 overflow-x-auto no-scrollbar">
<button
onClick={() => setProcessFilter("all")}
className={`px-3 py-1.5 rounded-full text-xs font-semibold whitespace-nowrap transition-all active:scale-95 ${
processFilter === "all"
? "bg-gray-900 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
</button>
{processMasters.map((pm) => (
<button
key={pm.process_code}
onClick={() => setProcessFilter(pm.process_code)}
className={`px-3 py-1.5 rounded-full text-xs font-semibold whitespace-nowrap transition-all active:scale-95 ${
processFilter === pm.process_code
? "bg-gray-900 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
{pm.process_name}
</button>
))}
</div>
</div>
)}
{/* Sync button */}
{/* Sync button row */}
<div className="flex items-center justify-end">
<button
onClick={syncAndFetch}
disabled={syncing}
@@ -354,19 +377,32 @@ export function WorkOrderList() {
{/* Tab bar */}
<div className="flex gap-1.5 overflow-x-auto no-scrollbar bg-gray-100 p-1 rounded-xl">
{TABS.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex-1 min-w-[60px] py-2.5 rounded-lg text-xs font-semibold transition-all active:scale-95 ${
activeTab === tab.key
? "bg-white text-gray-900 shadow-sm"
: "text-gray-500 hover:text-gray-700"
}`}
>
{tab.label}
</button>
))}
{TABS.map((tab) => {
const count = tabCounts[tab.key];
const isActive = activeTab === tab.key;
return (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex-1 min-w-[60px] py-2.5 rounded-lg text-xs font-semibold transition-all active:scale-95 flex items-center justify-center gap-1.5 ${
isActive
? `${tab.bgActive} ${tab.color} shadow-sm`
: "text-gray-500 hover:text-gray-700"
}`}
>
{tab.label}
{count > 0 && (
<span
className={`text-[10px] min-w-[18px] h-[18px] flex items-center justify-center rounded-full px-1 ${
isActive ? "bg-black/10" : "bg-gray-200/60"
}`}
>
{count}
</span>
)}
</button>
);
})}
</div>
{/* Loading */}
@@ -379,33 +415,31 @@ export function WorkOrderList() {
</div>
)}
{/* Instructions list */}
{!loading && displayInstructions.length === 0 && (
{/* Empty state */}
{!loading && filteredInstructions.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<div className="w-16 h-16 rounded-full bg-gray-100 flex items-center justify-center">
<svg className="w-8 h-8 text-gray-300" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15a2.25 2.25 0 012.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V19.5a2.25 2.25 0 002.25 2.25h.75" />
</svg>
</div>
<p className="text-sm text-gray-400"> </p>
<p className="text-sm text-gray-400">
{activeTab === "all" ? "등록된 작업지시가 없습니다" : `${TABS.find((t) => t.key === activeTab)?.label || ""} 상태의 작업지시가 없습니다`}
</p>
</div>
)}
{/* Instruction cards */}
{!loading &&
displayInstructions.map((wi) => {
const wiProcesses = processes[wi.id] || [];
const masterProcesses = wiProcesses.filter((p) => !p.parent_process_id);
filteredInstructions.map((wi) => {
const wiProcesses = processesByWo[wi.id] || [];
const masterProcesses = wiProcesses
.filter((p) => !p.parent_process_id)
.sort((a, b) => parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10));
const splitProcesses = wiProcesses.filter((p) => !!p.parent_process_id);
const hasProcesses = masterProcesses.length > 0;
const progressPct = wi.qty > 0 ? Math.min(100, Math.round(((wi.completed_qty || 0) / wi.qty) * 100)) : 0;
// Determine overall instruction status for tab filtering
let instrStatus: TabFilter = "waiting";
if (wi.progress_status === "completed") instrStatus = "completed";
else if (masterProcesses.some((p) => p.status === "in_progress")) instrStatus = "in_progress";
else if (masterProcesses.some((p) => p.status === "acceptable")) instrStatus = "acceptable";
if (activeTab !== "all" && instrStatus !== activeTab) return null;
const currentProc = getCurrentProcess(wi.id);
return (
<div
@@ -422,13 +456,15 @@ export function WorkOrderList() {
</p>
</div>
<div className="flex items-center gap-2 shrink-0 ml-3">
<span className={`text-xs font-semibold px-2.5 py-1 rounded-full ${
wi.progress_status === "completed"
? "bg-green-100 text-green-700"
: wi.progress_status === "진행중"
? "bg-blue-100 text-blue-700"
: "bg-gray-100 text-gray-500"
}`}>
<span
className={`text-xs font-semibold px-2.5 py-1 rounded-full ${
wi.progress_status === "completed"
? "bg-green-100 text-green-700"
: wi.progress_status === "진행중"
? "bg-blue-100 text-blue-700"
: "bg-gray-100 text-gray-500"
}`}
>
{wi.progress_status || wi.status || "대기"}
</span>
</div>
@@ -456,13 +492,25 @@ export function WorkOrderList() {
className="h-full rounded-full transition-all duration-500"
style={{
width: `${progressPct}%`,
background: progressPct >= 100
? "linear-gradient(90deg, #22c55e, #16a34a)"
: "linear-gradient(90deg, #3b82f6, #1d4ed8)",
background:
progressPct >= 100
? "linear-gradient(90deg, #22c55e, #16a34a)"
: "linear-gradient(90deg, #3b82f6, #1d4ed8)",
}}
/>
</div>
{/* Process Timeline */}
{hasProcesses && (
<div className="mb-3">
<p className="text-[10px] font-semibold text-gray-400 mb-1.5 uppercase tracking-wider"> </p>
<ProcessTimeline
processes={wiProcesses}
currentSeqNo={currentProc?.seq_no || null}
/>
</div>
)}
{/* Process cards */}
{hasProcesses ? (
<div className="flex flex-col gap-2">
@@ -473,11 +521,17 @@ export function WorkOrderList() {
({masterProcesses.length})
</p>
<div className="flex gap-2 overflow-x-auto no-scrollbar pb-1">
{masterProcesses.map((proc, idx) => {
{masterProcesses.map((proc) => {
const badge = STATUS_BADGE[proc.status] || STATUS_BADGE.waiting;
const mySplits = splitProcesses.filter(
(s) => s.parent_process_id === proc.id
);
const mySplits = splitProcesses
.filter((s) => s.parent_process_id === proc.id)
.sort((a, b) => {
// Most recent first
const ta = a.started_at ? new Date(a.started_at).getTime() : 0;
const tb = b.started_at ? new Date(b.started_at).getTime() : 0;
return tb - ta;
});
return (
<div
key={proc.id}
@@ -504,6 +558,8 @@ export function WorkOrderList() {
<div className="flex flex-col gap-1 mb-2">
{mySplits.slice(0, 2).map((split) => {
const sBadge = STATUS_BADGE[split.status] || STATUS_BADGE.waiting;
const splitGood = parseInt(split.good_qty || "0", 10);
const splitInput = parseInt(split.input_qty || "0", 10);
return (
<button
key={split.id}
@@ -511,7 +567,10 @@ export function WorkOrderList() {
className="flex items-center justify-between px-2 py-1.5 rounded-lg bg-white border border-gray-200 hover:border-blue-300 active:scale-95 transition-all"
>
<span className="text-[10px] text-gray-500">
{parseInt(split.input_qty || "0", 10)}EA
{splitInput}EA
{splitGood > 0 && (
<span className="text-green-600 ml-1">({splitGood})</span>
)}
</span>
<span className={`text-[10px] font-semibold ${sBadge.text}`}>
{sBadge.label}
@@ -543,7 +602,7 @@ export function WorkOrderList() {
)}
{proc.status === "in_progress" && mySplits.length > 0 && (
<button
onClick={() => goToWork(mySplits[mySplits.length - 1].id)}
onClick={() => goToWork(mySplits[0].id)}
className="w-full h-9 rounded-lg text-xs font-bold text-white active:scale-95 transition-all"
style={{
background: "linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)",