feat: POP 기능 병합 (pop-screen → main, PC 무변경 46건)
- POP 생산: 재고 관리, 재작업 이력, BOM 자재투입 기능 추가 - POP 설정: 설정 시스템 + 관리 페이지 (/pop/admin) - POP 화면: 버그 수정 + 설정 연동 + 다음공정 활성화 수정 - PC 코드 무변경 (보류 6건: app.ts, 출고/입고/작업지시 컨트롤러, 레이아웃)
This commit is contained in:
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
@@ -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)
|
||||
|
||||
@@ -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>© 2026 탑씰. All rights reserved.</span>
|
||||
<span>© {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">🔍</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">→</span>
|
||||
<span>불량 {originDefectQty}개</span>
|
||||
<span className="text-orange-400">→</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Generated
+38
-5
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user