feat: POP 기능 병합 (pop-screen → main, PC 무변경 46건)

- POP 생산: 재고 관리, 재작업 이력, BOM 자재투입 기능 추가
- POP 설정: 설정 시스템 + 관리 페이지 (/pop/admin)
- POP 화면: 버그 수정 + 설정 연동 + 다음공정 활성화 수정
- PC 코드 무변경 (보류 6건: app.ts, 출고/입고/작업지시 컨트롤러, 레이아웃)
This commit is contained in:
SeongHyun Kim
2026-04-05 17:45:33 +09:00
parent 9b7b88ff7c
commit a04ddd15ec
16 changed files with 4355 additions and 952 deletions
File diff suppressed because it is too large Load Diff
@@ -18,6 +18,10 @@ import {
updateTargetWarehouse,
inventoryInbound,
quickInventoryInbound,
getReworkHistory,
getBomMaterials,
saveMaterialInput,
getMaterialInputs,
} from "../controllers/popProductionController";
const router = Router();
@@ -41,5 +45,9 @@ router.get("/is-last-process/:processId", isLastProcess);
router.post("/update-target-warehouse", updateTargetWarehouse);
router.post("/inventory-inbound", inventoryInbound);
router.post("/quick-inventory-inbound", quickInventoryInbound);
router.get("/rework-history/:woId", getReworkHistory);
router.get("/bom-materials/:processId", getBomMaterials);
router.post("/material-input", saveMaterialInput);
router.get("/material-inputs/:processId", getMaterialInputs);
export default router;
File diff suppressed because it is too large Load Diff
+259
View File
@@ -0,0 +1,259 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiClient } from "@/lib/api/client";
import { PopShell } from "@/components/pop/hardcoded";
interface PopSettings {
version: string;
screens: {
processExecution: {
materialInput: boolean;
photoUpload: boolean;
plcEnabled: boolean;
bomFlexible: boolean;
packagingOptions: string[];
defectTypes: string[];
reworkTargetSelection: boolean;
groupPhotoEnabled: boolean;
};
inbound: {
inspectionRequired: boolean;
photoUpload: boolean;
barcodeEnabled: boolean;
};
outbound: {
photoUpload: boolean;
barcodeEnabled: boolean;
};
home: {
kpiCarousel: boolean;
recentActivity: boolean;
};
};
}
const DEFAULT_SETTINGS: PopSettings = {
version: "hardcoded-1.0",
screens: {
processExecution: {
materialInput: true,
photoUpload: true,
plcEnabled: false,
bomFlexible: true,
packagingOptions: ["낱개", "박스", "파렛트"],
defectTypes: ["스크래치", "치수불량", "변색", "크랙", "기포"],
reworkTargetSelection: true,
groupPhotoEnabled: false,
},
inbound: {
inspectionRequired: false,
photoUpload: false,
barcodeEnabled: true,
},
outbound: {
photoUpload: false,
barcodeEnabled: true,
},
home: {
kpiCarousel: true,
recentActivity: true,
},
},
};
function Toggle({ label, description, value, onChange }: {
label: string; description?: string; value: boolean; onChange: (v: boolean) => void;
}) {
return (
<div className="flex items-center justify-between py-3 border-b border-gray-100 last:border-0">
<div>
<p className="text-sm font-semibold text-gray-900">{label}</p>
{description && <p className="text-xs text-gray-400 mt-0.5">{description}</p>}
</div>
<button
onClick={() => onChange(!value)}
className={`w-12 h-7 rounded-full transition-all ${value ? "bg-blue-500" : "bg-gray-200"}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow-sm transition-transform mx-1 ${value ? "translate-x-5" : ""}`} />
</button>
</div>
);
}
function TagEditor({ label, tags, onChange }: {
label: string; tags: string[]; onChange: (tags: string[]) => void;
}) {
const [input, setInput] = useState("");
return (
<div className="py-3 border-b border-gray-100">
<p className="text-sm font-semibold text-gray-900 mb-2">{label}</p>
<div className="flex flex-wrap gap-1.5 mb-2">
{tags.map((tag) => (
<span key={tag} className="flex items-center gap-1 px-2.5 py-1 bg-blue-50 text-blue-700 text-xs font-medium rounded-full">
{tag}
<button onClick={() => onChange(tags.filter((t) => t !== tag))} className="text-blue-400 hover:text-blue-600">×</button>
</span>
))}
</div>
<div className="flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && input.trim()) {
onChange([...tags, input.trim()]);
setInput("");
}
}}
placeholder="추가 후 Enter"
className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm"
/>
</div>
</div>
);
}
export default function PopAdminSettingsPage() {
const { user } = useAuth();
const [settings, setSettings] = useState<PopSettings>(DEFAULT_SETTINGS);
const [saving, setSaving] = useState(false);
const [activeTab, setActiveTab] = useState<"process" | "inbound" | "outbound" | "home">("process");
const fetchSettings = useCallback(async () => {
try {
const res = await apiClient.get("/data/pop_settings?pageSize=1");
const rows = res.data?.data?.data || res.data?.data || [];
if (rows.length > 0 && rows[0].settings_data) {
const parsed = typeof rows[0].settings_data === "string"
? JSON.parse(rows[0].settings_data)
: rows[0].settings_data;
setSettings({ ...DEFAULT_SETTINGS, ...parsed });
}
} catch {
// 테이블 없으면 기본값 사용
}
}, []);
useEffect(() => { fetchSettings(); }, [fetchSettings]);
const handleSave = async () => {
setSaving(true);
try {
// pop_settings 테이블에 저장 (없으면 generic data API 사용)
await apiClient.post("/pop/execute-action", {
taskType: "data-save",
targetTable: "pop_settings",
columnMapping: {
id: crypto.randomUUID(),
company_code: user?.companyCode || "COMPANY_7",
settings_data: JSON.stringify(settings),
updated_by: user?.userId,
},
});
alert("설정이 저장되었습니다");
} catch {
// 테이블이 없을 수 있으므로 localStorage fallback
localStorage.setItem("pop_settings", JSON.stringify(settings));
alert("설정이 로컬에 저장되었습니다 (DB 테이블 생성 후 동기화 필요)");
}
setSaving(false);
};
const pe = settings.screens.processExecution;
const ib = settings.screens.inbound;
const ob = settings.screens.outbound;
const hm = settings.screens.home;
const updatePE = (key: string, value: unknown) => {
setSettings((prev) => ({
...prev,
screens: { ...prev.screens, processExecution: { ...prev.screens.processExecution, [key]: value } },
}));
};
const tabs = [
{ key: "process" as const, label: "공정실행" },
{ key: "inbound" as const, label: "입고" },
{ key: "outbound" as const, label: "출고" },
{ key: "home" as const, label: "홈" },
];
return (
<PopShell title="POP 설정 관리" showBanner={false} showBack>
<div className="max-w-2xl mx-auto p-4">
{/* 탭 */}
<div className="flex gap-1 mb-4 bg-gray-100 rounded-xl p-1">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex-1 py-2.5 rounded-lg text-sm font-semibold transition-all ${
activeTab === tab.key ? "bg-white text-gray-900 shadow-sm" : "text-gray-500"
}`}
>
{tab.label}
</button>
))}
</div>
{/* 공정실행 설정 */}
{activeTab === "process" && (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4">
<h3 className="text-base font-bold text-gray-900 mb-3"> </h3>
<Toggle label="자재 투입" description="BOM 기반 자재 투입 탭 표시" value={pe.materialInput} onChange={(v) => updatePE("materialInput", v)} />
<Toggle label="BOM 유동 투입" description="기준과 다른 수량 투입 허용" value={pe.bomFlexible} onChange={(v) => updatePE("bomFlexible", v)} />
<Toggle label="사진 첨부" description="실적 입력 시 사진 첨부 허용" value={pe.photoUpload} onChange={(v) => updatePE("photoUpload", v)} />
<Toggle label="그룹별 사진" description="체크리스트 그룹마다 사진 첨부" value={pe.groupPhotoEnabled} onChange={(v) => updatePE("groupPhotoEnabled", v)} />
<Toggle label="PLC 연동" description="설비 PLC 데이터 자동 연동" value={pe.plcEnabled} onChange={(v) => updatePE("plcEnabled", v)} />
<Toggle label="재작업 공정 지정" description="불량 처리 시 특정 공정 선택 가능" value={pe.reworkTargetSelection} onChange={(v) => updatePE("reworkTargetSelection", v)} />
<TagEditor label="포장 옵션" tags={pe.packagingOptions} onChange={(v) => updatePE("packagingOptions", v)} />
<TagEditor label="불량 유형" tags={pe.defectTypes} onChange={(v) => updatePE("defectTypes", v)} />
</div>
)}
{/* 입고 설정 */}
{activeTab === "inbound" && (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4">
<h3 className="text-base font-bold text-gray-900 mb-3"> </h3>
<Toggle label="검사 필수" description="입고 시 검사 항목 필수 체크" value={ib.inspectionRequired} onChange={(v) => setSettings((p) => ({ ...p, screens: { ...p.screens, inbound: { ...p.screens.inbound, inspectionRequired: v } } }))} />
<Toggle label="사진 첨부" description="입고 확정 시 사진 첨부" value={ib.photoUpload} onChange={(v) => setSettings((p) => ({ ...p, screens: { ...p.screens, inbound: { ...p.screens.inbound, photoUpload: v } } }))} />
<Toggle label="바코드 스캔" description="바코드/QR 스캔 기능" value={ib.barcodeEnabled} onChange={(v) => setSettings((p) => ({ ...p, screens: { ...p.screens, inbound: { ...p.screens.inbound, barcodeEnabled: v } } }))} />
</div>
)}
{/* 출고 설정 */}
{activeTab === "outbound" && (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4">
<h3 className="text-base font-bold text-gray-900 mb-3"> </h3>
<Toggle label="사진 첨부" value={ob.photoUpload} onChange={(v) => setSettings((p) => ({ ...p, screens: { ...p.screens, outbound: { ...p.screens.outbound, photoUpload: v } } }))} />
<Toggle label="바코드 스캔" value={ob.barcodeEnabled} onChange={(v) => setSettings((p) => ({ ...p, screens: { ...p.screens, outbound: { ...p.screens.outbound, barcodeEnabled: v } } }))} />
</div>
)}
{/* 홈 설정 */}
{activeTab === "home" && (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4">
<h3 className="text-base font-bold text-gray-900 mb-3"> </h3>
<Toggle label="KPI 캐러셀" description="오늘의 현황 캐러셀 표시" value={hm.kpiCarousel} onChange={(v) => setSettings((p) => ({ ...p, screens: { ...p.screens, home: { ...p.screens.home, kpiCarousel: v } } }))} />
<Toggle label="최근 활동" description="최근 입출고 활동 표시" value={hm.recentActivity} onChange={(v) => setSettings((p) => ({ ...p, screens: { ...p.screens, home: { ...p.screens.home, recentActivity: v } } }))} />
</div>
)}
{/* 저장 버튼 */}
<button
onClick={handleSave}
disabled={saving}
className="w-full mt-4 py-4 rounded-xl text-base font-bold text-white bg-blue-500 active:scale-[0.98] transition-all disabled:opacity-40"
>
{saving ? "저장중..." : "설정 저장"}
</button>
<p className="text-xs text-gray-400 text-center mt-3">
({user?.companyCode}) . .
</p>
</div>
</PopShell>
);
}
@@ -2,6 +2,7 @@
import React, { useState, useRef, useCallback, useEffect } from "react";
import { apiClient } from "@/lib/api/client";
import { usePopSettings } from "@/hooks/pop/usePopSettings";
/* ------------------------------------------------------------------ */
/* KPI Data Types */
@@ -32,6 +33,7 @@ interface ProdQualityKpi {
export function KpiCarousel() {
const { settings: popSettings } = usePopSettings();
const [current, setCurrent] = useState(0);
const trackRef = useRef<HTMLDivElement>(null);
const autoTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -209,6 +211,9 @@ export function KpiCarousel() {
startAuto();
};
/* Settings: hide entire KPI carousel if disabled */
if (!popSettings.screens.home.kpiCarousel) return null;
/* Computed slide values */
const inboundPercent = inOut.inboundTotal > 0
? Math.round((inOut.inboundCompleted / inOut.inboundTotal) * 100)
+13 -11
View File
@@ -2,6 +2,7 @@
import React, { useState, useEffect, useRef, ReactNode } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
interface PopShellProps {
children: ReactNode;
@@ -13,6 +14,10 @@ interface PopShellProps {
export function PopShell({ children, showBanner = true, title, showBack = false, headerRight }: PopShellProps) {
const router = useRouter();
const { user, logout } = useAuth();
const displayName = user?.userName || user?.userId || "사용자";
const deptName = user?.deptName || "";
const initial = displayName.charAt(0);
const [mounted, setMounted] = useState(false);
const [hours, setHours] = useState("00");
const [minutes, setMinutes] = useState("00");
@@ -90,10 +95,7 @@ export function PopShell({ children, showBanner = true, title, showBack = false,
const handleLogout = () => {
setProfileOpen(false);
localStorage.removeItem("token");
localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken");
window.location.href = "/login";
logout();
};
const marqueeText =
@@ -148,7 +150,7 @@ export function PopShell({ children, showBanner = true, title, showBack = false,
) : (
<>
<span className="text-white text-lg font-bold tracking-tight leading-tight truncate">
{user?.companyName || "POP"}
</span>
<span className="text-white/50 text-xs font-medium leading-tight">
@@ -224,14 +226,14 @@ export function PopShell({ children, showBanner = true, title, showBack = false,
className="flex items-center gap-2.5 cursor-pointer"
>
<div className="hidden sm:flex flex-col items-end">
<span className="text-sm text-white/90 font-semibold leading-tight"></span>
<span className="text-xs text-white/40 font-medium leading-tight">1</span>
<span className="text-sm text-white/90 font-semibold leading-tight">{displayName}</span>
<span className="text-xs text-white/40 font-medium leading-tight">{deptName}</span>
</div>
<div
className="w-10 h-10 rounded-full bg-blue-500 flex items-center justify-center text-sm font-bold text-white shrink-0 transition-transform active:scale-95"
style={{ boxShadow: "0 2px 8px rgba(59,130,246,.35)" }}
>
{initial}
</div>
</button>
@@ -245,8 +247,8 @@ export function PopShell({ children, showBanner = true, title, showBack = false,
>
{/* User Info */}
<div className="px-4 py-3 border-b border-gray-100">
<p className="text-sm font-semibold text-gray-900"></p>
<p className="text-xs text-gray-400 mt-0.5">1</p>
<p className="text-sm font-semibold text-gray-900">{displayName}</p>
<p className="text-xs text-gray-400 mt-0.5">{deptName || user?.userId}</p>
</div>
{/* Menu Items */}
@@ -319,7 +321,7 @@ export function PopShell({ children, showBanner = true, title, showBack = false,
{/* ===== FOOTER ===== */}
<footer className="border-t border-gray-200 bg-white px-4 sm:px-6 lg:px-8 py-3 sm:py-4">
<div className="max-w-[1400px] mx-auto flex flex-col sm:flex-row items-center justify-between gap-2 text-xs text-gray-400">
<span>&copy; 2026 . All rights reserved.</span>
<span>&copy; {new Date().getFullYear()} {user?.companyName || "VEXPLOR"}. All rights reserved.</span>
<div className="flex items-center gap-3 sm:gap-4">
<span>Version 1.0.0</span>
<span className="hidden sm:inline">|</span>
@@ -2,6 +2,7 @@
import React, { useState, useEffect } from "react";
import { apiClient } from "@/lib/api/client";
import { usePopSettings } from "@/hooks/pop/usePopSettings";
/* ------------------------------------------------------------------ */
/* Types */
@@ -52,6 +53,7 @@ function formatTime(dateStr: string): string {
/* ------------------------------------------------------------------ */
export function RecentActivity() {
const { settings: popSettings } = usePopSettings();
const [activities, setActivities] = useState<ActivityItem[]>([]);
const [loading, setLoading] = useState(true);
@@ -118,6 +120,9 @@ export function RecentActivity() {
fetchActivity();
}, []);
/* Settings: hide recent activity if disabled */
if (!popSettings.screens.home.recentActivity) return null;
return (
<section>
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 sm:p-6">
@@ -7,6 +7,7 @@ import { SupplierModal, type Supplier, matchChosung } from "./SupplierModal";
import { NumberPadModal, type PackageEntry } from "./NumberPadModal";
import { BarcodeScanModal } from "../common/BarcodeScanModal";
import type { CartItemWithId } from "../common/useCartSync";
import { usePopSettings } from "@/hooks/pop/usePopSettings";
/* ------------------------------------------------------------------ */
/* Types */
@@ -98,6 +99,8 @@ interface PurchaseInboundProps {
export function PurchaseInbound({ cart }: PurchaseInboundProps) {
const router = useRouter();
const { settings: popSettings } = usePopSettings();
const inboundSettings = popSettings.screens.inbound;
/* State */
const [selectedSupplier, setSelectedSupplier] = useState<Supplier | null>(null);
@@ -331,6 +334,7 @@ export function PurchaseInbound({ cart }: PurchaseInboundProps) {
{selectedSupplier ? selectedSupplier.customer_name : "거래처를 선택하세요"}
</button>
{/* QR/Barcode scan button - glossy v3 */}
{inboundSettings.barcodeEnabled && (
<button
onClick={() => setSupplierScanOpen(true)}
className="min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0"
@@ -344,6 +348,7 @@ export function PurchaseInbound({ cart }: PurchaseInboundProps) {
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
</svg>
</button>
)}
{selectedSupplier && (
<button
onClick={() => { setSelectedSupplier(null); setSupplierSearchText(""); }}
@@ -382,6 +387,7 @@ export function PurchaseInbound({ cart }: PurchaseInboundProps) {
}`}
/>
{/* QR/Barcode scan button - glossy v3 */}
{inboundSettings.barcodeEnabled && (
<button
onClick={() => setItemScanOpen(true)}
disabled={!selectedSupplier}
@@ -398,6 +404,7 @@ export function PurchaseInbound({ cart }: PurchaseInboundProps) {
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
</svg>
</button>
)}
</div>
</div>
</div>
@@ -462,12 +469,12 @@ export function PurchaseInbound({ cart }: PurchaseInboundProps) {
<div className="flex items-center gap-1.5 mb-2.5 pb-2 border-b border-gray-100">
<span className="text-[11px] text-gray-400 font-medium shrink-0">{order.item_code}</span>
<span className="text-[13px] font-semibold text-gray-900 flex-1 truncate">{order.item_name}</span>
{order.inspection_type === "self" && (
{inboundSettings.inspectionRequired && order.inspection_type === "self" && (
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 border border-blue-200 shrink-0 whitespace-nowrap">
</span>
)}
{order.inspection_type === "request" && (
{inboundSettings.inspectionRequired && order.inspection_type === "request" && (
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 border border-amber-200 shrink-0 whitespace-nowrap">
</span>
@@ -583,6 +590,20 @@ export function PurchaseInbound({ cart }: PurchaseInboundProps) {
</div>
);
})()}
{/* === Photo upload (shown when in cart and photoUpload enabled) === */}
{inboundSettings.photoUpload && inCart && (
<div className="mt-2">
<label className="flex items-center gap-2 px-3 py-2.5 rounded-xl bg-gray-50 border-2 border-dashed border-gray-200 text-gray-500 text-xs font-medium cursor-pointer hover:bg-gray-100 active:scale-[0.98] transition-all">
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6.827 6.175A2.31 2.31 0 015.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 00-1.134-.175 2.31 2.31 0 01-1.64-1.055l-.822-1.316a2.192 2.192 0 00-1.736-1.039 48.774 48.774 0 00-5.232 0 2.192 2.192 0 00-1.736 1.039l-.821 1.316z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 12.75a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0z" />
</svg>
<input type="file" accept="image/*" capture="environment" className="hidden" />
</label>
</div>
)}
</div>
);
})}
@@ -7,6 +7,7 @@ import { CustomerModal, type Customer, matchChosung } from "./CustomerModal";
import { NumberPadModal, type PackageEntry } from "../inbound/NumberPadModal";
import { BarcodeScanModal } from "../common/BarcodeScanModal";
import type { CartItemWithId } from "../common/useCartSync";
import { usePopSettings } from "@/hooks/pop/usePopSettings";
/* ------------------------------------------------------------------ */
/* Types */
@@ -87,6 +88,8 @@ interface SalesOutboundProps {
export function SalesOutbound({ cart }: SalesOutboundProps) {
const router = useRouter();
const { settings: popSettings } = usePopSettings();
const outboundSettings = popSettings.screens.outbound;
/* State */
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null);
@@ -297,6 +300,7 @@ export function SalesOutbound({ cart }: SalesOutboundProps) {
{selectedCustomer ? selectedCustomer.customer_name : "고객사를 선택하세요"}
</button>
{/* QR/Barcode scan button */}
{outboundSettings.barcodeEnabled && (
<button
onClick={() => setCustomerScanOpen(true)}
className="min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0"
@@ -310,6 +314,7 @@ export function SalesOutbound({ cart }: SalesOutboundProps) {
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
</svg>
</button>
)}
{selectedCustomer && (
<button
onClick={() => setSelectedCustomer(null)}
@@ -346,6 +351,7 @@ export function SalesOutbound({ cart }: SalesOutboundProps) {
}`}
/>
{/* QR/Barcode scan button */}
{outboundSettings.barcodeEnabled && (
<button
onClick={() => setItemScanOpen(true)}
disabled={!selectedCustomer}
@@ -362,6 +368,7 @@ export function SalesOutbound({ cart }: SalesOutboundProps) {
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
</svg>
</button>
)}
</div>
</div>
</div>
@@ -536,6 +543,20 @@ export function SalesOutbound({ cart }: SalesOutboundProps) {
</div>
);
})()}
{/* === Photo upload (shown when in cart and photoUpload enabled) === */}
{outboundSettings.photoUpload && inCart && (
<div className="mt-2">
<label className="flex items-center gap-2 px-3 py-2.5 rounded-xl bg-gray-50 border-2 border-dashed border-gray-200 text-gray-500 text-xs font-medium cursor-pointer hover:bg-gray-100 active:scale-[0.98] transition-all">
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6.827 6.175A2.31 2.31 0 015.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 00-1.134-.175 2.31 2.31 0 01-1.64-1.055l-.822-1.316a2.192 2.192 0 00-1.736-1.039 48.774 48.774 0 00-5.232 0 2.192 2.192 0 00-1.736 1.039l-.821 1.316z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 12.75a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0z" />
</svg>
<input type="file" accept="image/*" capture="environment" className="hidden" />
</label>
</div>
)}
</div>
);
})}
@@ -19,6 +19,7 @@ export interface DefectEntry {
defect_name: string;
qty: string;
disposition: "scrap" | "rework" | "accept";
target_process_code?: string; // 재작업 시 지정 공정 (없으면 모든 공정 표시)
}
interface DefectTypeModalProps {
@@ -28,6 +29,7 @@ interface DefectTypeModalProps {
defectTypes: DefectType[];
maxQty: number;
initialEntries?: DefectEntry[];
processList?: Array<{ process_code: string; process_name: string }>;
}
/* ------------------------------------------------------------------ */
@@ -88,9 +90,10 @@ export function DefectTypeModal({
defectTypes,
maxQty,
initialEntries,
processList,
}: DefectTypeModalProps) {
const [entries, setEntries] = useState<
Array<{ defect_code: string; defect_name: string; qty: number; disposition: DefectEntry["disposition"] }>
Array<{ defect_code: string; defect_name: string; qty: number; disposition: DefectEntry["disposition"]; target_process_code?: string }>
>([]);
useEffect(() => {
@@ -156,6 +159,7 @@ export function DefectTypeModal({
defect_name: e.defect_name,
qty: String(e.qty),
disposition: e.disposition,
target_process_code: e.target_process_code,
}))
);
onClose();
@@ -268,6 +272,33 @@ export function DefectTypeModal({
</button>
))}
</div>
{/* 재작업 시 공정 지정 */}
{entry.disposition === "rework" && processList && processList.length > 0 && (
<div className="mt-2">
<select
value={entry.target_process_code || ""}
onChange={(e) => {
const code = e.target.value;
setEntries((prev) =>
prev.map((en) =>
en.defect_code === entry.defect_code
? { ...en, target_process_code: code || undefined }
: en
)
);
}}
className="w-full p-2.5 rounded-lg border border-gray-200 text-sm bg-white"
>
<option value=""> </option>
{processList.map((p) => (
<option key={p.process_code} value={p.process_code}>
{p.process_name}
</option>
))}
</select>
</div>
)}
</div>
))}
</div>
@@ -1,6 +1,7 @@
"use client";
import React from "react";
import React, { useState, useEffect } from "react";
import { apiClient } from "@/lib/api/client";
/* ------------------------------------------------------------------ */
/* Types */
@@ -18,12 +19,36 @@ export interface ProcessStep {
availableQty: number;
}
interface ReworkChainItem {
id: string;
seq_no: string;
process_code: string;
process_name: string;
status: string;
input_qty: string;
good_qty: string;
defect_qty: string;
concession_qty: string;
is_rework: string;
rework_source_id: string | null;
started_at: string | null;
completed_at: string | null;
}
interface ReworkChain {
source: ReworkChainItem;
reworks: ReworkChainItem[];
totalReworkCount: number;
}
interface ProcessDetailModalProps {
open: boolean;
onClose: () => void;
workInstructionNo: string;
totalQty: number;
steps: ProcessStep[];
woId?: string;
showReworkHistory?: boolean;
}
/* ------------------------------------------------------------------ */
@@ -36,15 +61,50 @@ export function ProcessDetailModal({
workInstructionNo,
totalQty,
steps,
woId,
showReworkHistory,
}: ProcessDetailModalProps) {
const [activeTab, setActiveTab] = useState<"steps" | "rework">("steps");
const [reworkChains, setReworkChains] = useState<ReworkChain[]>([]);
const [reworkLoading, setReworkLoading] = useState(false);
const [totalReworkCount, setTotalReworkCount] = useState(0);
// Fetch rework history when tab switches to rework
useEffect(() => {
if (!open || !woId || activeTab !== "rework") return;
let cancelled = false;
const fetchHistory = async () => {
setReworkLoading(true);
try {
const res = await apiClient.get(`/pop/production/rework-history/${woId}`);
if (!cancelled && res.data?.success) {
setReworkChains(res.data.data.chains || []);
setTotalReworkCount(res.data.data.total_rework_count || 0);
}
} catch {
// Non-fatal
} finally {
if (!cancelled) setReworkLoading(false);
}
};
fetchHistory();
return () => { cancelled = true; };
}, [open, woId, activeTab]);
// Reset tab on close
useEffect(() => {
if (!open) setActiveTab("steps");
}, [open]);
if (!open) return null;
const completedCount = steps.filter((s) => s.status === "completed").length;
const overallPct = steps.length > 0 ? Math.round((completedCount / steps.length) * 100) : 0;
const hasReworkData = showReworkHistory && woId;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: "rgba(0,0,0,0.5)" }}>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4 overflow-hidden">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4 max-h-[90vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between">
<div>
@@ -61,7 +121,36 @@ export function ProcessDetailModal({
</button>
</div>
{/* Tab switcher — only show if rework data available */}
{hasReworkData && (
<div className="flex border-b border-gray-100">
<button
onClick={() => setActiveTab("steps")}
className={`flex-1 py-3 text-sm font-bold transition-all ${
activeTab === "steps"
? "text-blue-600 border-b-2 border-blue-500"
: "text-gray-400 hover:text-gray-600"
}`}
>
</button>
<button
onClick={() => setActiveTab("rework")}
className={`flex-1 py-3 text-sm font-bold transition-all ${
activeTab === "rework"
? "text-orange-600 border-b-2 border-orange-500"
: "text-gray-400 hover:text-gray-600"
}`}
>
</button>
</div>
)}
{/* Scrollable body */}
<div className="flex-1 overflow-y-auto">
{/* Summary bar */}
{activeTab === "steps" && (
<div className="px-5 py-4">
<div className="mb-4 p-4 bg-gray-50 rounded-xl">
<div className="flex items-center justify-between mb-2">
@@ -83,8 +172,10 @@ export function ProcessDetailModal({
</div>
</div>
</div>
)}
{/* Steps */}
{/* Steps — shown when activeTab is "steps" */}
{activeTab === "steps" && (
<div className="px-5 pb-4 max-h-[400px] overflow-y-auto -mt-2">
{steps.map((s) => {
const borderColor =
@@ -214,9 +305,127 @@ export function ProcessDetailModal({
);
})}
</div>
)}
{/* Rework History — shown when activeTab is "rework" */}
{activeTab === "rework" && (
<div className="px-5 py-4 max-h-[450px] overflow-y-auto">
{reworkLoading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin w-8 h-8 border-4 border-orange-200 border-t-orange-500 rounded-full" />
</div>
) : reworkChains.length === 0 ? (
<div className="text-center py-12">
<div className="text-4xl mb-3">&#128269;</div>
<p className="text-sm text-gray-400"> </p>
</div>
) : (
<>
{/* Summary */}
<div className="mb-4 p-3 bg-orange-50 border border-orange-200 rounded-xl">
<div className="flex items-center justify-between">
<span className="text-sm font-bold text-orange-700"> </span>
<span className="text-lg font-extrabold text-orange-600">{totalReworkCount}</span>
</div>
<p className="text-xs text-orange-500 mt-1">{reworkChains.length} </p>
</div>
{/* Chain tree */}
{reworkChains.map((chain, chainIdx) => {
const srcDefect = parseInt(chain.source.defect_qty || "0", 10);
const srcGood = parseInt(chain.source.good_qty || "0", 10);
return (
<div key={chain.source.id} className={`mb-4 ${chainIdx < reworkChains.length - 1 ? "pb-4 border-b border-gray-100" : ""}`}>
{/* Source (origin) node */}
<div className="flex items-start gap-3 mb-2">
<div className="flex flex-col items-center">
<div className="w-9 h-9 rounded-full bg-red-500 text-white flex items-center justify-center text-sm font-bold shrink-0">
{chain.source.seq_no || "?"}
</div>
{chain.reworks.length > 0 && (
<div className="w-0.5 h-4 bg-orange-300" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className="text-sm font-bold text-gray-900 truncate">
{chain.source.process_name || chain.source.process_code}
</span>
<span className="text-xs font-bold px-2 py-0.5 rounded-full bg-red-100 text-red-600 shrink-0">
{srcDefect}
</span>
</div>
<div className="flex gap-3 mt-1 text-xs text-gray-500">
<span> {srcGood}</span>
<span> {srcDefect}</span>
</div>
</div>
</div>
{/* Rework chain nodes */}
{chain.reworks.map((rw, rwIdx) => {
const rwGood = parseInt(rw.good_qty || "0", 10);
const rwDefect = parseInt(rw.defect_qty || "0", 10);
const rwInput = parseInt(rw.input_qty || "0", 10);
const isLast = rwIdx === chain.reworks.length - 1;
const statusColor =
rw.status === "completed" ? "bg-green-500"
: rw.status === "in_progress" ? "bg-blue-500"
: rw.status === "acceptable" ? "bg-amber-500"
: "bg-gray-400";
const statusLabel =
rw.status === "completed" ? "완료"
: rw.status === "in_progress" ? "진행중"
: rw.status === "acceptable" ? "접수가능"
: "대기";
return (
<div key={rw.id} className="flex items-start gap-3 ml-1">
<div className="flex flex-col items-center">
{!isLast && <div className="w-0.5 h-2 bg-orange-200" />}
<div className={`w-7 h-7 rounded-full ${statusColor} text-white flex items-center justify-center text-xs font-bold shrink-0 ring-2 ring-orange-200`}>
R{rwIdx + 1}
</div>
{!isLast && <div className="w-0.5 h-4 bg-orange-200" />}
</div>
<div className="flex-1 min-w-0 pb-2">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-gray-800 truncate">
{rw.process_name || rw.process_code}
</span>
<span className={`text-[10px] font-bold px-2 py-0.5 rounded-full ${
rw.status === "completed" ? "bg-green-100 text-green-700"
: rw.status === "in_progress" ? "bg-blue-100 text-blue-700"
: rw.status === "acceptable" ? "bg-amber-100 text-amber-700"
: "bg-gray-100 text-gray-500"
}`}>
{statusLabel}
</span>
</div>
<div className="flex gap-3 mt-1 text-xs text-gray-500">
<span> {rwInput}</span>
<span> {rwGood}</span>
{rwDefect > 0 && <span className="text-red-500"> {rwDefect}</span>}
</div>
</div>
</div>
);
})}
</div>
);
})}
</>
)}
</div>
)}
</div>{/* end scrollable body */}
{/* Footer */}
<div className="px-5 py-3 border-t border-gray-100">
<div className="shrink-0 px-5 py-3 border-t border-gray-100">
<button
onClick={onClose}
className="w-full py-3 rounded-xl text-sm font-bold text-white bg-blue-500 active:scale-[0.98] transition-all"
@@ -118,125 +118,67 @@ export function ProcessTimer({
completed: "완료",
};
const btnClass = "h-12 rounded-xl text-base font-bold text-white active:scale-95 transition-all disabled:opacity-40 px-5";
return (
<div className={`rounded-2xl border-2 ${colors.border} ${colors.bg} p-5 sm:p-6`}>
{/* Status badge */}
<div className="flex items-center justify-between mb-4">
<span className={`text-xs font-bold px-3 py-1 rounded-full ${colors.bg} ${colors.text} border ${colors.border}`}>
{statusLabels[status]}
</span>
{status === "running" && (
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-blue-500 animate-pulse" />
<span className="text-xs text-blue-500 font-medium"></span>
<div className={`rounded-xl border ${colors.border} ${colors.bg} px-4 py-3`}>
<div className="flex items-center justify-between gap-4">
{/* Left: status + time + start info */}
<div className="flex items-center gap-3 min-w-0">
<span className={`text-xs font-bold px-2.5 py-1 rounded-md ${colors.text} border ${colors.border} shrink-0`}>
{statusLabels[status]}
</span>
)}
</div>
{/* Timer display */}
<div className="text-center mb-5">
<p
className={`text-5xl sm:text-6xl font-black tracking-wider ${colors.text}`}
style={{ fontVariantNumeric: "tabular-nums", fontFamily: "monospace" }}
>
{formatTime(elapsed)}
</p>
{startedAt && (
<p className="text-xs text-gray-400 mt-2">
: {new Date(startedAt).toLocaleTimeString("ko-KR")}
{completedAt && ` | 종료: ${new Date(completedAt).toLocaleTimeString("ko-KR")}`}
</p>
)}
</div>
{/* Buttons */}
<div className="flex gap-3">
{status === "idle" && (
<button
onClick={onStart}
disabled={disabled}
className="flex-1 h-14 rounded-xl text-lg font-bold text-white active:scale-95 transition-all disabled:opacity-40"
style={{ background: "linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)" }}
<span
className={`text-3xl font-black tracking-wider ${colors.text} shrink-0`}
style={{ fontVariantNumeric: "tabular-nums", fontFamily: "monospace" }}
>
<span className="flex items-center justify-center gap-2">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
{formatTime(elapsed)}
</span>
{startedAt && (
<span className="text-xs text-gray-400 shrink-0 hidden sm:inline">
{new Date(startedAt).toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit" })}
</span>
</button>
)}
)}
</div>
{status === "running" && (
<>
<button
onClick={onPause}
disabled={disabled}
className="flex-1 h-14 rounded-xl text-lg font-bold text-white active:scale-95 transition-all disabled:opacity-40"
style={{ background: "linear-gradient(135deg, #f59e0b 0%, #d97706 100%)" }}
>
<span className="flex items-center justify-center gap-2">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
</span>
{/* Right: buttons */}
<div className="flex gap-2 shrink-0">
{status === "idle" && (
<button onClick={onStart} disabled={disabled} className={btnClass}
style={{ background: "linear-gradient(135deg, #3b82f6, #1d4ed8)" }}>
</button>
<button
onClick={onComplete}
disabled={disabled}
className="flex-1 h-14 rounded-xl text-lg font-bold text-white active:scale-95 transition-all disabled:opacity-40"
style={{ background: "linear-gradient(135deg, #10b981 0%, #059669 100%)" }}
>
<span className="flex items-center justify-center gap-2">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 6h12v12H6z" />
</svg>
</span>
</button>
</>
)}
{status === "paused" && (
<>
<button
onClick={onResume}
disabled={disabled}
className="flex-1 h-14 rounded-xl text-lg font-bold text-white active:scale-95 transition-all disabled:opacity-40"
style={{ background: "linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)" }}
>
<span className="flex items-center justify-center gap-2">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</span>
</button>
<button
onClick={onComplete}
disabled={disabled}
className="flex-1 h-14 rounded-xl text-lg font-bold text-white active:scale-95 transition-all disabled:opacity-40"
style={{ background: "linear-gradient(135deg, #10b981 0%, #059669 100%)" }}
>
<span className="flex items-center justify-center gap-2">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 6h12v12H6z" />
</svg>
</span>
</button>
</>
)}
{status === "completed" && (
<div className="flex-1 h-14 rounded-xl bg-green-100 border-2 border-green-300 flex items-center justify-center gap-2 text-green-700 font-bold text-lg">
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</div>
)}
)}
{status === "running" && (
<>
<button onClick={onPause} disabled={disabled} className={btnClass}
style={{ background: "linear-gradient(135deg, #f59e0b, #d97706)" }}>
</button>
<button onClick={onComplete} disabled={disabled} className={btnClass}
style={{ background: "linear-gradient(135deg, #10b981, #059669)" }}>
</button>
</>
)}
{status === "paused" && (
<>
<button onClick={onResume} disabled={disabled} className={btnClass}
style={{ background: "linear-gradient(135deg, #3b82f6, #1d4ed8)" }}>
</button>
<button onClick={onComplete} disabled={disabled} className={btnClass}
style={{ background: "linear-gradient(135deg, #10b981, #059669)" }}>
</button>
</>
)}
{status === "completed" && (
<div className="h-12 px-5 rounded-xl bg-green-100 border-2 border-green-300 flex items-center gap-2 text-green-700 font-bold text-base">
</div>
)}
</div>
</div>
</div>
);
File diff suppressed because it is too large Load Diff
@@ -2,10 +2,12 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiClient } from "@/lib/api/client";
import { dataApi } from "@/lib/api/data";
import { AcceptProcessModal } from "./AcceptProcessModal";
import { ProcessDetailModal, ProcessStep } from "./ProcessDetailModal";
import { ProcessWork } from "./ProcessWork";
/* ------------------------------------------------------------------ */
/* Types */
@@ -17,6 +19,7 @@ interface WorkInstruction {
item_id: string;
item_name: string;
item_code: string;
item_number: string;
qty: number;
completed_qty: number;
status: string;
@@ -44,9 +47,13 @@ interface WorkOrderProcess {
total_production_qty: string;
parent_process_id: string | null;
is_rework: string;
rework_source_id: string | null;
result_status: string;
started_at: string | null;
completed_at: string | null;
accepted_by?: string;
accepted_at?: string | null;
created_date?: string;
}
interface ProcessMng {
@@ -71,7 +78,6 @@ const CARD_COLS_KEY = "workorder-card-cols";
const DEFAULT_COLS = 1;
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" },
@@ -99,6 +105,113 @@ const COLS_GRID_CLASS: Record<number, string> = {
3: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3",
};
/* ------------------------------------------------------------------ */
/* Fullscreen Work Modal with My-Work Drawer */
/* ------------------------------------------------------------------ */
function FullscreenWorkModal({
processId,
myProcesses,
instructionMap,
onSwitch,
onClose,
}: {
processId: string;
myProcesses: WorkOrderProcess[];
instructionMap: Record<string, WorkInstruction>;
onSwitch: (id: string) => void;
onClose: () => void;
}) {
const [drawerOpen, setDrawerOpen] = React.useState(false);
return (
<div className="fixed inset-0 z-[100] bg-white flex">
{/* Drawer tab handle (left edge, middle) */}
<button
onClick={() => setDrawerOpen((v) => !v)}
className={`absolute left-0 top-1/2 -translate-y-1/2 z-[115] h-20 w-5 flex items-center justify-center transition-all ${
drawerOpen ? "left-[280px]" : "left-0"
}`}
style={{
background: "#6d28d9",
borderRadius: "0 8px 8px 0",
boxShadow: "2px 0 8px rgba(0,0,0,0.15)",
}}
>
<svg className={`w-3 h-3 text-white transition-transform ${drawerOpen ? "rotate-180" : ""}`} fill="none" stroke="currentColor" strokeWidth={3} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
{/* Drawer overlay */}
{drawerOpen && (
<div className="absolute inset-0 bg-black/20 z-[111]" onClick={() => setDrawerOpen(false)} />
)}
{/* Drawer panel */}
<div
className={`absolute left-0 top-0 bottom-0 z-[112] bg-white border-r border-gray-200 shadow-xl transition-transform duration-300 flex flex-col ${
drawerOpen ? "translate-x-0" : "-translate-x-full"
}`}
style={{ width: 280 }}
>
<div className="p-4 border-b border-gray-100">
<h3 className="text-base font-bold text-gray-900"> </h3>
<p className="text-xs text-gray-400 mt-1">{myProcesses.length}</p>
</div>
<div className="flex-1 overflow-y-auto p-2">
{myProcesses.map((proc) => {
const wi = instructionMap[proc.wo_id];
const isActive = proc.id === processId;
return (
<button
key={proc.id}
onClick={() => { onSwitch(proc.id); setDrawerOpen(false); }}
className={`w-full text-left p-3.5 rounded-xl mb-2 transition-all active:scale-[0.98] ${
isActive
? "bg-blue-50 border-2 border-blue-400"
: "bg-gray-50 border border-gray-200 hover:bg-gray-100"
}`}
>
<div className="text-base font-bold text-gray-900 mb-1">
{wi?.work_instruction_no || "작업지시"}
</div>
<div className="text-sm text-gray-500 mb-0.5 truncate">
{wi?.item_name || ""}{(wi?.item_code || wi?.item_number) ? `(${wi?.item_code || wi?.item_number})` : ""}
</div>
<div className="text-sm text-gray-600 mb-0.5">
{proc.process_name} · {proc.equipment_code || "미배정"}
</div>
<div className="text-sm font-semibold text-blue-600">
{proc.input_qty || 0}EA
</div>
</button>
);
})}
{myProcesses.length === 0 && (
<p className="text-sm text-gray-400 text-center py-8"> </p>
)}
</div>
</div>
{/* Close button */}
<button
onClick={onClose}
className="absolute top-4 right-4 z-[110] w-12 h-12 rounded-full bg-black/10 hover:bg-black/20 flex items-center justify-center active:scale-95 transition-all"
>
<svg className="w-6 h-6 text-gray-700" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/* ProcessWork content */}
<div className="flex-1 overflow-auto">
<ProcessWork key={processId} processId={processId} />
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Filter Selector Modal */
/* ------------------------------------------------------------------ */
@@ -223,21 +336,26 @@ function CompressedProcessSteps({
const beforeCollapsed = prevIdx > 0 ? prevIdx : 0;
const afterCollapsed = sorted.length - 1 - (nextIdx < sorted.length ? nextIdx : currentIdx);
const stepDot = (proc: WorkOrderProcess, isCurrent: boolean) => {
// 공정명 축약 (4글자 초과 시 잘라내기)
const shortName = (name: string) => {
if (!name) return "?";
const clean = name.replace(/^제조반_/, "");
return clean.length > 4 ? clean.slice(0, 4) : clean;
};
const stepLabel = (proc: WorkOrderProcess, isCurrent: boolean) => {
const isCompleted = proc.status === "completed";
const isInProgress = proc.status === "in_progress" || proc.status === "acceptable";
const size = isCurrent ? 32 : 26;
const fontSize = isCurrent ? 11 : 9;
if (isCurrent) {
const bgColor = isInProgress ? "bg-blue-500" : isCompleted ? "bg-green-500" : "bg-amber-400";
const ringColor = isInProgress ? "ring-blue-200" : isCompleted ? "ring-green-200" : "ring-amber-200";
return (
<span
className={`rounded-full ${bgColor} text-white ring-2 ${ringColor} flex items-center justify-center shrink-0 font-bold ${isInProgress ? "animate-pulse" : ""}`}
style={{ width: size, height: size, fontSize }}
className={`rounded-lg ${bgColor} text-white ring-2 ${ringColor} flex items-center justify-center shrink-0 font-bold px-3 ${isInProgress ? "animate-pulse" : ""}`}
style={{ height: 36, fontSize: 14, whiteSpace: "nowrap" }}
>
{parseInt(proc.seq_no, 10)}
{shortName(proc.process_name)}
</span>
);
}
@@ -245,20 +363,20 @@ function CompressedProcessSteps({
if (isCompleted) {
return (
<span
className="rounded-full bg-green-100 text-green-700 border-2 border-green-400 flex items-center justify-center shrink-0 font-bold"
style={{ width: size, height: size, fontSize }}
className="rounded-lg bg-green-100 text-green-700 border border-green-400 flex items-center justify-center shrink-0 font-bold px-2.5"
style={{ height: 32, fontSize: 13, whiteSpace: "nowrap" }}
>
{parseInt(proc.seq_no, 10)}
{shortName(proc.process_name)}
</span>
);
}
return (
<span
className="rounded-full bg-gray-100 text-gray-500 border-2 border-gray-300 flex items-center justify-center shrink-0 font-bold"
style={{ width: size, height: size, fontSize }}
className="rounded-lg bg-gray-100 text-gray-500 border border-gray-300 flex items-center justify-center shrink-0 font-bold px-2.5"
style={{ height: 32, fontSize: 13, whiteSpace: "nowrap" }}
>
{parseInt(proc.seq_no, 10)}
{shortName(proc.process_name)}
</span>
);
};
@@ -270,12 +388,12 @@ function CompressedProcessSteps({
return <span className={`w-3 h-0.5 ${color} shrink-0`} />;
};
// For waiting status: no click handler, no chevron
const isClickable = status !== "waiting" && onClick;
// 모든 상태에서 클릭 가능 (공정 상세 모달)
const isClickable = !!onClick;
return (
<div
className={`flex items-center justify-center gap-0.5 mb-3 py-2 px-3 bg-gray-50 rounded-xl transition ${
className={`flex items-center justify-center gap-1 mb-3 py-3 px-4 bg-gray-50 rounded-xl transition ${
isClickable ? "cursor-pointer hover:bg-gray-100" : ""
}`}
onClick={isClickable ? onClick : undefined}
@@ -283,7 +401,7 @@ function CompressedProcessSteps({
{/* Collapsed before */}
{beforeCollapsed > 0 && (
<>
<span className="w-7 h-7 rounded-full bg-green-100 text-green-700 border border-green-300 flex items-center justify-center shrink-0 text-[9px] font-bold">
<span className="rounded-lg bg-green-100 text-green-700 border border-green-300 flex items-center justify-center shrink-0 text-xs font-bold px-2" style={{ height: 32 }}>
+{beforeCollapsed}
</span>
<span className="w-3 h-0.5 bg-green-400 shrink-0" />
@@ -293,19 +411,19 @@ function CompressedProcessSteps({
{/* Previous step */}
{prevIdx >= 0 && (
<>
{stepDot(sorted[prevIdx], false)}
{lineBetween(sorted[prevIdx], sorted[currentIdx])}
{stepLabel(sorted[prevIdx], false)}
<span className="w-3 h-0.5 bg-gray-300 shrink-0" />
</>
)}
{/* Current step */}
{stepDot(sorted[currentIdx], true)}
{stepLabel(sorted[currentIdx], true)}
{/* Next step */}
{nextIdx < sorted.length && (
<>
{lineBetween(sorted[currentIdx], sorted[nextIdx])}
{stepDot(sorted[nextIdx], false)}
<span className="w-3 h-0.5 bg-gray-300 shrink-0" />
{stepLabel(sorted[nextIdx], false)}
</>
)}
@@ -313,7 +431,7 @@ function CompressedProcessSteps({
{afterCollapsed > 0 && (
<>
<span className="w-3 h-0.5 bg-gray-200 shrink-0" />
<span className="w-7 h-7 rounded-full bg-gray-200 text-gray-500 flex items-center justify-center shrink-0 text-[9px] font-bold">
<span className="rounded-lg bg-gray-200 text-gray-500 flex items-center justify-center shrink-0 text-xs font-bold px-2" style={{ height: 32 }}>
+{afterCollapsed}
</span>
</>
@@ -346,17 +464,17 @@ function AcceptableCardBody({
return (
<div className="grid grid-cols-3 gap-2 mb-3">
<div className="text-center bg-gray-50 rounded-xl py-4">
<div className="text-[13px] text-gray-400"></div>
<div className="text-2xl font-extrabold text-gray-900">{planQty.toLocaleString()}</div>
<div className="text-sm text-gray-400"></div>
<div className="text-3xl font-extrabold text-gray-900">{planQty.toLocaleString()}</div>
</div>
<div className="text-center bg-emerald-50 rounded-xl py-4">
<div className="text-[13px] text-emerald-500"></div>
<div className="text-2xl font-extrabold text-emerald-600">
<div className="text-sm text-emerald-500"></div>
<div className="text-3xl font-extrabold text-emerald-600">
{prevGoodQty !== null ? prevGoodQty.toLocaleString() : "-"}
</div>
</div>
<div className="text-center bg-blue-50 rounded-xl py-4">
<div className="text-[13px] text-blue-500"></div>
<div className="text-sm text-blue-500"></div>
<div className="font-extrabold text-blue-600" style={{ fontSize: 28 }}>
{availableQty.toLocaleString()}
</div>
@@ -394,20 +512,20 @@ function InProgressCardBody({
{/* 4-col qty grid */}
<div className="grid grid-cols-4 gap-1.5 mb-2">
<div className="text-center bg-blue-50 rounded-xl py-3">
<div className="text-[13px] text-blue-500"></div>
<div className="text-2xl font-extrabold text-blue-700">{inputQty.toLocaleString()}</div>
<div className="text-sm text-blue-500"></div>
<div className="text-3xl font-extrabold text-blue-700">{inputQty.toLocaleString()}</div>
</div>
<div className="text-center bg-emerald-50 rounded-xl py-3">
<div className="text-[13px] text-emerald-500"></div>
<div className="text-2xl font-extrabold text-emerald-700">{goodQty.toLocaleString()}</div>
<div className="text-sm text-emerald-500"></div>
<div className="text-3xl font-extrabold text-emerald-700">{goodQty.toLocaleString()}</div>
</div>
<div className="text-center bg-red-50 rounded-xl py-3">
<div className="text-[13px] text-red-500"></div>
<div className="text-2xl font-extrabold text-red-700">{defectQty.toLocaleString()}</div>
<div className="text-sm text-red-500"></div>
<div className="text-3xl font-extrabold text-red-700">{defectQty.toLocaleString()}</div>
</div>
<div className="text-center bg-amber-50 rounded-xl py-3">
<div className="text-[13px] text-amber-500"></div>
<div className="text-2xl font-extrabold text-amber-700">{remainQty.toLocaleString()}</div>
<div className="text-sm text-amber-500"></div>
<div className="text-3xl font-extrabold text-amber-700">{remainQty.toLocaleString()}</div>
</div>
</div>
{additionalAvailable > 0 && (
@@ -437,18 +555,18 @@ function WaitingCardBody({
<>
<div className="grid grid-cols-3 gap-2 mb-3">
<div className="text-center bg-gray-50 rounded-xl py-3">
<div className="text-[13px] text-gray-400"></div>
<div className="text-2xl font-extrabold text-gray-900">{planQty.toLocaleString()}</div>
<div className="text-sm text-gray-400"></div>
<div className="text-3xl font-extrabold text-gray-900">{planQty.toLocaleString()}</div>
</div>
<div className="text-center bg-blue-50 rounded-xl py-3">
<div className="text-[13px] text-blue-500"></div>
<div className="text-sm text-blue-500"></div>
<div className="text-base font-bold text-blue-600 truncate">{prevProcessName || "-"}</div>
{prevProgressPct !== null && (
<div className="text-xs text-blue-400 mt-1"> ({prevProgressPct}%)</div>
)}
</div>
<div className="text-center bg-gray-50 rounded-xl py-3">
<div className="text-[13px] text-gray-400"></div>
<div className="text-sm text-gray-400"></div>
<div className="text-base font-bold text-gray-600 truncate">{currentProcessName}</div>
<div className="text-xs text-gray-400 mt-1">{currentSeqNo} </div>
</div>
@@ -473,20 +591,20 @@ function CompletedCardBody({
return (
<div className="grid grid-cols-4 gap-1.5">
<div className="text-center bg-emerald-50 rounded-xl py-3">
<div className="text-[13px] text-emerald-500"></div>
<div className="text-2xl font-extrabold text-emerald-700">{goodQty.toLocaleString()}</div>
<div className="text-sm text-emerald-500"></div>
<div className="text-3xl font-extrabold text-emerald-700">{goodQty.toLocaleString()}</div>
</div>
<div className="text-center bg-red-50 rounded-xl py-3">
<div className="text-[13px] text-red-500"></div>
<div className="text-2xl font-extrabold text-red-700">{defectQty.toLocaleString()}</div>
<div className="text-sm text-red-500"></div>
<div className="text-3xl font-extrabold text-red-700">{defectQty.toLocaleString()}</div>
</div>
<div className="text-center bg-gray-50 rounded-xl py-3">
<div className="text-[13px] text-gray-400"></div>
<div className="text-2xl font-extrabold text-gray-600">{planQty.toLocaleString()}</div>
<div className="text-sm text-gray-400"></div>
<div className="text-3xl font-extrabold text-gray-600">{planQty.toLocaleString()}</div>
</div>
<div className="text-center bg-blue-50 rounded-xl py-3">
<div className="text-[13px] text-blue-500"></div>
<div className="text-2xl font-extrabold text-blue-700">{inputQty.toLocaleString()}</div>
<div className="text-sm text-blue-500"></div>
<div className="text-3xl font-extrabold text-blue-700">{inputQty.toLocaleString()}</div>
</div>
</div>
);
@@ -496,31 +614,47 @@ function CompletedCardBody({
function ReworkCardBody({
reworkQty,
availableQty,
originProcessName,
originProcessCode,
originDefectQty,
reworkRound,
}: {
reworkQty: number;
availableQty: number;
originProcessName: string;
originProcessCode: string;
originDefectQty: number;
reworkRound: number;
}) {
return (
<>
{/* Rework info summary */}
<div className="mb-3 p-2.5 bg-orange-50 border border-orange-200 rounded-xl">
<div className="flex items-center gap-1.5 text-sm font-semibold text-orange-700">
<span>{originProcessName || originProcessCode}</span>
<span className="text-orange-400">&rarr;</span>
<span> {originDefectQty}</span>
<span className="text-orange-400">&rarr;</span>
<span className="bg-orange-500 text-white text-xs px-1.5 py-0.5 rounded font-bold">
{reworkRound}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-3 mb-3">
<div className="text-center bg-orange-50 rounded-xl py-4">
<div className="text-[13px] text-orange-500"></div>
<div className="text-sm text-orange-500"></div>
<div className="font-extrabold text-orange-600" style={{ fontSize: 32 }}>
{reworkQty.toLocaleString()}
</div>
</div>
<div className="text-center bg-blue-50 rounded-xl py-4">
<div className="text-[13px] text-blue-500"></div>
<div className="text-sm text-blue-500"></div>
<div className="font-extrabold text-blue-600" style={{ fontSize: 32 }}>
{availableQty.toLocaleString()}
</div>
</div>
</div>
<div className="text-center text-sm text-gray-400">
{originProcessCode}
</div>
</>
);
}
@@ -531,6 +665,8 @@ function ReworkCardBody({
export function WorkOrderList() {
const router = useRouter();
const { user } = useAuth();
const currentUserId = user?.userId || "";
/* ---- State ---- */
const [instructions, setInstructions] = useState<WorkInstruction[]>([]);
@@ -539,7 +675,7 @@ export function WorkOrderList() {
const [equipmentList, setEquipmentList] = useState<EquipmentMng[]>([]);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [activeTab, setActiveTab] = useState<TabFilter>("all");
const [activeTab, setActiveTab] = useState<TabFilter>("acceptable");
/* Column layout */
const [cardCols, setCardCols] = useState<number>(DEFAULT_COLS);
@@ -566,6 +702,8 @@ export function WorkOrderList() {
wiNo: string;
totalQty: number;
steps: ProcessStep[];
woId?: string;
showReworkHistory?: boolean;
}>({ open: false, wiNo: "", totalQty: 0, steps: [] });
/* ---- Load saved column preference ---- */
@@ -601,11 +739,27 @@ export function WorkOrderList() {
}
const wiRes = await apiClient.get("/work-instruction/list");
let wiData: WorkInstruction[] = [];
let wiRaw: Record<string, unknown>[] = [];
if (wiRes.data?.data) {
wiData = Array.isArray(wiRes.data.data) ? wiRes.data.data : wiRes.data.data.rows || [];
wiRaw = Array.isArray(wiRes.data.data) ? wiRes.data.data : wiRes.data.data.rows || [];
} else if (Array.isArray(wiRes.data)) {
wiData = wiRes.data;
wiRaw = wiRes.data;
}
// wi_id → id 매핑 + 중복 제거 (header+detail 조인이므로 첫 행만)
const seen = new Set<string>();
const wiData: WorkInstruction[] = [];
for (const raw of wiRaw) {
const wiId = String(raw.wi_id || raw.id || "");
if (!wiId || seen.has(wiId)) continue;
seen.add(wiId);
wiData.push({
...raw,
id: wiId,
item_name: String(raw.item_name || ""),
item_code: String(raw.item_code || ""),
item_number: String(raw.item_number || ""),
qty: parseInt(String(raw.total_qty || raw.qty || 0), 10),
} as unknown as WorkInstruction);
}
setInstructions(wiData);
@@ -671,11 +825,16 @@ export function WorkOrderList() {
}, [allProcesses]);
const masterProcesses = useMemo(() => {
// 마스터 행 + 내가 접수한 분할 행(진행중)도 포함
// 마스터 행 + 분할 행(진행중/완료/리워크) — 중복 제거
const seen = new Set<string>();
return allProcesses.filter((p) => {
if (!p.parent_process_id) return true; // 마스터 행
if (p.status === "in_progress" || p.status === "completed") return true; // 분할 행 (접수된 것)
return false;
if (seen.has(p.id)) return false;
const include =
!p.parent_process_id || // 마스터 행
p.status === "in_progress" || p.status === "completed" || // 분할 행
p.is_rework === "Y" || p.is_rework === "true" || p.is_rework === "1"; // 재작업
if (include) seen.add(p.id);
return include;
});
}, [allProcesses]);
@@ -690,7 +849,7 @@ export function WorkOrderList() {
/* ---- Filter options ---- */
const processFilterOptions = useMemo(() => {
const opts: { value: string; label: string }[] = [{ value: "__all__", label: "전체 공정" }];
const opts: { value: string; label: string }[] = [{ value: "__all__", label: "공정을 선택하세요" }];
const seen = new Set<string>();
for (const proc of masterProcesses) {
if (proc.process_code && !seen.has(proc.process_code)) {
@@ -725,8 +884,9 @@ export function WorkOrderList() {
/* ---- Filtered processes ---- */
const filteredProcesses = useMemo(() => {
if (selectedProcess === "__all__") return []; // 공정 미선택 시 빈 목록
return masterProcesses.filter((proc) => {
if (selectedProcess !== "__all__" && proc.process_code !== selectedProcess) return false;
if (proc.process_code !== selectedProcess) return false;
if (selectedEquipment !== "__all__") {
const wi = instructionMap[proc.wo_id];
if (!wi) return false;
@@ -737,7 +897,7 @@ export function WorkOrderList() {
if (activeTab !== "all" && proc.status !== activeTab) return false;
return true;
});
}, [masterProcesses, selectedProcess, selectedEquipment, activeTab, instructionMap, equipmentMap]);
}, [masterProcesses, selectedProcess, selectedEquipment, activeTab, instructionMap, equipmentMap, currentUserId, allProcesses]);
/* ---- Tab counts ---- */
const tabCounts = useMemo(() => {
@@ -790,13 +950,8 @@ export function WorkOrderList() {
accept_qty: qty,
});
if (res.data?.success) {
const splitId = res.data.data?.id;
setAcceptModal((m) => ({ ...m, open: false }));
if (splitId) {
router.push(`/pop/production/work/${splitId}`);
} else {
fetchAll();
}
await fetchAll();
} else {
alert(res.data?.message || "접수 실패");
}
@@ -807,9 +962,11 @@ export function WorkOrderList() {
}
};
/* ---- Navigate to work ---- */
/* ---- Open work detail as fullscreen modal ---- */
const [workModalProcessId, setWorkModalProcessId] = useState<string | null>(null);
const goToWork = (processId: string) => {
router.push(`/pop/production/work/${processId}`);
setWorkModalProcessId(processId);
};
/* ---- Open process detail modal ---- */
@@ -841,11 +998,18 @@ export function WorkOrderList() {
};
});
// Check if this wo has any rework processes
const hasReworks = allProcesses.some(
(p) => p.wo_id === proc.wo_id && (p.is_rework === "Y" || p.is_rework === "true" || p.is_rework === "1")
);
setDetailModal({
open: true,
wiNo: wi?.work_instruction_no || "작업지시",
totalQty,
steps,
woId: proc.wo_id,
showReworkHistory: hasReworks,
});
};
@@ -998,8 +1162,8 @@ export function WorkOrderList() {
</svg>
</div>
<p className="text-sm text-gray-400">
{activeTab === "all"
? "등록된 공정이 없습니다"
{selectedProcess === "__all__"
? "공정을 선택해주세요"
: `${TABS.find((t) => t.key === activeTab)?.label || ""} 상태의 공정이 없습니다`}
</p>
</div>
@@ -1023,7 +1187,7 @@ export function WorkOrderList() {
const goodQty = parseInt(proc.good_qty || "0", 10);
const defectQty = parseInt(proc.defect_qty || "0", 10);
const inputQty = parseInt(proc.input_qty || "0", 10);
const isRework = proc.is_rework === "true" || proc.is_rework === "1";
const isRework = proc.is_rework === "Y" || proc.is_rework === "true" || proc.is_rework === "1";
const borderLeft = isRework ? "border-l-orange-500" : (BORDER_LEFT_COLOR[proc.status] || "border-l-gray-300");
const eqName = getEquipmentName(proc);
@@ -1041,20 +1205,57 @@ export function WorkOrderList() {
const prevInfo = getPrevProcessInfo(proc);
// Calculate available qty for acceptable
const availableQty = prevInfo.prevGoodQty !== null
? Math.max(0, prevInfo.prevGoodQty - inputQty)
: Math.max(0, planQty - inputQty);
const availableQty = isRework
? inputQty // 리워크 카드는 input_qty 자체가 접수 대상
: prevInfo.prevGoodQty !== null
? Math.max(0, prevInfo.prevGoodQty - inputQty)
: Math.max(0, planQty - inputQty);
// Additional available for in_progress
const additionalAvailable = Math.max(0, planQty - inputQty);
// Rework info: origin process + rework round
let reworkRound = 1;
let originProcessName = proc.process_name || proc.process_code;
let originProcessCode = proc.process_code;
let originDefectQty = defectQty;
if (isRework) {
// Count how many rework processes exist for this wo_id with same process_code
const sameWoReworks = allProcesses.filter(
(p) =>
p.wo_id === proc.wo_id &&
(p.is_rework === "Y" || p.is_rework === "true" || p.is_rework === "1")
);
// Find this process's position among reworks (by created_date or id)
const sortedReworks = [...sameWoReworks].sort((a, b) => {
const da = a.created_date ? new Date(a.created_date).getTime() : 0;
const db = b.created_date ? new Date(b.created_date).getTime() : 0;
return da - db || a.id.localeCompare(b.id);
});
const myIdx = sortedReworks.findIndex((r) => r.id === proc.id);
reworkRound = myIdx >= 0 ? myIdx + 1 : 1;
// Find origin (source) process
if (proc.rework_source_id) {
const origin = allProcesses.find((p) => p.id === proc.rework_source_id);
if (origin) {
originProcessName = origin.process_name || origin.process_code;
originProcessCode = origin.process_code;
originDefectQty = parseInt(origin.defect_qty || "0", 10);
}
}
}
return (
<div key={proc.id} className="card-item">
<div key={`${proc.id}-${proc.status}-${proc.parent_process_id || "m"}`} className="card-item">
<div
className={`bg-white rounded-2xl border-l-4 ${borderLeft} border border-gray-100 shadow-sm overflow-hidden flex flex-col ${
proc.status === "waiting" ? "opacity-75" : ""
} ${isRework ? "border-2 border-orange-200" : ""}`}
style={{ minHeight: 340 }}
} ${isRework ? "border-2 border-orange-200" : ""} ${
proc.status === "in_progress" ? "cursor-pointer active:scale-[0.99] transition-transform" : ""
}`}
style={{ height: "100%" }}
onClick={proc.status === "in_progress" ? () => goToWork(proc.parent_process_id ? proc.id : (mySplits[0]?.id || proc.id)) : undefined}
>
<div className="p-4 flex-1 flex flex-col">
{/* Header: Work instruction number + status badge */}
@@ -1065,34 +1266,28 @@ export function WorkOrderList() {
🔄
</span>
)}
<h3 className="text-lg font-extrabold text-gray-900 truncate">
<h3 className="text-xl font-extrabold text-gray-900 truncate">
{wi?.work_instruction_no || "작업지시"}
</h3>
</div>
<span className={`shrink-0 ml-2 text-xs font-bold px-3 py-1 rounded-full ${badge.bg} ${badge.text}`}>
<span className={`shrink-0 ml-2 text-sm font-bold px-3 py-1.5 rounded-full ${badge.bg} ${badge.text}`}>
{badge.prefix}{badge.label}
</span>
</div>
{/* Sub-info: item name + equipment */}
<div className="flex items-center gap-3 text-xs text-gray-500 mb-3">
<div className="flex items-center gap-3 text-sm text-gray-500 mb-3">
<span className="truncate">📦 {wi?.item_name || wi?.item_code || wi?.item_number || "품목"}</span>
{!isRework && (
<>
<span className="truncate">📦 {wi?.item_name || wi?.item_code || "품목"}</span>
<span className="shrink-0"> {eqName}</span>
</>
<span className="shrink-0"> {eqName}</span>
)}
{isRework && (
<>
<span className="truncate">📦 {wi?.item_name || wi?.item_code || "품목"}</span>
<span className="shrink-0">: {proc.process_code}</span>
<span className="shrink-0"> {defectQty}EA</span>
</>
<span className="shrink-0"> {proc.process_name || proc.process_code}</span>
)}
</div>
{/* Process steps (compressed) -- not for rework */}
{!isRework && siblingProcesses.length > 1 && (
{/* Process steps (compressed) — both normal and rework cards */}
{siblingProcesses.length > 1 && (
<CompressedProcessSteps
processes={processesByWo[proc.wo_id] || []}
currentSeqNo={proc.seq_no}
@@ -1107,7 +1302,10 @@ export function WorkOrderList() {
<ReworkCardBody
reworkQty={planQty}
availableQty={availableQty}
originProcessCode={proc.process_code}
originProcessName={originProcessName}
originProcessCode={originProcessCode}
originDefectQty={originDefectQty}
reworkRound={reworkRound}
/>
) : proc.status === "acceptable" ? (
<AcceptableCardBody
@@ -1153,7 +1351,7 @@ export function WorkOrderList() {
</button>
)}
{!isRework && proc.status === "acceptable" && (
{!isRework && proc.status === "acceptable" && availableQty > 0 && (
<button
onClick={() => openAcceptModal(proc.id, proc.process_name, proc.seq_no)}
className="w-full py-3.5 text-sm font-bold text-white active:scale-[0.98] transition-all"
@@ -1162,12 +1360,33 @@ export function WorkOrderList() {
</button>
)}
{proc.status === "in_progress" && mySplits.length > 0 && (
{!isRework && proc.status === "acceptable" && availableQty <= 0 && inputQty > 0 && (
<div className="w-full py-3 text-sm font-bold text-center text-gray-400 bg-gray-100">
</div>
)}
{proc.status === "in_progress" && parseInt(proc.total_production_qty || "0", 10) === 0 && proc.parent_process_id && (
<button
onClick={() => goToWork(mySplits[0].id)}
className="w-full py-3.5 text-sm font-bold text-white bg-blue-500 active:scale-[0.98] transition-all"
onClick={async (e) => {
e.stopPropagation();
if (!confirm("접수를 취소하시겠습니까?")) return;
try {
const res = await apiClient.post("/pop/production/cancel-accept", {
work_order_process_id: proc.id,
});
if (res.data?.success) {
fetchAll();
} else {
alert(res.data?.message || "취소 실패");
}
} catch (err: unknown) {
const e2 = err as { response?: { data?: { message?: string } } };
alert(e2.response?.data?.message || "취소 중 오류");
}
}}
className="w-full py-4 text-base font-bold text-red-500 border-t-2 border-red-100 bg-red-50/50 active:scale-[0.98] transition-all"
>
</button>
)}
</div>
@@ -1213,7 +1432,20 @@ export function WorkOrderList() {
workInstructionNo={detailModal.wiNo}
totalQty={detailModal.totalQty}
steps={detailModal.steps}
woId={detailModal.woId}
showReworkHistory={detailModal.showReworkHistory}
/>
{/* Fullscreen Work Modal */}
{workModalProcessId && (
<FullscreenWorkModal
processId={workModalProcessId}
myProcesses={allProcesses.filter((p) => p.parent_process_id && p.accepted_by === currentUserId && (p.status === "in_progress" || p.status === "completed"))}
instructionMap={instructionMap}
onSwitch={(id) => setWorkModalProcessId(id)}
onClose={() => { setWorkModalProcessId(null); fetchAll(); }}
/>
)}
</div>
);
}
+159
View File
@@ -0,0 +1,159 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { apiClient } from "@/lib/api/client";
export interface PopSettings {
version: string;
screens: {
processExecution: {
materialInput: boolean;
photoUpload: boolean;
plcEnabled: boolean;
bomFlexible: boolean;
packagingOptions: string[];
defectTypes: string[];
reworkTargetSelection: boolean;
groupPhotoEnabled: boolean;
dateFilter: boolean;
lastProcessInventory: "auto" | "manual" | "button";
defaultWarehouse: boolean;
inspectionAutoJudge: "off" | "warn" | "fail";
standardTimeDisplay: boolean;
progressDisplay: boolean;
};
inbound: {
inspectionRequired: boolean;
photoUpload: boolean;
barcodeEnabled: boolean;
packagingRecord: boolean;
defectSeparation: boolean;
};
outbound: {
photoUpload: boolean;
barcodeEnabled: boolean;
};
home: {
kpiCarousel: boolean;
recentActivity: boolean;
bannerEnabled: boolean;
bannerText: string;
iconThemeColor: string;
iconCustomImages: boolean;
dashboardLayout: "default" | "compact" | "detailed";
};
plc: {
connectionType: "db" | "opcua" | "rest";
refreshInterval: number;
tagMappings: Array<{
tagName: string;
processCode: string;
checklistItemId: string;
unit: string;
}>;
alarmThresholds: Array<{
tagName: string;
lowerLimit: number;
upperLimit: number;
action: "warn" | "stop";
}>;
};
};
}
const DEFAULT_SETTINGS: PopSettings = {
version: "hardcoded-1.0",
screens: {
processExecution: {
materialInput: true,
photoUpload: true,
plcEnabled: false,
bomFlexible: true,
packagingOptions: ["낱개", "박스", "파렛트"],
defectTypes: ["스크래치", "치수불량", "변색", "크랙", "기포"],
reworkTargetSelection: true,
groupPhotoEnabled: false,
dateFilter: false,
lastProcessInventory: "manual",
defaultWarehouse: false,
inspectionAutoJudge: "off",
standardTimeDisplay: false,
progressDisplay: false,
},
inbound: {
inspectionRequired: false,
photoUpload: false,
barcodeEnabled: true,
packagingRecord: false,
defectSeparation: false,
},
outbound: {
photoUpload: false,
barcodeEnabled: true,
},
home: {
kpiCarousel: true,
recentActivity: true,
bannerEnabled: false,
bannerText: "",
iconThemeColor: "#2563eb",
iconCustomImages: false,
dashboardLayout: "default",
},
plc: {
connectionType: "db",
refreshInterval: 5,
tagMappings: [],
alarmThresholds: [],
},
},
};
let cachedSettings: PopSettings | null = null;
export function usePopSettings() {
const [settings, setSettings] = useState<PopSettings>(cachedSettings || DEFAULT_SETTINGS);
const [loading, setLoading] = useState(!cachedSettings);
const fetchSettings = useCallback(async () => {
if (cachedSettings) { setSettings(cachedSettings); setLoading(false); return; }
try {
const res = await apiClient.get("/data/pop_settings?pageSize=1");
const rows = res.data?.data?.data || res.data?.data || [];
if (rows.length > 0 && rows[0].settings_data) {
const parsed = typeof rows[0].settings_data === "string"
? JSON.parse(rows[0].settings_data) : rows[0].settings_data;
const merged = {
...DEFAULT_SETTINGS,
...parsed,
screens: {
...DEFAULT_SETTINGS.screens,
...parsed.screens,
processExecution: { ...DEFAULT_SETTINGS.screens.processExecution, ...parsed.screens?.processExecution },
inbound: { ...DEFAULT_SETTINGS.screens.inbound, ...parsed.screens?.inbound },
outbound: { ...DEFAULT_SETTINGS.screens.outbound, ...parsed.screens?.outbound },
home: { ...DEFAULT_SETTINGS.screens.home, ...parsed.screens?.home },
plc: { ...DEFAULT_SETTINGS.screens.plc, ...parsed.screens?.plc },
},
};
cachedSettings = merged;
setSettings(merged);
}
} catch {
// localStorage fallback
const local = localStorage.getItem("pop_settings");
if (local) {
try {
const parsed = JSON.parse(local);
cachedSettings = { ...DEFAULT_SETTINGS, ...parsed };
setSettings(cachedSettings);
} catch { /* use default */ }
}
}
setLoading(false);
}, []);
useEffect(() => { fetchSettings(); }, [fetchSettings]);
return { settings, loading };
}
+38 -5
View File
@@ -268,6 +268,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -309,6 +310,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -342,6 +344,7 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
@@ -1418,6 +1421,7 @@
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"playwright": "1.58.2"
},
@@ -3073,6 +3077,7 @@
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz",
"integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.17.8",
"@types/react-reconciler": "^0.32.0",
@@ -3726,6 +3731,7 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz",
"integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/query-core": "5.90.6"
},
@@ -3820,6 +3826,7 @@
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -4133,6 +4140,7 @@
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz",
"integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-collab": "^1.3.1",
@@ -6633,6 +6641,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -6643,6 +6652,7 @@
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -6685,6 +6695,7 @@
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
@@ -6767,6 +6778,7 @@
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2",
@@ -7399,6 +7411,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -8549,7 +8562,8 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/d3": {
"version": "7.9.0",
@@ -8871,6 +8885,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -9630,6 +9645,7 @@
"integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -9718,6 +9734,7 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -9819,6 +9836,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -10990,6 +11008,7 @@
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
@@ -11770,7 +11789,8 @@
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
"license": "BSD-2-Clause",
"peer": true
},
"node_modules/levn": {
"version": "0.4.1",
@@ -13109,6 +13129,7 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -13402,6 +13423,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT",
"peer": true,
"dependencies": {
"orderedmap": "^2.0.0"
}
@@ -13431,6 +13453,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
@@ -13479,6 +13502,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz",
"integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
@@ -13682,6 +13706,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13751,6 +13776,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@@ -13801,6 +13827,7 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -13842,7 +13869,8 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/react-leaflet": {
"version": "5.0.0",
@@ -14150,6 +14178,7 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@@ -14172,7 +14201,8 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/recharts/node_modules/redux-thunk": {
"version": "3.1.0",
@@ -15202,7 +15232,8 @@
"version": "0.180.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/three-mesh-bvh": {
"version": "0.8.3",
@@ -15290,6 +15321,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -15638,6 +15670,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"