feat: [생산지시] 목록/상세 API 연동 + 작업자 화면 개선
- ProductionOrders 목록/상세 페이지 API 연동 - 절곡 중간검사 입력 모달 (7개 제품 항목 통합) - 자재투입 다중 BOM 그룹 LOT 독립 관리 - 작업자 화면 제품명 productCode만 표시 - BOM 공정 분류 접이식 카드 UI - 검사성적서 TemplateInspectionContent API 연동
This commit is contained in:
@@ -30,6 +30,7 @@ import {
|
||||
Circle,
|
||||
Activity,
|
||||
Play,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { PageLayout } from "@/components/organisms/PageLayout";
|
||||
import { PageHeader } from "@/components/organisms/PageHeader";
|
||||
@@ -47,143 +48,17 @@ import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { toast } from "sonner";
|
||||
import { ServerErrorPage } from "@/components/common/ServerErrorPage";
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
|
||||
// 생산지시 상태 타입
|
||||
type ProductionOrderStatus = "waiting" | "in_progress" | "completed";
|
||||
|
||||
// 작업지시 상태 타입
|
||||
type WorkOrderStatus = "pending" | "in_progress" | "completed";
|
||||
|
||||
// 작업지시 데이터 타입
|
||||
interface WorkOrder {
|
||||
id: string;
|
||||
workOrderNumber: string; // KD-WO-XXXXXX-XX
|
||||
process: string; // 공정명
|
||||
quantity: number;
|
||||
status: WorkOrderStatus;
|
||||
assignee: string;
|
||||
}
|
||||
|
||||
// 생산지시 상세 데이터 타입
|
||||
interface ProductionOrderDetail {
|
||||
id: string;
|
||||
productionOrderNumber: string;
|
||||
orderNumber: string;
|
||||
productionOrderDate: string;
|
||||
dueDate: string;
|
||||
quantity: number;
|
||||
status: ProductionOrderStatus;
|
||||
client: string;
|
||||
siteName: string;
|
||||
productType: string;
|
||||
pendingWorkOrderCount: number; // 생성 예정 작업지시 수
|
||||
workOrders: WorkOrder[];
|
||||
}
|
||||
|
||||
// 샘플 생산지시 상세 데이터
|
||||
const SAMPLE_PRODUCTION_ORDER_DETAILS: Record<string, ProductionOrderDetail> = {
|
||||
"PO-001": {
|
||||
id: "PO-001",
|
||||
productionOrderNumber: "PO-KD-TS-251217-07",
|
||||
orderNumber: "KD-TS-251217-07",
|
||||
productionOrderDate: "2025-12-22",
|
||||
dueDate: "2026-02-15",
|
||||
quantity: 2,
|
||||
status: "completed", // 생산완료 상태 - 목록 버튼만 표시
|
||||
client: "호반건설(주)",
|
||||
siteName: "씨밋 광교 센트럴시티",
|
||||
productType: "",
|
||||
pendingWorkOrderCount: 0, // 작업지시 이미 생성됨
|
||||
workOrders: [
|
||||
{
|
||||
id: "WO-001",
|
||||
workOrderNumber: "KD-WO-251217-07",
|
||||
process: "재단",
|
||||
quantity: 2,
|
||||
status: "completed",
|
||||
assignee: "-",
|
||||
},
|
||||
{
|
||||
id: "WO-002",
|
||||
workOrderNumber: "KD-WO-251217-08",
|
||||
process: "조립",
|
||||
quantity: 2,
|
||||
status: "completed",
|
||||
assignee: "-",
|
||||
},
|
||||
{
|
||||
id: "WO-003",
|
||||
workOrderNumber: "KD-WO-251217-09",
|
||||
process: "검수",
|
||||
quantity: 2,
|
||||
status: "completed",
|
||||
assignee: "-",
|
||||
},
|
||||
],
|
||||
},
|
||||
"PO-002": {
|
||||
id: "PO-002",
|
||||
productionOrderNumber: "PO-KD-TS-251217-09",
|
||||
orderNumber: "KD-TS-251217-09",
|
||||
productionOrderDate: "2025-12-22",
|
||||
dueDate: "2026-02-10",
|
||||
quantity: 10,
|
||||
status: "waiting",
|
||||
client: "태영건설(주)",
|
||||
siteName: "데시앙 동탄 파크뷰",
|
||||
productType: "",
|
||||
pendingWorkOrderCount: 5, // 생성 예정 작업지시 5개 (일괄생성)
|
||||
workOrders: [],
|
||||
},
|
||||
"PO-003": {
|
||||
id: "PO-003",
|
||||
productionOrderNumber: "PO-KD-TS-251217-06",
|
||||
orderNumber: "KD-TS-251217-06",
|
||||
productionOrderDate: "2025-12-22",
|
||||
dueDate: "2026-02-10",
|
||||
quantity: 1,
|
||||
status: "waiting",
|
||||
client: "롯데건설(주)",
|
||||
siteName: "예술 검실 푸르지오",
|
||||
productType: "",
|
||||
pendingWorkOrderCount: 1, // 생성 예정 작업지시 1개 (단일 생성)
|
||||
workOrders: [],
|
||||
},
|
||||
"PO-004": {
|
||||
id: "PO-004",
|
||||
productionOrderNumber: "PO-KD-BD-251220-35",
|
||||
orderNumber: "KD-BD-251220-35",
|
||||
productionOrderDate: "2025-12-20",
|
||||
dueDate: "2026-02-03",
|
||||
quantity: 3,
|
||||
status: "in_progress",
|
||||
client: "현대건설(주)",
|
||||
siteName: "[코레타스프] 판교 물류센터 철거현장",
|
||||
productType: "",
|
||||
pendingWorkOrderCount: 0,
|
||||
workOrders: [
|
||||
{
|
||||
id: "WO-004",
|
||||
workOrderNumber: "KD-WO-251220-01",
|
||||
process: "재단",
|
||||
quantity: 3,
|
||||
status: "completed",
|
||||
assignee: "-",
|
||||
},
|
||||
{
|
||||
id: "WO-005",
|
||||
workOrderNumber: "KD-WO-251220-02",
|
||||
process: "조립",
|
||||
quantity: 3,
|
||||
status: "in_progress",
|
||||
assignee: "-",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
import { getProductionOrderDetail } from "@/components/production/ProductionOrders/actions";
|
||||
import { createProductionOrder } from "@/components/orders/actions";
|
||||
import type {
|
||||
ProductionOrderDetail,
|
||||
ProductionStatus,
|
||||
ProductionWorkOrder,
|
||||
BomProcessGroup,
|
||||
} from "@/components/production/ProductionOrders/types";
|
||||
|
||||
// 공정 진행 현황 컴포넌트
|
||||
function ProcessProgress({ workOrders }: { workOrders: WorkOrder[] }) {
|
||||
function ProcessProgress({ workOrders }: { workOrders: ProductionWorkOrder[] }) {
|
||||
if (workOrders.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
@@ -202,7 +77,9 @@ function ProcessProgress({ workOrders }: { workOrders: WorkOrder[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
const completedCount = workOrders.filter((w) => w.status === "completed").length;
|
||||
const completedCount = workOrders.filter(
|
||||
(w) => w.status === "completed" || w.status === "shipped"
|
||||
).length;
|
||||
const totalCount = workOrders.length;
|
||||
const progressPercent = Math.round((completedCount / totalCount) * 100);
|
||||
|
||||
@@ -237,25 +114,27 @@ function ProcessProgress({ workOrders }: { workOrders: WorkOrder[] }) {
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div
|
||||
className={`flex items-center justify-center w-8 h-8 rounded-full ${
|
||||
wo.status === "completed"
|
||||
wo.status === "completed" || wo.status === "shipped"
|
||||
? "bg-green-500 text-white"
|
||||
: wo.status === "in_progress"
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-gray-100 text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{wo.status === "completed" ? (
|
||||
{wo.status === "completed" || wo.status === "shipped" ? (
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
) : (
|
||||
<Circle className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{wo.process}</span>
|
||||
<span className="text-xs text-muted-foreground">{wo.processName}</span>
|
||||
</div>
|
||||
{index < workOrders.length - 1 && (
|
||||
<div
|
||||
className={`w-12 h-0.5 mx-1 ${
|
||||
wo.status === "completed" ? "bg-green-500" : "bg-gray-200"
|
||||
wo.status === "completed" || wo.status === "shipped"
|
||||
? "bg-green-500"
|
||||
: "bg-gray-200"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
@@ -269,13 +148,13 @@ function ProcessProgress({ workOrders }: { workOrders: WorkOrder[] }) {
|
||||
}
|
||||
|
||||
// 상태 배지 헬퍼
|
||||
function getStatusBadge(status: ProductionOrderStatus) {
|
||||
const config: Record<ProductionOrderStatus, { label: string; className: string }> = {
|
||||
function getStatusBadge(status: ProductionStatus) {
|
||||
const config: Record<ProductionStatus, { label: string; className: string }> = {
|
||||
waiting: {
|
||||
label: "생산대기",
|
||||
className: "bg-yellow-100 text-yellow-700 border-yellow-200",
|
||||
},
|
||||
in_progress: {
|
||||
in_production: {
|
||||
label: "생산중",
|
||||
className: "bg-green-100 text-green-700 border-green-200",
|
||||
},
|
||||
@@ -289,22 +168,16 @@ function getStatusBadge(status: ProductionOrderStatus) {
|
||||
}
|
||||
|
||||
// 작업지시 상태 배지 헬퍼
|
||||
function getWorkOrderStatusBadge(status: WorkOrderStatus) {
|
||||
const config: Record<WorkOrderStatus, { label: string; className: string }> = {
|
||||
pending: {
|
||||
label: "대기",
|
||||
className: "bg-gray-100 text-gray-700 border-gray-200",
|
||||
},
|
||||
in_progress: {
|
||||
label: "작업중",
|
||||
className: "bg-blue-100 text-blue-700 border-blue-200",
|
||||
},
|
||||
completed: {
|
||||
label: "완료",
|
||||
className: "bg-green-100 text-green-700 border-green-200",
|
||||
},
|
||||
function getWorkOrderStatusBadge(status: string) {
|
||||
const config: Record<string, { label: string; className: string }> = {
|
||||
unassigned: { label: "미배정", className: "bg-gray-100 text-gray-700 border-gray-200" },
|
||||
pending: { label: "대기", className: "bg-gray-100 text-gray-700 border-gray-200" },
|
||||
waiting: { label: "준비중", className: "bg-yellow-100 text-yellow-700 border-yellow-200" },
|
||||
in_progress: { label: "작업중", className: "bg-blue-100 text-blue-700 border-blue-200" },
|
||||
completed: { label: "완료", className: "bg-green-100 text-green-700 border-green-200" },
|
||||
shipped: { label: "출하", className: "bg-purple-100 text-purple-700 border-purple-200" },
|
||||
};
|
||||
const c = config[status];
|
||||
const c = config[status] || { label: status, className: "bg-gray-100 text-gray-700 border-gray-200" };
|
||||
return <BadgeSm className={c.className}>{c.label}</BadgeSm>;
|
||||
}
|
||||
|
||||
@@ -318,99 +191,33 @@ function InfoItem({ label, value }: { label: string; value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// 샘플 공정 목록 (작업지시 생성 팝업에 표시용)
|
||||
const SAMPLE_PROCESSES = [
|
||||
{ id: "P1", name: "1.1 백판필름", quantity: 10 },
|
||||
{ id: "P2", name: "2. 하안마감재", quantity: 10 },
|
||||
{ id: "P3", name: "3.1 케이스", quantity: 10 },
|
||||
{ id: "P4", name: "4. 연기단자", quantity: 10 },
|
||||
{ id: "P5", name: "5. 가이드레일 하부브라켓", quantity: 10 },
|
||||
];
|
||||
|
||||
// BOM 품목 타입
|
||||
interface BomItem {
|
||||
id: string;
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
spec: string;
|
||||
lotNo: string;
|
||||
requiredQty: number;
|
||||
qty: number;
|
||||
}
|
||||
|
||||
// BOM 공정 분류 타입
|
||||
interface BomProcessGroup {
|
||||
processName: string;
|
||||
sizeSpec?: string;
|
||||
items: BomItem[];
|
||||
}
|
||||
|
||||
// BOM 품목별 공정 분류 목데이터
|
||||
const SAMPLE_BOM_PROCESS_GROUPS: BomProcessGroup[] = [
|
||||
{
|
||||
processName: "1.1 백판필름",
|
||||
sizeSpec: "[20-70]",
|
||||
items: [
|
||||
{ id: "B1", itemCode: "①", itemName: "아연판", spec: "EGI.6T", lotNo: "LOT-M-2024-001", requiredQty: 4300, qty: 8 },
|
||||
{ id: "B2", itemCode: "②", itemName: "가이드레일", spec: "EGI.6T", lotNo: "LOT-M-2024-002", requiredQty: 3000, qty: 4 },
|
||||
{ id: "B3", itemCode: "③", itemName: "C형", spec: "EGI.6ST", lotNo: "LOT-M-2024-003", requiredQty: 4300, qty: 4 },
|
||||
{ id: "B4", itemCode: "④", itemName: "D형", spec: "EGI.6T", lotNo: "LOT-M-2024-004", requiredQty: 4300, qty: 4 },
|
||||
{ id: "B5", itemCode: "⑤", itemName: "행거마감재", spec: "SUS1.2T", lotNo: "LOT-M-2024-005", requiredQty: 4300, qty: 2 },
|
||||
{ id: "B6", itemCode: "⑥", itemName: "R/RBASE", spec: "EGI.5ST", lotNo: "LOT-M-2024-006", requiredQty: 0, qty: 4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
processName: "2. 하안마감재",
|
||||
sizeSpec: "[60-40]",
|
||||
items: [
|
||||
{ id: "B7", itemCode: "①", itemName: "하안마감재", spec: "EGI.5T", lotNo: "LOT-M-2024-007", requiredQty: 3000, qty: 4 },
|
||||
{ id: "B8", itemCode: "②", itemName: "하안보강재판", spec: "EGI.5T", lotNo: "LOT-M-2024-009", requiredQty: 3000, qty: 4 },
|
||||
{ id: "B9", itemCode: "③", itemName: "하안보강멈틀", spec: "EGI.1T", lotNo: "LOT-M-2024-010", requiredQty: 0, qty: 2 },
|
||||
{ id: "B10", itemCode: "④", itemName: "행거마감재", spec: "SUS1.2T", lotNo: "LOT-M-2024-008", requiredQty: 3000, qty: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
processName: "3.1 케이스",
|
||||
sizeSpec: "[500*330]",
|
||||
items: [
|
||||
{ id: "B11", itemCode: "①", itemName: "전판", spec: "EGI.5ST", lotNo: "LOT-M-2024-011", requiredQty: 0, qty: 2 },
|
||||
{ id: "B12", itemCode: "②", itemName: "전멈틀", spec: "EGI.5T", lotNo: "LOT-M-2024-012", requiredQty: 0, qty: 4 },
|
||||
{ id: "B13", itemCode: "③⑤", itemName: "타부멈틀", spec: "", lotNo: "LOT-M-2024-013", requiredQty: 0, qty: 2 },
|
||||
{ id: "B14", itemCode: "④", itemName: "좌우판-HW", spec: "", lotNo: "LOT-M-2024-014", requiredQty: 0, qty: 2 },
|
||||
{ id: "B15", itemCode: "⑥", itemName: "상부판", spec: "", lotNo: "LOT-M-2024-015", requiredQty: 1220, qty: 5 },
|
||||
{ id: "B16", itemCode: "⑦", itemName: "측판(전산판)", spec: "", lotNo: "LOT-M-2024-016", requiredQty: 0, qty: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
processName: "4. 연기단자",
|
||||
sizeSpec: "",
|
||||
items: [
|
||||
{ id: "B17", itemCode: "-", itemName: "레일홀 (W50)", spec: "EGI.8T + 화이버 금사", lotNo: "LOT-M-2024-017", requiredQty: 0, qty: 4 },
|
||||
{ id: "B18", itemCode: "-", itemName: "카이드홀 (W60)", spec: "EGI.8T + 화이버 금사", lotNo: "LOT-M-2024-018", requiredQty: 0, qty: 4 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function ProductionOrderDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const productionOrderId = params.id as string;
|
||||
const orderId = params.id as string;
|
||||
|
||||
const [productionOrder, setProductionOrder] = useState<ProductionOrderDetail | null>(null);
|
||||
const [detail, setDetail] = useState<ProductionOrderDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isCreateWorkOrderDialogOpen, setIsCreateWorkOrderDialogOpen] = useState(false);
|
||||
const [isSuccessDialogOpen, setIsSuccessDialogOpen] = useState(false);
|
||||
const [createdWorkOrders, setCreatedWorkOrders] = useState<string[]>([]);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [bomOpen, setBomOpen] = useState(false);
|
||||
|
||||
// 데이터 로드
|
||||
const loadDetail = async () => {
|
||||
setLoading(true);
|
||||
const result = await getProductionOrderDetail(orderId);
|
||||
if (result.success && result.data) {
|
||||
setDetail(result.data);
|
||||
} else {
|
||||
setDetail(null);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
const found = SAMPLE_PRODUCTION_ORDER_DETAILS[productionOrderId];
|
||||
setProductionOrder(found || null);
|
||||
setLoading(false);
|
||||
}, 300);
|
||||
}, [productionOrderId]);
|
||||
loadDetail();
|
||||
}, [orderId]);
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/sales/order-management-sales/production-orders");
|
||||
@@ -423,19 +230,13 @@ export default function ProductionOrderDetailPage() {
|
||||
const handleConfirmCreateWorkOrder = async () => {
|
||||
setIsCreating(true);
|
||||
try {
|
||||
// API 호출 시뮬레이션
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// 생성된 작업지시서 목록 (실제로는 API 응답에서 받음)
|
||||
const workOrderCount = productionOrder?.pendingWorkOrderCount || 0;
|
||||
const created = Array.from({ length: workOrderCount }, (_, i) =>
|
||||
`KD-WO-251223-${String(i + 1).padStart(2, "0")}`
|
||||
);
|
||||
setCreatedWorkOrders(created);
|
||||
|
||||
// 확인 팝업 닫고 성공 팝업 열기
|
||||
setIsCreateWorkOrderDialogOpen(false);
|
||||
setIsSuccessDialogOpen(true);
|
||||
const result = await createProductionOrder(orderId);
|
||||
if (result.success) {
|
||||
setIsCreateWorkOrderDialogOpen(false);
|
||||
setIsSuccessDialogOpen(true);
|
||||
} else {
|
||||
toast.error(result.error || "작업지시 생성에 실패했습니다.");
|
||||
}
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
@@ -457,7 +258,7 @@ export default function ProductionOrderDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!productionOrder) {
|
||||
if (!detail) {
|
||||
return (
|
||||
<ServerErrorPage
|
||||
title="생산지시 정보를 불러올 수 없습니다"
|
||||
@@ -468,6 +269,9 @@ export default function ProductionOrderDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const hasWorkOrders = detail.workOrders.length > 0;
|
||||
const canCreateWorkOrders = detail.productionStatus === "waiting" && !hasWorkOrders;
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 헤더 */}
|
||||
@@ -476,9 +280,9 @@ export default function ProductionOrderDetailPage() {
|
||||
<div className="flex items-center gap-3">
|
||||
<span>생산지시 상세</span>
|
||||
<code className="text-sm font-mono bg-blue-50 text-blue-700 px-2 py-1 rounded">
|
||||
{productionOrder.productionOrderNumber}
|
||||
{detail.orderNumber}
|
||||
</code>
|
||||
{getStatusBadge(productionOrder.status)}
|
||||
{getStatusBadge(detail.productionStatus)}
|
||||
</div>
|
||||
}
|
||||
icon={Factory}
|
||||
@@ -488,10 +292,7 @@ export default function ProductionOrderDetailPage() {
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
{/* 작업지시 생성 버튼 - 완료 아님 + 작업지시 없음 + 생성 예정 있음 */}
|
||||
{productionOrder.status !== "completed" &&
|
||||
productionOrder.workOrders.length === 0 &&
|
||||
productionOrder.pendingWorkOrderCount > 0 && (
|
||||
{canCreateWorkOrders && (
|
||||
<Button onClick={handleCreateWorkOrder}>
|
||||
<ClipboardList className="h-4 w-4 mr-2" />
|
||||
작업지시 생성
|
||||
@@ -503,7 +304,7 @@ export default function ProductionOrderDetailPage() {
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 공정 진행 현황 */}
|
||||
<ProcessProgress workOrders={productionOrder.workOrders} />
|
||||
<ProcessProgress workOrders={detail.workOrders} />
|
||||
|
||||
{/* 기본 정보 & 거래처/현장 정보 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
@@ -514,11 +315,10 @@ export default function ProductionOrderDetailPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoItem label="생산지시번호" value={productionOrder.productionOrderNumber} />
|
||||
<InfoItem label="수주번호" value={productionOrder.orderNumber} />
|
||||
<InfoItem label="생산지시일" value={productionOrder.productionOrderDate} />
|
||||
<InfoItem label="납기일" value={productionOrder.dueDate} />
|
||||
<InfoItem label="수량" value={`${productionOrder.quantity}개`} />
|
||||
<InfoItem label="수주번호" value={detail.orderNumber} />
|
||||
<InfoItem label="생산지시일" value={detail.productionOrderedAt} />
|
||||
<InfoItem label="납기일" value={detail.deliveryDate} />
|
||||
<InfoItem label="개소" value={`${formatNumber(detail.nodeCount)}개소`} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -530,112 +330,108 @@ export default function ProductionOrderDetailPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoItem label="거래처" value={productionOrder.client} />
|
||||
<InfoItem label="현장명" value={productionOrder.siteName} />
|
||||
<InfoItem label="제품유형" value={productionOrder.productType} />
|
||||
<InfoItem label="거래처" value={detail.clientName} />
|
||||
<InfoItem label="현장명" value={detail.siteName} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* BOM 품목별 공정 분류 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">BOM 품목별 공정 분류</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 절곡 부품 전개도 정보 헤더 */}
|
||||
<p className="text-sm font-medium text-muted-foreground border-b pb-2">
|
||||
절곡 부품 전개도 정보
|
||||
</p>
|
||||
|
||||
{/* 공정별 테이블 */}
|
||||
{SAMPLE_BOM_PROCESS_GROUPS.map((group) => (
|
||||
<div key={group.processName} className="space-y-2">
|
||||
{/* 공정명 헤더 */}
|
||||
<h4 className="text-sm font-semibold">
|
||||
{group.processName}
|
||||
{group.sizeSpec && (
|
||||
<span className="ml-2 text-muted-foreground font-normal">
|
||||
{group.sizeSpec}
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
|
||||
{/* BOM 품목 테이블 */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="w-[60px] text-center">항목코드</TableHead>
|
||||
<TableHead>세부품명</TableHead>
|
||||
<TableHead>규격</TableHead>
|
||||
<TableHead>LOT NO</TableHead>
|
||||
<TableHead className="text-right">필요수량</TableHead>
|
||||
<TableHead className="text-center w-[60px]">수량</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{group.items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center font-medium">
|
||||
{item.itemCode}
|
||||
</TableCell>
|
||||
<TableCell>{item.itemName}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{item.spec || "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{item.lotNo}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{item.requiredQty > 0 ? formatNumber(item.requiredQty) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.qty}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{/* BOM 품목별 공정 분류 (접이식) */}
|
||||
{detail.bomProcessGroups.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader
|
||||
className="cursor-pointer select-none"
|
||||
onClick={() => setBomOpen((prev) => !prev)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">
|
||||
BOM 품목별 공정 분류
|
||||
<span className="ml-2 text-sm font-normal text-muted-foreground">
|
||||
({detail.bomProcessGroups.length}개 공정)
|
||||
</span>
|
||||
</CardTitle>
|
||||
<ChevronDown
|
||||
className={`h-5 w-5 text-muted-foreground transition-transform ${
|
||||
bomOpen ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</CardHeader>
|
||||
{bomOpen && (
|
||||
<CardContent className="space-y-6 pt-0">
|
||||
{detail.bomProcessGroups.map((group) => (
|
||||
<div key={group.processName} className="space-y-2">
|
||||
<h4 className="text-sm font-semibold flex items-center gap-2">
|
||||
<Badge variant="outline">{group.processName}</Badge>
|
||||
<span className="text-muted-foreground font-normal text-xs">
|
||||
{group.items.length}건
|
||||
</span>
|
||||
</h4>
|
||||
|
||||
{/* 합계 정보 */}
|
||||
<div className="flex justify-between items-center pt-4 border-t text-sm">
|
||||
<span className="text-muted-foreground">총 부품 종류: 18개</span>
|
||||
<span className="text-muted-foreground">총 중량: 25.8 kg</span>
|
||||
<span className="text-muted-foreground">비고: VT칼 작업 완료 후 절곡 진행</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead>품목코드</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead>규격</TableHead>
|
||||
<TableHead className="text-center">단위</TableHead>
|
||||
<TableHead className="text-right">수량</TableHead>
|
||||
<TableHead className="text-right">단가</TableHead>
|
||||
<TableHead className="text-right">금액</TableHead>
|
||||
<TableHead>개소</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{group.items.map((item, idx) => (
|
||||
<TableRow key={`${item.id}-${idx}`}>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{item.itemCode}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>{item.itemName}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{item.spec || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.unit || "-"}</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(item.quantity)}</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(item.unitPrice)}</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(item.totalPrice)}</TableCell>
|
||||
<TableCell className="text-muted-foreground text-xs">{item.nodeName || "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 작업지시서 목록 */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">작업지시서 목록</CardTitle>
|
||||
{/* 버튼 조건: 완료 아님 + 작업지시 없음 + 생성 예정 있음 */}
|
||||
{productionOrder.status !== "completed" &&
|
||||
productionOrder.workOrders.length === 0 &&
|
||||
productionOrder.pendingWorkOrderCount > 0 && (
|
||||
{canCreateWorkOrders && (
|
||||
<Button onClick={handleCreateWorkOrder}>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
{productionOrder.pendingWorkOrderCount > 1
|
||||
? "작업지시 일괄생성"
|
||||
: "작업지시 생성"}
|
||||
작업지시 생성
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{productionOrder.workOrders.length === 0 ? (
|
||||
{!hasWorkOrders ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ClipboardList className="h-12 w-12 text-gray-300" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
아직 작업지시서가 생성되지 않았습니다.
|
||||
</p>
|
||||
{productionOrder.pendingWorkOrderCount > 0 && (
|
||||
{canCreateWorkOrders && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
위 버튼을 클릭하여 BOM 기반 작업지시서를 자동 생성하세요.
|
||||
</p>
|
||||
@@ -649,23 +445,23 @@ export default function ProductionOrderDetailPage() {
|
||||
<TableRow>
|
||||
<TableHead>작업지시번호</TableHead>
|
||||
<TableHead>공정</TableHead>
|
||||
<TableHead className="text-center">수량</TableHead>
|
||||
<TableHead className="text-center">개소</TableHead>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead>담당자</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{productionOrder.workOrders.map((wo) => (
|
||||
{detail.workOrders.map((wo) => (
|
||||
<TableRow key={wo.id}>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{wo.workOrderNumber}
|
||||
{wo.workOrderNo}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>{wo.process}</TableCell>
|
||||
<TableCell className="text-center">{wo.quantity}개</TableCell>
|
||||
<TableCell>{wo.processName}</TableCell>
|
||||
<TableCell className="text-center">{wo.quantity}개소</TableCell>
|
||||
<TableCell>{getWorkOrderStatusBadge(wo.status)}</TableCell>
|
||||
<TableCell>{wo.assignee}</TableCell>
|
||||
<TableCell>{wo.assignees.length > 0 ? wo.assignees.join(", ") : "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -676,7 +472,7 @@ export default function ProductionOrderDetailPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 팝업1: 작업지시 생성 확인 다이얼로그 */}
|
||||
{/* 작업지시 생성 확인 다이얼로그 */}
|
||||
<ConfirmDialog
|
||||
open={isCreateWorkOrderDialogOpen}
|
||||
onOpenChange={setIsCreateWorkOrderDialogOpen}
|
||||
@@ -685,19 +481,10 @@ export default function ProductionOrderDetailPage() {
|
||||
description={
|
||||
<div className="space-y-4 pt-2">
|
||||
<p className="font-medium text-foreground">
|
||||
다음 공정에 대한 작업지시서가 생성됩니다:
|
||||
이 수주에 대한 작업지시서를 자동 생성합니다.
|
||||
</p>
|
||||
{productionOrder?.pendingWorkOrderCount && productionOrder.pendingWorkOrderCount > 0 && (
|
||||
<ul className="space-y-1 text-sm text-muted-foreground pl-4">
|
||||
{SAMPLE_PROCESSES.slice(0, productionOrder.pendingWorkOrderCount).map((process) => (
|
||||
<li key={process.id} className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-gray-400 rounded-full" />
|
||||
{process.name} ({process.quantity}개)
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="text-muted-foreground">
|
||||
BOM 기반으로 공정별 작업지시서가 생성됩니다.
|
||||
생성된 작업지시서는 생산팀에서 확인하고 작업을 진행할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
@@ -706,7 +493,7 @@ export default function ProductionOrderDetailPage() {
|
||||
loading={isCreating}
|
||||
/>
|
||||
|
||||
{/* 팝업2: 작업지시 생성 성공 다이얼로그 */}
|
||||
{/* 작업지시 생성 성공 다이얼로그 */}
|
||||
<AlertDialog open={isSuccessDialogOpen} onOpenChange={setIsSuccessDialogOpen}>
|
||||
<AlertDialogContent className="max-w-md">
|
||||
<AlertDialogHeader>
|
||||
@@ -716,24 +503,9 @@ export default function ProductionOrderDetailPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||
<span className="font-medium text-foreground">
|
||||
{createdWorkOrders.length}개의 작업지시서가 공정별로 자동 생성되었습니다.
|
||||
작업지시서가 자동 생성되었습니다.
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground mb-2">생성된 작업지시서:</p>
|
||||
{createdWorkOrders.length > 0 ? (
|
||||
<ul className="space-y-1 text-sm text-muted-foreground pl-4">
|
||||
{createdWorkOrders.map((wo, idx) => (
|
||||
<li key={wo} className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-green-500 rounded-full" />
|
||||
{wo}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">-</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
작업지시 관리 페이지로 이동합니다.
|
||||
</p>
|
||||
@@ -749,4 +521,4 @@ export default function ProductionOrderDetailPage() {
|
||||
</AlertDialog>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,24 +4,20 @@
|
||||
* 생산지시 목록 페이지
|
||||
*
|
||||
* - 수주관리 > 생산지시 보기에서 접근
|
||||
* - 진행 단계 바
|
||||
* - 진행 단계 바 (Order 상태 기반 동적)
|
||||
* - 필터 탭: 전체, 생산대기, 생산중, 생산완료 (TabChip 사용)
|
||||
* - IntegratedListTemplateV2 템플릿 적용
|
||||
* - 서버사이드 페이지네이션
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { DeleteConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
@@ -29,7 +25,6 @@ import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
UniversalListPage,
|
||||
@@ -39,136 +34,63 @@ import {
|
||||
} from "@/components/templates/UniversalListPage";
|
||||
import { BadgeSm } from "@/components/atoms/BadgeSm";
|
||||
import { ListMobileCard, InfoField } from "@/components/organisms/MobileCard";
|
||||
|
||||
// 생산지시 상태 타입
|
||||
type ProductionOrderStatus =
|
||||
| "waiting" // 생산대기
|
||||
| "in_progress" // 생산중
|
||||
| "completed"; // 생산완료
|
||||
|
||||
// 생산지시 데이터 타입
|
||||
interface ProductionOrder {
|
||||
id: string;
|
||||
productionOrderNumber: string; // PO-KD-TS-XXXXXX-XX
|
||||
orderNumber: string; // KD-TS-XXXXXX-XX
|
||||
siteName: string;
|
||||
client: string;
|
||||
quantity: number;
|
||||
dueDate: string;
|
||||
productionOrderDate: string;
|
||||
status: ProductionOrderStatus;
|
||||
workOrderCount: number;
|
||||
}
|
||||
|
||||
// 샘플 생산지시 데이터
|
||||
const SAMPLE_PRODUCTION_ORDERS: ProductionOrder[] = [
|
||||
{
|
||||
id: "PO-001",
|
||||
productionOrderNumber: "PO-KD-TS-251217-07",
|
||||
orderNumber: "KD-TS-251217-07",
|
||||
siteName: "씨밋 광교 센트럴시티",
|
||||
client: "호반건설(주)",
|
||||
quantity: 2,
|
||||
dueDate: "2026-02-15",
|
||||
productionOrderDate: "2025-12-22",
|
||||
status: "waiting",
|
||||
workOrderCount: 3,
|
||||
},
|
||||
{
|
||||
id: "PO-002",
|
||||
productionOrderNumber: "PO-KD-TS-251217-09",
|
||||
orderNumber: "KD-TS-251217-09",
|
||||
siteName: "데시앙 동탄 파크뷰",
|
||||
client: "태영건설(주)",
|
||||
quantity: 10,
|
||||
dueDate: "2026-02-10",
|
||||
productionOrderDate: "2025-12-22",
|
||||
status: "waiting",
|
||||
workOrderCount: 0,
|
||||
},
|
||||
{
|
||||
id: "PO-003",
|
||||
productionOrderNumber: "PO-KD-TS-251217-06",
|
||||
orderNumber: "KD-TS-251217-06",
|
||||
siteName: "예술 검실 푸르지오",
|
||||
client: "롯데건설(주)",
|
||||
quantity: 1,
|
||||
dueDate: "2026-02-10",
|
||||
productionOrderDate: "2025-12-22",
|
||||
status: "waiting",
|
||||
workOrderCount: 0,
|
||||
},
|
||||
{
|
||||
id: "PO-004",
|
||||
productionOrderNumber: "PO-KD-BD-251220-35",
|
||||
orderNumber: "KD-BD-251220-35",
|
||||
siteName: "[코레타스프] 판교 물류센터 철거현장",
|
||||
client: "현대건설(주)",
|
||||
quantity: 3,
|
||||
dueDate: "2026-02-03",
|
||||
productionOrderDate: "2025-12-20",
|
||||
status: "in_progress",
|
||||
workOrderCount: 2,
|
||||
},
|
||||
{
|
||||
id: "PO-005",
|
||||
productionOrderNumber: "PO-KD-BD-251219-34",
|
||||
orderNumber: "KD-BD-251219-34",
|
||||
siteName: "[코레타스프1] 김포 6차 필라테스장",
|
||||
client: "신성플랜(주)",
|
||||
quantity: 2,
|
||||
dueDate: "2026-01-15",
|
||||
productionOrderDate: "2025-12-19",
|
||||
status: "in_progress",
|
||||
workOrderCount: 3,
|
||||
},
|
||||
{
|
||||
id: "PO-006",
|
||||
productionOrderNumber: "PO-KD-TS-250401-29",
|
||||
orderNumber: "KD-TS-250401-29",
|
||||
siteName: "포레나 전주",
|
||||
client: "한화건설(주)",
|
||||
quantity: 2,
|
||||
dueDate: "2025-05-16",
|
||||
productionOrderDate: "2025-04-01",
|
||||
status: "completed",
|
||||
workOrderCount: 3,
|
||||
},
|
||||
{
|
||||
id: "PO-007",
|
||||
productionOrderNumber: "PO-KD-BD-250331-28",
|
||||
orderNumber: "KD-BD-250331-28",
|
||||
siteName: "포레나 수원",
|
||||
client: "포레나건설(주)",
|
||||
quantity: 4,
|
||||
dueDate: "2025-05-15",
|
||||
productionOrderDate: "2025-03-31",
|
||||
status: "completed",
|
||||
workOrderCount: 3,
|
||||
},
|
||||
{
|
||||
id: "PO-008",
|
||||
productionOrderNumber: "PO-KD-TS-250314-23",
|
||||
orderNumber: "KD-TS-250314-23",
|
||||
siteName: "자이 흑산파크",
|
||||
client: "GS건설(주)",
|
||||
quantity: 3,
|
||||
dueDate: "2025-04-28",
|
||||
productionOrderDate: "2025-03-14",
|
||||
status: "completed",
|
||||
workOrderCount: 3,
|
||||
},
|
||||
];
|
||||
import {
|
||||
getProductionOrders,
|
||||
getProductionOrderStats,
|
||||
} from "@/components/production/ProductionOrders/actions";
|
||||
import type {
|
||||
ProductionOrder,
|
||||
ProductionStatus,
|
||||
ProductionOrderStats,
|
||||
} from "@/components/production/ProductionOrders/types";
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
|
||||
// 진행 단계 컴포넌트
|
||||
function ProgressSteps() {
|
||||
const steps = [
|
||||
{ label: "수주확정", active: true, completed: true },
|
||||
{ label: "생산지시", active: true, completed: false },
|
||||
{ label: "작업지시", active: false, completed: false },
|
||||
{ label: "생산", active: false, completed: false },
|
||||
{ label: "검사출하", active: false, completed: false },
|
||||
];
|
||||
function ProgressSteps({ statusCode }: { statusCode?: string }) {
|
||||
const getSteps = () => {
|
||||
// 기본: 생산지시 목록에 있으면 수주확정, 생산지시는 이미 완료
|
||||
const steps = [
|
||||
{ label: "수주확정", completed: true, active: false },
|
||||
{ label: "생산지시", completed: true, active: false },
|
||||
{ label: "작업지시", completed: false, active: false },
|
||||
{ label: "생산", completed: false, active: false },
|
||||
{ label: "검사출하", completed: false, active: false },
|
||||
];
|
||||
|
||||
if (!statusCode) return steps;
|
||||
|
||||
// IN_PROGRESS = 생산대기 (작업지시 배정 진행 중)
|
||||
if (statusCode === "IN_PROGRESS") {
|
||||
steps[2].active = true;
|
||||
}
|
||||
// IN_PRODUCTION = 생산중
|
||||
if (statusCode === "IN_PRODUCTION") {
|
||||
steps[2].completed = true;
|
||||
steps[3].active = true;
|
||||
}
|
||||
// PRODUCED = 생산완료
|
||||
if (statusCode === "PRODUCED") {
|
||||
steps[2].completed = true;
|
||||
steps[3].completed = true;
|
||||
steps[4].active = true;
|
||||
}
|
||||
// SHIPPING = 출하중
|
||||
if (statusCode === "SHIPPING") {
|
||||
steps[2].completed = true;
|
||||
steps[3].completed = true;
|
||||
steps[4].active = true;
|
||||
}
|
||||
// SHIPPED = 출하완료
|
||||
if (statusCode === "SHIPPED") {
|
||||
steps[2].completed = true;
|
||||
steps[3].completed = true;
|
||||
steps[4].completed = true;
|
||||
}
|
||||
|
||||
return steps;
|
||||
};
|
||||
|
||||
const steps = getSteps();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 py-4">
|
||||
@@ -214,16 +136,16 @@ function ProgressSteps() {
|
||||
}
|
||||
|
||||
// 상태 배지 헬퍼
|
||||
function getStatusBadge(status: ProductionOrderStatus) {
|
||||
function getStatusBadge(status: ProductionStatus) {
|
||||
const config: Record<
|
||||
ProductionOrderStatus,
|
||||
ProductionStatus,
|
||||
{ label: string; className: string }
|
||||
> = {
|
||||
waiting: {
|
||||
label: "생산대기",
|
||||
className: "bg-yellow-100 text-yellow-700 border-yellow-200",
|
||||
},
|
||||
in_progress: {
|
||||
in_production: {
|
||||
label: "생산중",
|
||||
className: "bg-green-100 text-green-700 border-green-200",
|
||||
},
|
||||
@@ -239,13 +161,12 @@ function getStatusBadge(status: ProductionOrderStatus) {
|
||||
// 테이블 컬럼 정의
|
||||
const TABLE_COLUMNS: TableColumn[] = [
|
||||
{ key: "no", label: "번호", className: "w-[60px] text-center" },
|
||||
{ key: "productionOrderNumber", label: "생산지시번호", className: "min-w-[150px]" },
|
||||
{ key: "orderNumber", label: "수주번호", className: "min-w-[140px]" },
|
||||
{ key: "orderNumber", label: "수주번호", className: "min-w-[150px]" },
|
||||
{ key: "siteName", label: "현장명", className: "min-w-[180px]" },
|
||||
{ key: "client", label: "거래처", className: "min-w-[120px]" },
|
||||
{ key: "quantity", label: "수량", className: "w-[80px] text-center" },
|
||||
{ key: "dueDate", label: "납기", className: "w-[110px]" },
|
||||
{ key: "productionOrderDate", label: "생산지시일", className: "w-[110px]" },
|
||||
{ key: "clientName", label: "거래처", className: "min-w-[120px]" },
|
||||
{ key: "nodeCount", label: "개소", className: "w-[80px] text-center" },
|
||||
{ key: "deliveryDate", label: "납기", className: "w-[110px]" },
|
||||
{ key: "productionOrderedAt", label: "생산지시일", className: "w-[110px]" },
|
||||
{ key: "status", label: "상태", className: "w-[100px]" },
|
||||
{ key: "workOrderCount", label: "작업지시", className: "w-[80px] text-center" },
|
||||
{ key: "actions", label: "작업", className: "w-[100px] text-center" },
|
||||
@@ -253,65 +174,21 @@ const TABLE_COLUMNS: TableColumn[] = [
|
||||
|
||||
export default function ProductionOrdersListPage() {
|
||||
const router = useRouter();
|
||||
const [orders, setOrders] = useState<ProductionOrder[]>(SAMPLE_PRODUCTION_ORDERS);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [activeTab, setActiveTab] = useState("all");
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 삭제 확인 다이얼로그 상태
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null); // 개별 삭제 시 사용
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredData = orders.filter((item) => {
|
||||
// 탭 필터
|
||||
if (activeTab !== "all") {
|
||||
const statusMap: Record<string, ProductionOrderStatus> = {
|
||||
waiting: "waiting",
|
||||
in_progress: "in_progress",
|
||||
completed: "completed",
|
||||
};
|
||||
if (item.status !== statusMap[activeTab]) return false;
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
return (
|
||||
item.productionOrderNumber.toLowerCase().includes(term) ||
|
||||
item.orderNumber.toLowerCase().includes(term) ||
|
||||
item.siteName.toLowerCase().includes(term) ||
|
||||
item.client.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
const [stats, setStats] = useState<ProductionOrderStats>({
|
||||
total: 0,
|
||||
waiting: 0,
|
||||
in_production: 0,
|
||||
completed: 0,
|
||||
});
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(filteredData.length / itemsPerPage);
|
||||
const paginatedData = filteredData.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
);
|
||||
|
||||
// 탭별 건수
|
||||
const tabCounts = {
|
||||
all: orders.length,
|
||||
waiting: orders.filter((i) => i.status === "waiting").length,
|
||||
in_progress: orders.filter((i) => i.status === "in_progress").length,
|
||||
completed: orders.filter((i) => i.status === "completed").length,
|
||||
};
|
||||
|
||||
// 탭 옵션
|
||||
const tabs: TabOption[] = [
|
||||
{ value: "all", label: "전체", count: tabCounts.all },
|
||||
{ value: "waiting", label: "생산대기", count: tabCounts.waiting, color: "yellow" },
|
||||
{ value: "in_progress", label: "생산중", count: tabCounts.in_progress, color: "green" },
|
||||
{ value: "completed", label: "생산완료", count: tabCounts.completed, color: "gray" },
|
||||
];
|
||||
// 통계 로드
|
||||
useEffect(() => {
|
||||
getProductionOrderStats().then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setStats(result.data);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/sales/order-management-sales");
|
||||
@@ -325,57 +202,13 @@ export default function ProductionOrdersListPage() {
|
||||
router.push(`/sales/order-management-sales/production-orders/${item.id}?mode=view`);
|
||||
};
|
||||
|
||||
// 개별 삭제 다이얼로그 열기
|
||||
const handleDelete = (item: ProductionOrder) => {
|
||||
setDeleteTargetId(item.id);
|
||||
setShowDeleteDialog(true);
|
||||
};
|
||||
|
||||
// 체크박스 선택
|
||||
const toggleSelection = (id: string) => {
|
||||
const newSelection = new Set(selectedItems);
|
||||
if (newSelection.has(id)) {
|
||||
newSelection.delete(id);
|
||||
} else {
|
||||
newSelection.add(id);
|
||||
}
|
||||
setSelectedItems(newSelection);
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedItems.size === paginatedData.length) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedData.map((item) => item.id)));
|
||||
}
|
||||
};
|
||||
|
||||
// 일괄 삭제 다이얼로그 열기
|
||||
const handleBulkDelete = () => {
|
||||
if (selectedItems.size > 0) {
|
||||
setDeleteTargetId(null); // 일괄 삭제
|
||||
setShowDeleteDialog(true);
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제 개수 계산 (개별 삭제 시 1, 일괄 삭제 시 selectedItems.size)
|
||||
const deleteCount = deleteTargetId ? 1 : selectedItems.size;
|
||||
|
||||
// 실제 삭제 실행
|
||||
const handleConfirmDelete = () => {
|
||||
if (deleteTargetId) {
|
||||
// 개별 삭제
|
||||
setOrders(orders.filter((o) => o.id !== deleteTargetId));
|
||||
setSelectedItems(new Set([...selectedItems].filter(id => id !== deleteTargetId)));
|
||||
} else {
|
||||
// 일괄 삭제
|
||||
const selectedIds = Array.from(selectedItems);
|
||||
setOrders(orders.filter((o) => !selectedIds.includes(o.id)));
|
||||
setSelectedItems(new Set());
|
||||
}
|
||||
setShowDeleteDialog(false);
|
||||
setDeleteTargetId(null);
|
||||
};
|
||||
// 탭 옵션 (통계 기반 동적 카운트)
|
||||
const tabs: TabOption[] = [
|
||||
{ value: "all", label: "전체", count: stats.total },
|
||||
{ value: "waiting", label: "생산대기", count: stats.waiting, color: "yellow" },
|
||||
{ value: "in_production", label: "생산중", count: stats.in_production, color: "green" },
|
||||
{ value: "completed", label: "생산완료", count: stats.completed, color: "gray" },
|
||||
];
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = (
|
||||
@@ -402,22 +235,17 @@ export default function ProductionOrdersListPage() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-blue-50 text-blue-700 px-1.5 py-0.5 rounded">
|
||||
{item.productionOrderNumber}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{item.orderNumber}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate">
|
||||
{item.siteName}
|
||||
</TableCell>
|
||||
<TableCell>{item.client}</TableCell>
|
||||
<TableCell className="text-center">{item.quantity}개</TableCell>
|
||||
<TableCell>{item.dueDate}</TableCell>
|
||||
<TableCell>{item.productionOrderDate}</TableCell>
|
||||
<TableCell>{getStatusBadge(item.status)}</TableCell>
|
||||
<TableCell>{item.clientName}</TableCell>
|
||||
<TableCell className="text-center">{formatNumber(item.nodeCount)}개소</TableCell>
|
||||
<TableCell>{item.deliveryDate}</TableCell>
|
||||
<TableCell>{item.productionOrderedAt}</TableCell>
|
||||
<TableCell>{getStatusBadge(item.productionStatus)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{item.workOrderCount > 0 ? (
|
||||
<Badge variant="outline">{item.workOrderCount}건</Badge>
|
||||
@@ -431,9 +259,6 @@ export default function ProductionOrdersListPage() {
|
||||
<Button variant="ghost" size="sm" onClick={() => handleView(item)}>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDelete(item)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
@@ -463,19 +288,19 @@ export default function ProductionOrdersListPage() {
|
||||
variant="outline"
|
||||
className="bg-blue-50 text-blue-700 font-mono text-xs"
|
||||
>
|
||||
{item.productionOrderNumber}
|
||||
{item.orderNumber}
|
||||
</Badge>
|
||||
{getStatusBadge(item.status)}
|
||||
{getStatusBadge(item.productionStatus)}
|
||||
</>
|
||||
}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="수주번호" value={item.orderNumber} />
|
||||
<InfoField label="현장명" value={item.siteName} />
|
||||
<InfoField label="거래처" value={item.client} />
|
||||
<InfoField label="수량" value={`${item.quantity}개`} />
|
||||
<InfoField label="납기" value={item.dueDate} />
|
||||
<InfoField label="생산지시일" value={item.productionOrderDate} />
|
||||
<InfoField label="거래처" value={item.clientName} />
|
||||
<InfoField label="개소" value={`${formatNumber(item.nodeCount)}개소`} />
|
||||
<InfoField label="납기" value={item.deliveryDate} />
|
||||
<InfoField label="생산지시일" value={item.productionOrderedAt} />
|
||||
<InfoField
|
||||
label="작업지시"
|
||||
value={item.workOrderCount > 0 ? `${item.workOrderCount}건` : "-"}
|
||||
@@ -497,18 +322,6 @@ export default function ProductionOrdersListPage() {
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
상세
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(item);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
@@ -516,6 +329,43 @@ export default function ProductionOrdersListPage() {
|
||||
);
|
||||
};
|
||||
|
||||
// getList API 호출
|
||||
const getList = useCallback(async (params?: { page?: number; pageSize?: number; search?: string; tab?: string }) => {
|
||||
const productionStatus = params?.tab && params.tab !== "all"
|
||||
? (params.tab as ProductionStatus)
|
||||
: undefined;
|
||||
|
||||
const result = await getProductionOrders({
|
||||
search: params?.search,
|
||||
productionStatus,
|
||||
page: params?.page,
|
||||
perPage: params?.pageSize,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// 통계 새로고침
|
||||
getProductionOrderStats().then((statsResult) => {
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
totalCount: result.pagination?.total || 0,
|
||||
totalPages: result.pagination?.lastPage || 1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
totalCount: 0,
|
||||
error: result.error,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ===== UniversalListPage 설정 =====
|
||||
const productionOrderConfig: UniversalListConfig<ProductionOrder> = {
|
||||
title: "생산지시 목록",
|
||||
@@ -525,43 +375,19 @@ export default function ProductionOrdersListPage() {
|
||||
idField: "id",
|
||||
|
||||
actions: {
|
||||
getList: async () => ({
|
||||
success: true,
|
||||
data: orders,
|
||||
totalCount: orders.length,
|
||||
}),
|
||||
getList,
|
||||
},
|
||||
|
||||
columns: TABLE_COLUMNS,
|
||||
|
||||
tabs: tabs,
|
||||
defaultTab: activeTab,
|
||||
defaultTab: "all",
|
||||
|
||||
searchPlaceholder: "생산지시번호, 수주번호, 현장명 검색...",
|
||||
searchPlaceholder: "수주번호, 현장명, 거래처 검색...",
|
||||
|
||||
itemsPerPage,
|
||||
itemsPerPage: 20,
|
||||
|
||||
clientSideFiltering: true,
|
||||
|
||||
searchFilter: (item, searchValue) => {
|
||||
const term = searchValue.toLowerCase();
|
||||
return (
|
||||
item.productionOrderNumber.toLowerCase().includes(term) ||
|
||||
item.orderNumber.toLowerCase().includes(term) ||
|
||||
item.siteName.toLowerCase().includes(term) ||
|
||||
item.client.toLowerCase().includes(term)
|
||||
);
|
||||
},
|
||||
|
||||
tabFilter: (item, tabValue) => {
|
||||
if (tabValue === "all") return true;
|
||||
const statusMap: Record<string, ProductionOrderStatus> = {
|
||||
waiting: "waiting",
|
||||
in_progress: "in_progress",
|
||||
completed: "completed",
|
||||
};
|
||||
return item.status === statusMap[tabValue];
|
||||
},
|
||||
clientSideFiltering: false,
|
||||
|
||||
headerActions: () => (
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
@@ -580,50 +406,11 @@ export default function ProductionOrdersListPage() {
|
||||
|
||||
renderTableRow,
|
||||
renderMobileCard,
|
||||
|
||||
renderDialogs: () => (
|
||||
<DeleteConfirmDialog
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="삭제 확인"
|
||||
description={
|
||||
<>
|
||||
선택한 <strong>{deleteCount}개</strong>의 항목을 삭제하시겠습니까?
|
||||
<br />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
삭제된 항목은 복구할 수 없습니다. 관련된 데이터도 함께 삭제될 수 있습니다.
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<UniversalListPage<ProductionOrder>
|
||||
config={productionOrderConfig}
|
||||
initialData={orders}
|
||||
initialTotalCount={orders.length}
|
||||
externalSelection={{
|
||||
selectedItems,
|
||||
onToggleSelection: toggleSelection,
|
||||
onToggleSelectAll: toggleSelectAll,
|
||||
setSelectedItems,
|
||||
getItemId: (item: ProductionOrder) => item.id,
|
||||
}}
|
||||
onTabChange={(value: string) => {
|
||||
setActiveTab(value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
onSearchChange={setSearchTerm}
|
||||
externalPagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: filteredData.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
115
src/components/production/ProductionOrders/actions.ts
Normal file
115
src/components/production/ProductionOrders/actions.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
'use server';
|
||||
|
||||
import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
|
||||
import { executeServerAction } from '@/lib/api/execute-server-action';
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
import type {
|
||||
ApiProductionOrder,
|
||||
ApiProductionOrderDetail,
|
||||
ProductionOrder,
|
||||
ProductionOrderDetail,
|
||||
ProductionOrderStats,
|
||||
ProductionOrderListParams,
|
||||
ProductionStatus,
|
||||
} from './types';
|
||||
|
||||
// ===== 변환 함수 =====
|
||||
|
||||
function formatDateOnly(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return '-';
|
||||
// ISO "2026-02-21T18:12:31.000000Z" 또는 "2026-02-22 03:12:31" 형식 모두 지원
|
||||
return dateStr.split(/[T ]/)[0];
|
||||
}
|
||||
|
||||
function transformApiToFrontend(data: ApiProductionOrder): ProductionOrder {
|
||||
return {
|
||||
id: String(data.id),
|
||||
orderNumber: data.order_no,
|
||||
siteName: data.site_name || '',
|
||||
clientName: data.client_name || data.client?.name || '',
|
||||
quantity: parseFloat(String(data.quantity)) || 0,
|
||||
nodeCount: data.node_count || data.nodes_count || 0,
|
||||
deliveryDate: data.delivery_date || '',
|
||||
productionOrderedAt: formatDateOnly(data.production_ordered_at),
|
||||
productionStatus: data.production_status,
|
||||
workOrderCount: data.work_orders_count,
|
||||
workOrderProgress: {
|
||||
total: data.work_order_progress?.total || 0,
|
||||
completed: data.work_order_progress?.completed || 0,
|
||||
inProgress: data.work_order_progress?.in_progress || 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function transformDetailApiToFrontend(data: ApiProductionOrderDetail): ProductionOrderDetail {
|
||||
const order = transformApiToFrontend(data.order);
|
||||
return {
|
||||
...order,
|
||||
productionOrderedAt: formatDateOnly(data.production_ordered_at) || order.productionOrderedAt,
|
||||
productionStatus: data.production_status || order.productionStatus,
|
||||
nodeCount: data.node_count || order.nodeCount,
|
||||
workOrderProgress: {
|
||||
total: data.work_order_progress?.total || 0,
|
||||
completed: data.work_order_progress?.completed || 0,
|
||||
inProgress: data.work_order_progress?.in_progress || 0,
|
||||
},
|
||||
workOrders: (data.work_orders || []).map((wo) => ({
|
||||
id: wo.id,
|
||||
workOrderNo: wo.work_order_no,
|
||||
processName: wo.process_name,
|
||||
quantity: wo.quantity,
|
||||
status: wo.status,
|
||||
assignees: wo.assignees || [],
|
||||
})),
|
||||
bomProcessGroups: (data.bom_process_groups || []).map((group) => ({
|
||||
processName: group.process_name,
|
||||
sizeSpec: group.size_spec,
|
||||
items: (group.items || []).map((item) => ({
|
||||
id: item.id,
|
||||
itemCode: item.item_code,
|
||||
itemName: item.item_name,
|
||||
spec: item.spec || '',
|
||||
unit: item.unit || '',
|
||||
quantity: item.quantity ?? 0,
|
||||
unitPrice: item.unit_price ?? 0,
|
||||
totalPrice: item.total_price ?? 0,
|
||||
nodeName: item.node_name || '',
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ===== Server Actions =====
|
||||
|
||||
// 목록 조회
|
||||
export async function getProductionOrders(params: ProductionOrderListParams) {
|
||||
return executePaginatedAction<ApiProductionOrder, ProductionOrder>({
|
||||
url: buildApiUrl('/api/v1/production-orders', {
|
||||
search: params.search,
|
||||
production_status: params.productionStatus,
|
||||
sort_by: params.sortBy,
|
||||
sort_dir: params.sortDir,
|
||||
page: params.page,
|
||||
per_page: params.perPage,
|
||||
}),
|
||||
transform: transformApiToFrontend,
|
||||
errorMessage: '생산지시 목록 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// 상태별 통계
|
||||
export async function getProductionOrderStats() {
|
||||
return executeServerAction<ProductionOrderStats>({
|
||||
url: buildApiUrl('/api/v1/production-orders/stats'),
|
||||
errorMessage: '생산지시 통계 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// 상세 조회
|
||||
export async function getProductionOrderDetail(orderId: string) {
|
||||
return executeServerAction<ApiProductionOrderDetail, ProductionOrderDetail>({
|
||||
url: buildApiUrl(`/api/v1/production-orders/${orderId}`),
|
||||
transform: transformDetailApiToFrontend,
|
||||
errorMessage: '생산지시 상세 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
141
src/components/production/ProductionOrders/types.ts
Normal file
141
src/components/production/ProductionOrders/types.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
// 생산지시 상태 (프론트 탭용)
|
||||
export type ProductionStatus = 'waiting' | 'in_production' | 'completed';
|
||||
|
||||
// API 응답 타입 (snake_case)
|
||||
export interface ApiProductionOrder {
|
||||
id: number;
|
||||
order_no: string;
|
||||
site_name: string;
|
||||
client_name: string;
|
||||
quantity: number;
|
||||
node_count: number;
|
||||
delivery_date: string | null;
|
||||
status_code: string;
|
||||
production_ordered_at: string | null;
|
||||
production_status: ProductionStatus;
|
||||
work_orders_count: number;
|
||||
nodes_count: number;
|
||||
work_order_progress: {
|
||||
total: number;
|
||||
completed: number;
|
||||
in_progress: number;
|
||||
};
|
||||
client?: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
// 프론트 타입 (camelCase)
|
||||
export interface ProductionOrder {
|
||||
id: string;
|
||||
orderNumber: string;
|
||||
siteName: string;
|
||||
clientName: string;
|
||||
quantity: number;
|
||||
nodeCount: number;
|
||||
deliveryDate: string;
|
||||
productionOrderedAt: string;
|
||||
productionStatus: ProductionStatus;
|
||||
workOrderCount: number;
|
||||
workOrderProgress: {
|
||||
total: number;
|
||||
completed: number;
|
||||
inProgress: number;
|
||||
};
|
||||
}
|
||||
|
||||
// 생산지시 통계
|
||||
export interface ProductionOrderStats {
|
||||
total: number;
|
||||
waiting: number;
|
||||
in_production: number;
|
||||
completed: number;
|
||||
}
|
||||
|
||||
// 생산지시 상세 API 응답
|
||||
export interface ApiProductionOrderDetail {
|
||||
order: ApiProductionOrder;
|
||||
production_ordered_at: string | null;
|
||||
production_status: ProductionStatus;
|
||||
node_count: number;
|
||||
work_order_progress: {
|
||||
total: number;
|
||||
completed: number;
|
||||
in_progress: number;
|
||||
};
|
||||
work_orders: ApiProductionWorkOrder[];
|
||||
bom_process_groups: ApiBomProcessGroup[];
|
||||
}
|
||||
|
||||
// 상세 내 작업지시 정보
|
||||
export interface ApiProductionWorkOrder {
|
||||
id: number;
|
||||
work_order_no: string;
|
||||
process_name: string;
|
||||
quantity: number;
|
||||
status: string;
|
||||
assignees: string[];
|
||||
}
|
||||
|
||||
// BOM 공정 분류
|
||||
export interface ApiBomProcessGroup {
|
||||
process_name: string;
|
||||
size_spec?: string;
|
||||
items: ApiBomItem[];
|
||||
}
|
||||
|
||||
export interface ApiBomItem {
|
||||
id: number | null;
|
||||
item_code: string;
|
||||
item_name: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
total_price: number;
|
||||
node_name: string;
|
||||
}
|
||||
|
||||
// 프론트 상세 타입
|
||||
export interface ProductionOrderDetail extends ProductionOrder {
|
||||
workOrders: ProductionWorkOrder[];
|
||||
bomProcessGroups: BomProcessGroup[];
|
||||
}
|
||||
|
||||
export interface ProductionWorkOrder {
|
||||
id: number;
|
||||
workOrderNo: string;
|
||||
processName: string;
|
||||
quantity: number;
|
||||
status: string;
|
||||
assignees: string[];
|
||||
}
|
||||
|
||||
export interface BomProcessGroup {
|
||||
processName: string;
|
||||
sizeSpec?: string;
|
||||
items: BomItem[];
|
||||
}
|
||||
|
||||
export interface BomItem {
|
||||
id: number | null;
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
totalPrice: number;
|
||||
nodeName: string;
|
||||
}
|
||||
|
||||
// 조회 파라미터
|
||||
export interface ProductionOrderListParams {
|
||||
search?: string;
|
||||
productionStatus?: ProductionStatus;
|
||||
sortBy?: string;
|
||||
sortDir?: 'asc' | 'desc';
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ArrowLeft, FileText, X, Edit, Loader2, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { FileText, X, Edit, Loader2, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
@@ -22,14 +22,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { SalesOrderSelectModal } from './SalesOrderSelectModal';
|
||||
import { AssigneeSelectModal } from './AssigneeSelectModal';
|
||||
import { toast } from 'sonner';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { createWorkOrder, getProcessOptions, searchItemsForWorkOrder, type ProcessOption, type ManualItemOption } from './actions';
|
||||
import { PROCESS_TYPE_LABELS, type ProcessType, type SalesOrder } from './types';
|
||||
import { type SalesOrder } from './types';
|
||||
import { workOrderCreateConfig } from './workOrderConfig';
|
||||
|
||||
import { useDevFill } from '@/components/dev';
|
||||
@@ -44,20 +43,6 @@ interface ManualItem {
|
||||
unit: string;
|
||||
}
|
||||
|
||||
// Validation 에러 타입
|
||||
interface ValidationErrors {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
// 필드명 매핑
|
||||
const FIELD_NAME_MAP: Record<string, string> = {
|
||||
selectedOrder: '수주',
|
||||
client: '발주처',
|
||||
projectName: '현장명',
|
||||
processId: '공정',
|
||||
shipmentDate: '출고예정일',
|
||||
};
|
||||
|
||||
type RegistrationMode = 'linked' | 'manual';
|
||||
|
||||
interface FormData {
|
||||
@@ -102,7 +87,7 @@ export function WorkOrderCreate() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isAssigneeModalOpen, setIsAssigneeModalOpen] = useState(false);
|
||||
const [assigneeNames, setAssigneeNames] = useState<string[]>([]);
|
||||
const [validationErrors, setValidationErrors] = useState<ValidationErrors>({});
|
||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [processOptions, setProcessOptions] = useState<ProcessOption[]>([]);
|
||||
const [isLoadingProcesses, setIsLoadingProcesses] = useState(true);
|
||||
@@ -114,6 +99,17 @@ export function WorkOrderCreate() {
|
||||
const [isSearchingItems, setIsSearchingItems] = useState(false);
|
||||
const [showItemSearch, setShowItemSearch] = useState(false);
|
||||
|
||||
// 필드 에러 클리어 헬퍼
|
||||
const clearFieldError = useCallback((field: string) => {
|
||||
if (validationErrors[field]) {
|
||||
setValidationErrors(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[field];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [validationErrors]);
|
||||
|
||||
// 공정 옵션 로드
|
||||
useEffect(() => {
|
||||
async function loadProcessOptions() {
|
||||
@@ -173,6 +169,7 @@ export function WorkOrderCreate() {
|
||||
orderNo: order.orderNo,
|
||||
itemCount: order.itemCount,
|
||||
});
|
||||
clearFieldError('selectedOrder');
|
||||
};
|
||||
|
||||
// 수주 해제
|
||||
@@ -217,6 +214,7 @@ export function WorkOrderCreate() {
|
||||
setShowItemSearch(false);
|
||||
setItemSearchQuery('');
|
||||
setItemSearchResults([]);
|
||||
clearFieldError('items');
|
||||
};
|
||||
|
||||
// 품목 수량 변경
|
||||
@@ -232,7 +230,7 @@ export function WorkOrderCreate() {
|
||||
// 폼 제출
|
||||
const handleSubmit = async (): Promise<{ success: boolean; error?: string }> => {
|
||||
// Validation 체크
|
||||
const errors: ValidationErrors = {};
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (mode === 'linked') {
|
||||
if (!formData.selectedOrder) {
|
||||
@@ -261,8 +259,8 @@ export function WorkOrderCreate() {
|
||||
// 에러가 있으면 상태 업데이트 후 리턴
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setValidationErrors(errors);
|
||||
// 페이지 상단으로 스크롤
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
const firstError = Object.values(errors)[0];
|
||||
toast.error(firstError);
|
||||
return { success: false, error: '' };
|
||||
}
|
||||
|
||||
@@ -318,35 +316,6 @@ export function WorkOrderCreate() {
|
||||
// 폼 컨텐츠 렌더링
|
||||
const renderFormContent = useCallback(() => (
|
||||
<div className="space-y-6">
|
||||
{/* Validation 에러 표시 */}
|
||||
{Object.keys(validationErrors).length > 0 && (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">⚠️</span>
|
||||
<div className="flex-1">
|
||||
<strong className="block mb-2">
|
||||
입력 내용을 확인해주세요 ({Object.keys(validationErrors).length}개 오류)
|
||||
</strong>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{Object.entries(validationErrors).map(([field, message]) => {
|
||||
const fieldName = FIELD_NAME_MAP[field] || field;
|
||||
return (
|
||||
<li key={field} className="flex items-start gap-1">
|
||||
<span>•</span>
|
||||
<span>
|
||||
<strong>{fieldName}</strong>: {message}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 등록 방식 */}
|
||||
<section className="bg-white border rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-4">등록 방식</h3>
|
||||
@@ -381,7 +350,7 @@ export function WorkOrderCreate() {
|
||||
<section className="bg-muted/30 border rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-4">수주 정보</h3>
|
||||
{!formData.selectedOrder ? (
|
||||
<div className="flex items-center justify-between p-4 bg-white border rounded-lg">
|
||||
<div className={`flex items-center justify-between p-4 bg-white border rounded-lg ${validationErrors.selectedOrder ? 'border-red-500' : ''}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="w-5 h-5 text-muted-foreground" />
|
||||
<div>
|
||||
@@ -448,6 +417,7 @@ export function WorkOrderCreate() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{validationErrors.selectedOrder && <p className="text-sm text-red-500 mt-1">{validationErrors.selectedOrder}</p>}
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -459,21 +429,29 @@ export function WorkOrderCreate() {
|
||||
<Label>발주처 *</Label>
|
||||
<Input
|
||||
value={formData.client}
|
||||
onChange={(e) => setFormData({ ...formData, client: e.target.value })}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, client: e.target.value });
|
||||
clearFieldError('client');
|
||||
}}
|
||||
placeholder={mode === 'linked' ? '수주를 선택하면 자동 입력됩니다' : '발주처 입력'}
|
||||
disabled={mode === 'linked'}
|
||||
className="bg-white"
|
||||
className={`bg-white ${validationErrors.client ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
{validationErrors.client && <p className="text-sm text-red-500">{validationErrors.client}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>현장명 *</Label>
|
||||
<Input
|
||||
value={formData.projectName}
|
||||
onChange={(e) => setFormData({ ...formData, projectName: e.target.value })}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, projectName: e.target.value });
|
||||
clearFieldError('projectName');
|
||||
}}
|
||||
placeholder={mode === 'linked' ? '수주를 선택하면 자동 입력됩니다' : '현장명 입력'}
|
||||
disabled={mode === 'linked'}
|
||||
className="bg-white"
|
||||
className={`bg-white ${validationErrors.projectName ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
{validationErrors.projectName && <p className="text-sm text-red-500">{validationErrors.projectName}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>수주번호</Label>
|
||||
@@ -506,10 +484,13 @@ export function WorkOrderCreate() {
|
||||
<Label>공정구분 *</Label>
|
||||
<Select
|
||||
value={formData.processId?.toString() || ''}
|
||||
onValueChange={(value) => setFormData({ ...formData, processId: parseInt(value) })}
|
||||
onValueChange={(value) => {
|
||||
setFormData({ ...formData, processId: parseInt(value) });
|
||||
clearFieldError('processId');
|
||||
}}
|
||||
disabled={isLoadingProcesses}
|
||||
>
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectTrigger className={`bg-white ${validationErrors.processId ? 'border-red-500' : ''}`}>
|
||||
<SelectValue placeholder={isLoadingProcesses ? '로딩 중...' : '공정을 선택하세요'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -520,6 +501,7 @@ export function WorkOrderCreate() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{validationErrors.processId && <p className="text-sm text-red-500">{validationErrors.processId}</p>}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
공정코드: {getSelectedProcessCode()}
|
||||
</p>
|
||||
@@ -529,8 +511,13 @@ export function WorkOrderCreate() {
|
||||
<Label>출고예정일 *</Label>
|
||||
<DatePicker
|
||||
value={formData.shipmentDate}
|
||||
onChange={(date) => setFormData({ ...formData, shipmentDate: date })}
|
||||
onChange={(date) => {
|
||||
setFormData({ ...formData, shipmentDate: date });
|
||||
clearFieldError('shipmentDate');
|
||||
}}
|
||||
className={validationErrors.shipmentDate ? 'border-red-500' : ''}
|
||||
/>
|
||||
{validationErrors.shipmentDate && <p className="text-sm text-red-500">{validationErrors.shipmentDate}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -717,7 +704,7 @@ export function WorkOrderCreate() {
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
), [mode, formData, validationErrors, processOptions, isLoadingProcesses, assigneeNames, getSelectedProcessCode, manualItems, showItemSearch, itemSearchQuery, itemSearchResults, isSearchingItems]);
|
||||
), [mode, formData, validationErrors, processOptions, isLoadingProcesses, assigneeNames, getSelectedProcessCode, manualItems, showItemSearch, itemSearchQuery, itemSearchResults, isSearchingItems, clearFieldError]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -751,4 +738,4 @@ export function WorkOrderCreate() {
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { AssigneeSelectModal } from './AssigneeSelectModal';
|
||||
import { toast } from 'sonner';
|
||||
@@ -52,17 +51,6 @@ interface EditableItem extends WorkOrderItem {
|
||||
editQuantity?: number;
|
||||
}
|
||||
|
||||
// Validation 에러 타입
|
||||
interface ValidationErrors {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
// 필드명 매핑
|
||||
const FIELD_NAME_MAP: Record<string, string> = {
|
||||
processId: '공정',
|
||||
scheduledDate: '출고예정일',
|
||||
};
|
||||
|
||||
interface FormData {
|
||||
// 기본 정보 (읽기 전용)
|
||||
client: string;
|
||||
@@ -101,7 +89,7 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
|
||||
const [isAssigneeModalOpen, setIsAssigneeModalOpen] = useState(false);
|
||||
const [deleteTargetItemId, setDeleteTargetItemId] = useState<string | null>(null);
|
||||
const [assigneeNames, setAssigneeNames] = useState<string[]>([]);
|
||||
const [validationErrors, setValidationErrors] = useState<ValidationErrors>({});
|
||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [processOptions, setProcessOptions] = useState<ProcessOption[]>([]);
|
||||
@@ -213,7 +201,7 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
|
||||
// 폼 제출
|
||||
const handleSubmit = async (): Promise<{ success: boolean; error?: string }> => {
|
||||
// Validation 체크
|
||||
const errors: ValidationErrors = {};
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (!formData.processId) {
|
||||
errors.processId = '공정을 선택해주세요';
|
||||
@@ -226,7 +214,8 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
|
||||
// 에러가 있으면 상태 업데이트 후 리턴
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setValidationErrors(errors);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
const firstError = Object.values(errors)[0];
|
||||
toast.error(firstError);
|
||||
return { success: false, error: '입력 정보를 확인해주세요.' };
|
||||
}
|
||||
|
||||
@@ -344,35 +333,6 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
|
||||
// 폼 컨텐츠 렌더링 (기획서 4열 그리드)
|
||||
const renderFormContent = useCallback(() => (
|
||||
<div className="space-y-6">
|
||||
{/* Validation 에러 표시 */}
|
||||
{Object.keys(validationErrors).length > 0 && (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">⚠️</span>
|
||||
<div className="flex-1">
|
||||
<strong className="block mb-2">
|
||||
입력 내용을 확인해주세요 ({Object.keys(validationErrors).length}개 오류)
|
||||
</strong>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{Object.entries(validationErrors).map(([field, message]) => {
|
||||
const fieldName = FIELD_NAME_MAP[field] || field;
|
||||
return (
|
||||
<li key={field} className="flex items-start gap-1">
|
||||
<span>•</span>
|
||||
<span>
|
||||
<strong>{fieldName}</strong>: {message}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 기본 정보 (기획서 4열 구성) */}
|
||||
<section className="bg-amber-50 border border-amber-200 rounded-lg p-4 md:p-6">
|
||||
<h3 className="font-semibold mb-4">기본 정보</h3>
|
||||
@@ -391,10 +351,15 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
|
||||
<Label className="text-sm text-muted-foreground">공정 *</Label>
|
||||
<Select
|
||||
value={formData.processId?.toString() || ''}
|
||||
onValueChange={(value) => setFormData({ ...formData, processId: parseInt(value) })}
|
||||
onValueChange={(value) => {
|
||||
setFormData({ ...formData, processId: parseInt(value) });
|
||||
if (validationErrors.processId) {
|
||||
setValidationErrors(prev => { const { processId: _, ...rest } = prev; return rest; });
|
||||
}
|
||||
}}
|
||||
disabled={isLoadingProcesses}
|
||||
>
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectTrigger className={`bg-white ${validationErrors.processId ? 'border-red-500' : ''}`}>
|
||||
<SelectValue placeholder={isLoadingProcesses ? '로딩 중...' : '공정 선택'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -405,6 +370,7 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{validationErrors.processId && <p className="text-sm text-red-500">{validationErrors.processId}</p>}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm text-muted-foreground">구분</Label>
|
||||
@@ -442,8 +408,15 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
|
||||
<Label className="text-sm text-muted-foreground">출고예정일 *</Label>
|
||||
<DatePicker
|
||||
value={formData.scheduledDate}
|
||||
onChange={(date) => setFormData({ ...formData, scheduledDate: date })}
|
||||
onChange={(date) => {
|
||||
setFormData({ ...formData, scheduledDate: date });
|
||||
if (validationErrors.scheduledDate) {
|
||||
setValidationErrors(prev => { const { scheduledDate: _, ...rest } = prev; return rest; });
|
||||
}
|
||||
}}
|
||||
className={validationErrors.scheduledDate ? 'border-red-500' : ''}
|
||||
/>
|
||||
{validationErrors.scheduledDate && <p className="text-sm text-red-500">{validationErrors.scheduledDate}</p>}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm text-muted-foreground">틀수</Label>
|
||||
@@ -671,4 +644,4 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -857,6 +857,7 @@ export async function saveInspectionDocument(
|
||||
title?: string;
|
||||
data: Record<string, unknown>[];
|
||||
approvers?: { role_name: string; user_id?: number }[];
|
||||
rendered_html?: string;
|
||||
}
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
@@ -921,6 +922,34 @@ export async function resolveInspectionDocument(
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 문서 스냅샷 저장 (Lazy Snapshot) =====
|
||||
export async function patchDocumentSnapshot(
|
||||
documentId: number,
|
||||
renderedHtml: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const { response, error } = await serverFetch(
|
||||
buildApiUrl(`/api/v1/documents/${documentId}/snapshot`),
|
||||
{ method: 'PATCH', body: JSON.stringify({ rendered_html: renderedHtml }) }
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
return { success: false, error: error?.message || 'API 요청 실패' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success) {
|
||||
return { success: false, error: result.message || '스냅샷 저장에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[WorkOrderActions] patchDocumentSnapshot error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 문서 결재 상신 =====
|
||||
export async function submitDocumentForApproval(
|
||||
documentId: number
|
||||
|
||||
@@ -213,6 +213,15 @@ export const BendingInspectionContent = forwardRef<InspectionContentRef, Bending
|
||||
...p,
|
||||
bendingStatus: bendingStatusValue,
|
||||
})));
|
||||
} else if (itemData.judgment) {
|
||||
// 이전 형식 호환: products/bendingStatus 없이 judgment만 있는 경우
|
||||
const inferredStatus: CheckStatus = itemData.judgment === 'pass' ? '양호' : itemData.judgment === 'fail' ? '불량' : null;
|
||||
if (inferredStatus) {
|
||||
setProducts(prev => prev.map(p => ({
|
||||
...p,
|
||||
bendingStatus: inferredStatus,
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
// 부적합 내용 로드
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
saveInspectionDocument,
|
||||
resolveInspectionDocument,
|
||||
submitDocumentForApproval,
|
||||
patchDocumentSnapshot,
|
||||
} from '../actions';
|
||||
import type { WorkOrder, ProcessType } from '../types';
|
||||
import type { InspectionReportData, InspectionReportNodeGroup } from '../actions';
|
||||
@@ -164,6 +165,7 @@ export function InspectionReportModal({
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const contentRef = useRef<InspectionContentRef>(null);
|
||||
const contentWrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// API에서 로딩된 검사 데이터 (props 없을 때 fallback)
|
||||
const [apiWorkItems, setApiWorkItems] = useState<WorkItemData[] | null>(null);
|
||||
@@ -183,6 +185,8 @@ export function InspectionReportModal({
|
||||
const [savedDocumentId, setSavedDocumentId] = useState<number | null>(null);
|
||||
const [savedDocumentStatus, setSavedDocumentStatus] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
// Lazy Snapshot 대상 문서 ID (rendered_html이 없는 문서)
|
||||
const [snapshotDocumentId, setSnapshotDocumentId] = useState<number | null>(null);
|
||||
|
||||
// props에서 목업 제외한 실제 개소만 사용 (WorkerScreen에서 apiItems + mockItems가 합쳐져 전달됨)
|
||||
// ★ 반드시 workItems와 inspectionDataMap을 같은 소스에서 가져와야 key 포맷이 일치함
|
||||
@@ -296,7 +300,8 @@ export function InspectionReportModal({
|
||||
|
||||
// 4) 기존 문서의 document_data EAV 레코드 + ID/상태 추출
|
||||
if (resolveResult?.success && resolveResult.data) {
|
||||
const existingDoc = (resolveResult.data as Record<string, unknown>).existing_document as
|
||||
const resolveData = resolveResult.data as Record<string, unknown>;
|
||||
const existingDoc = resolveData.existing_document as
|
||||
| { id?: number; status?: string; data?: Array<{ section_id: number | null; column_id: number | null; row_index: number; field_key: string; field_value: string | null }> }
|
||||
| null;
|
||||
if (existingDoc?.data && existingDoc.data.length > 0) {
|
||||
@@ -307,10 +312,13 @@ export function InspectionReportModal({
|
||||
// 문서 ID/상태 저장 (결재 상신용)
|
||||
setSavedDocumentId(existingDoc?.id ?? null);
|
||||
setSavedDocumentStatus(existingDoc?.status ?? null);
|
||||
// Lazy Snapshot 대상 문서 ID
|
||||
setSnapshotDocumentId((resolveData.snapshot_document_id as number) ?? null);
|
||||
} else {
|
||||
setDocumentRecords(null);
|
||||
setSavedDocumentId(null);
|
||||
setSavedDocumentStatus(null);
|
||||
setSnapshotDocumentId(null);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -328,10 +336,30 @@ export function InspectionReportModal({
|
||||
setDocumentRecords(null);
|
||||
setSavedDocumentId(null);
|
||||
setSavedDocumentStatus(null);
|
||||
setSnapshotDocumentId(null);
|
||||
setError(null);
|
||||
}
|
||||
}, [open, workOrderId, processType, templateData]);
|
||||
|
||||
// Lazy Snapshot: 콘텐츠 렌더링 완료 후 rendered_html이 없는 문서에 스냅샷 저장
|
||||
useEffect(() => {
|
||||
if (!snapshotDocumentId || isLoading || !order) return;
|
||||
|
||||
// 콘텐츠 렌더링 대기 후 캡처
|
||||
const timer = setTimeout(() => {
|
||||
const html = contentWrapperRef.current?.innerHTML;
|
||||
if (html && html.length > 50) {
|
||||
patchDocumentSnapshot(snapshotDocumentId, html).then((result) => {
|
||||
if (result.success) {
|
||||
setSnapshotDocumentId(null); // 저장 완료 → 재실행 방지
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 500); // DOM 렌더링 완료 대기
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [snapshotDocumentId, isLoading, order]);
|
||||
|
||||
// 템플릿 결정: prop 우선, 없으면 자체 로딩 결과 사용
|
||||
const resolvedTemplateData = templateData || selfTemplateData;
|
||||
const activeTemplate = resolvedTemplateData?.has_template ? resolvedTemplateData.template : null;
|
||||
@@ -341,6 +369,8 @@ export function InspectionReportModal({
|
||||
if (!workOrderId || !contentRef.current) return;
|
||||
|
||||
const data = contentRef.current.getInspectionData();
|
||||
// HTML 스냅샷 캡처 (MNG 출력용)
|
||||
const renderedHtml = contentWrapperRef.current?.innerHTML || undefined;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// 템플릿 모드: Document 기반 저장 (정규화 형식)
|
||||
@@ -359,6 +389,7 @@ export function InspectionReportModal({
|
||||
step_id: activeStepId ?? undefined,
|
||||
title: activeTemplate.title || activeTemplate.name,
|
||||
data: inspData.records,
|
||||
rendered_html: renderedHtml,
|
||||
});
|
||||
if (result.success) {
|
||||
toast.success('검사 문서가 저장되었습니다.');
|
||||
@@ -530,7 +561,9 @@ export function InspectionReportModal({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{renderContent()}
|
||||
<div ref={contentWrapperRef}>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DocumentViewer>
|
||||
|
||||
@@ -624,6 +624,124 @@ export const TemplateInspectionContent = forwardRef<InspectionContentRef, Templa
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [documentRecords, isBending, bendingProducts]);
|
||||
|
||||
// ===== Bending: inspectionDataMap의 products 배열에서 셀 값 복원 =====
|
||||
// InspectionInputModal이 저장한 products 배열 → bending 셀 키로 매핑
|
||||
// ★ inspectionDataMap의 products가 있으면 documentRecords(EAV)보다 우선
|
||||
// (입력 모달에서 방금 저장한 신규 데이터가 이전 문서 데이터보다 최신)
|
||||
useEffect(() => {
|
||||
if (!isBending || !inspectionDataMap || !workItems || bendingProducts.length === 0) return;
|
||||
|
||||
// inspectionDataMap에서 products 배열 찾기
|
||||
type SavedProduct = {
|
||||
id: string;
|
||||
bendingStatus: string | null;
|
||||
lengthMeasured: string;
|
||||
widthMeasured: string;
|
||||
gapPoints: Array<{ point: string; designValue: string; measured: string }>;
|
||||
};
|
||||
let savedProducts: SavedProduct[] | undefined;
|
||||
for (const wi of workItems) {
|
||||
const d = inspectionDataMap.get(wi.id) as Record<string, unknown> | undefined;
|
||||
if (d?.products && Array.isArray(d.products)) {
|
||||
savedProducts = d.products as SavedProduct[];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!savedProducts || savedProducts.length === 0) return;
|
||||
|
||||
const initial: Record<string, CellValue> = {};
|
||||
|
||||
// 컬럼 분류
|
||||
const checkColId = template.columns.find(c => c.column_type === 'check')?.id;
|
||||
const complexCols = template.columns.filter(c =>
|
||||
c.column_type === 'complex' && c.id !== gapColumnId
|
||||
);
|
||||
|
||||
// 각 template bendingProduct → 저장된 product 매핑
|
||||
bendingProducts.forEach((bp, productIdx) => {
|
||||
// 1. ID 정규화 매칭 (guide-rail-wall ↔ guide_rail_wall)
|
||||
const normalizedBpId = bp.id.replace(/[-_]/g, '').toLowerCase();
|
||||
let matched = savedProducts!.find(sp =>
|
||||
sp.id.replace(/[-_]/g, '').toLowerCase() === normalizedBpId
|
||||
);
|
||||
|
||||
// 2. 이름 키워드 매칭
|
||||
if (!matched) {
|
||||
const bpKey = `${bp.productName}${bp.productType}`.replace(/\s/g, '').toLowerCase();
|
||||
matched = savedProducts!.find(sp => {
|
||||
const spId = sp.id.toLowerCase();
|
||||
if (bpKey.includes('가이드레일') && bpKey.includes('벽면') && spId.includes('guide') && spId.includes('wall')) return true;
|
||||
if (bpKey.includes('가이드레일') && bpKey.includes('측면') && spId.includes('guide') && spId.includes('side')) return true;
|
||||
if (bpKey.includes('케이스') && spId.includes('case')) return true;
|
||||
if (bpKey.includes('하단마감') && (spId.includes('bottom-finish') || spId.includes('bottom_bar'))) return true;
|
||||
if (bpKey.includes('연기차단') && bpKey.includes('w50') && spId.includes('w50')) return true;
|
||||
if (bpKey.includes('연기차단') && bpKey.includes('w80') && spId.includes('w80')) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 인덱스 폴백
|
||||
if (!matched && productIdx < savedProducts!.length) {
|
||||
matched = savedProducts![productIdx];
|
||||
}
|
||||
if (!matched) return;
|
||||
|
||||
// check 컬럼 (절곡상태)
|
||||
if (checkColId) {
|
||||
const cellKey = `b-${productIdx}-${checkColId}`;
|
||||
if (matched.bendingStatus === '양호') {
|
||||
initial[cellKey] = { status: 'good' };
|
||||
} else if (matched.bendingStatus === '불량') {
|
||||
initial[cellKey] = { status: 'bad' };
|
||||
}
|
||||
}
|
||||
|
||||
// 간격 컬럼
|
||||
if (gapColumnId && matched.gapPoints) {
|
||||
matched.gapPoints.forEach((gp, pointIdx) => {
|
||||
if (gp.measured) {
|
||||
const cellKey = `b-${productIdx}-p${pointIdx}-${gapColumnId}`;
|
||||
initial[cellKey] = { measurements: [gp.measured, '', ''] };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// complex 컬럼 (길이/너비)
|
||||
// bending 렌더링은 measurements[si] (si = sub_label raw index)를 읽으므로
|
||||
// 측정값 sub_label의 실제 si 위치에 값을 넣어야 함
|
||||
for (const col of complexCols) {
|
||||
const label = col.label.trim();
|
||||
const cellKey = `b-${productIdx}-${col.id}`;
|
||||
|
||||
// 측정값 sub_label의 si 인덱스 찾기
|
||||
let measurementSi = 0;
|
||||
if (col.sub_labels) {
|
||||
for (let si = 0; si < col.sub_labels.length; si++) {
|
||||
const sl = col.sub_labels[si].toLowerCase();
|
||||
if (!sl.includes('도면') && !sl.includes('기준')) {
|
||||
measurementSi = si;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const measurements: [string, string, string] = ['', '', ''];
|
||||
if (label.includes('길이') && matched.lengthMeasured) {
|
||||
measurements[measurementSi] = matched.lengthMeasured;
|
||||
initial[cellKey] = { measurements };
|
||||
} else if ((label.includes('너비') || label.includes('폭') || label.includes('높이')) && matched.widthMeasured) {
|
||||
measurements[measurementSi] = matched.widthMeasured;
|
||||
initial[cellKey] = { measurements };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(initial).length > 0) {
|
||||
setCellValues(prev => ({ ...prev, ...initial }));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isBending, inspectionDataMap, workItems, bendingProducts, template.columns, gapColumnId]);
|
||||
|
||||
const updateCell = (key: string, update: Partial<CellValue>) => {
|
||||
setCellValues(prev => ({
|
||||
...prev,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import type { BendingInfoExtended, MaterialMapping } from './types';
|
||||
import { buildBottomBarRows, getBendingImageUrl, fmt, fmtWeight, lengthToCode } from './utils';
|
||||
import { buildBottomBarRows, getBendingImageUrl, fmt, fmtWeight, lookupLotNo } from './utils';
|
||||
|
||||
interface BottomBarSectionProps {
|
||||
bendingInfo: BendingInfoExtended;
|
||||
@@ -17,7 +17,7 @@ interface BottomBarSectionProps {
|
||||
}
|
||||
|
||||
export function BottomBarSection({ bendingInfo, mapping, lotNoMap }: BottomBarSectionProps) {
|
||||
const rows = buildBottomBarRows(bendingInfo.bottomBar, mapping);
|
||||
const rows = buildBottomBarRows(bendingInfo.bottomBar, mapping, bendingInfo.productCode);
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
@@ -57,7 +57,7 @@ export function BottomBarSection({ bendingInfo, mapping, lotNoMap }: BottomBarSe
|
||||
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.length)}</td>
|
||||
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.quantity)}</td>
|
||||
<td className="border border-gray-400 px-1 py-0.5 text-center">
|
||||
{lotNoMap?.[`BD-${row.lotPrefix}-${lengthToCode(row.length)}`] || '-'}
|
||||
{lookupLotNo(lotNoMap, row.lotPrefix, row.length)}
|
||||
</td>
|
||||
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmtWeight(row.weight)}</td>
|
||||
</tr>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import type { BendingInfoExtended, MaterialMapping, GuideRailPartRow } from './types';
|
||||
import { buildWallGuideRailRows, buildSideGuideRailRows, getBendingImageUrl, fmt, fmtWeight, lengthToCode } from './utils';
|
||||
import { buildWallGuideRailRows, buildSideGuideRailRows, getBendingImageUrl, fmt, fmtWeight, lookupLotNo } from './utils';
|
||||
|
||||
interface GuideRailSectionProps {
|
||||
bendingInfo: BendingInfoExtended;
|
||||
@@ -63,7 +63,7 @@ function PartTable({ title, rows, imageUrl, lotNo, baseSize, lotNoMap }: {
|
||||
</td>
|
||||
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.quantity)}</td>
|
||||
<td className="border border-gray-400 px-1 py-0.5 text-center">
|
||||
{lotNoMap?.[`BD-${row.lotPrefix}-${lengthToCode(row.length)}`] || '-'}
|
||||
{lookupLotNo(lotNoMap, row.lotPrefix, row.length)}
|
||||
</td>
|
||||
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmtWeight(row.weight)}</td>
|
||||
</tr>
|
||||
@@ -81,11 +81,11 @@ export function GuideRailSection({ bendingInfo, mapping, lotNo, lotNoMap }: Guid
|
||||
const productCode = bendingInfo.productCode;
|
||||
|
||||
const wallRows = wall
|
||||
? buildWallGuideRailRows(wall.lengthData, wall.baseDimension || '135*80', mapping)
|
||||
? buildWallGuideRailRows(wall.lengthData, wall.baseDimension || '135*80', mapping, productCode)
|
||||
: [];
|
||||
|
||||
const sideRows = side
|
||||
? buildSideGuideRailRows(side.lengthData, side.baseDimension || '135*130', mapping)
|
||||
? buildSideGuideRailRows(side.lengthData, side.baseDimension || '135*130', mapping, productCode)
|
||||
: [];
|
||||
|
||||
if (wallRows.length === 0 && sideRows.length === 0) return null;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import type { BendingInfoExtended, ShutterBoxData } from './types';
|
||||
import { buildShutterBoxRows, getBendingImageUrl, fmt, fmtWeight, lengthToCode } from './utils';
|
||||
import { buildShutterBoxRows, getBendingImageUrl, fmt, fmtWeight, lookupLotNo } from './utils';
|
||||
|
||||
interface ShutterBoxSectionProps {
|
||||
bendingInfo: BendingInfoExtended;
|
||||
@@ -75,9 +75,10 @@ function ShutterBoxSubSection({ box, index, lotNoMap }: { box: ShutterBoxData; i
|
||||
{(() => {
|
||||
const dimNum = parseInt(row.dimension);
|
||||
if (!isNaN(dimNum) && !row.dimension.includes('*')) {
|
||||
return lotNoMap?.[`BD-${row.lotPrefix}-${lengthToCode(dimNum)}`] || '-';
|
||||
return lookupLotNo(lotNoMap, row.lotPrefix, dimNum);
|
||||
}
|
||||
return '-';
|
||||
// 치수형(1219*539 등)도 prefix-only fallback
|
||||
return lookupLotNo(lotNoMap, row.lotPrefix);
|
||||
})()}
|
||||
</td>
|
||||
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmtWeight(row.weight)}</td>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import type { BendingInfoExtended } from './types';
|
||||
import { buildSmokeBarrierRows, getBendingImageUrl, fmt, fmtWeight } from './utils';
|
||||
import { buildSmokeBarrierRows, getBendingImageUrl, fmt, fmtWeight, lookupLotNo } from './utils';
|
||||
|
||||
interface SmokeBarrierSectionProps {
|
||||
bendingInfo: BendingInfoExtended;
|
||||
@@ -57,7 +57,14 @@ export function SmokeBarrierSection({ bendingInfo, lotNoMap }: SmokeBarrierSecti
|
||||
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.length)}</td>
|
||||
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.quantity)}</td>
|
||||
<td className="border border-gray-400 px-1 py-0.5 text-center">
|
||||
{lotNoMap?.[`BD-${row.lotCode}`] || '-'}
|
||||
{(() => {
|
||||
// 정확 매칭 (GI-83, GI-54 등)
|
||||
const exact = lotNoMap?.[`BD-${row.lotCode}`];
|
||||
if (exact) return exact;
|
||||
// Fallback: GI prefix로 검색
|
||||
const prefix = row.lotCode.split('-')[0];
|
||||
return lookupLotNo(lotNoMap, prefix, row.length);
|
||||
})()}
|
||||
</td>
|
||||
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmtWeight(row.weight)}</td>
|
||||
</tr>
|
||||
|
||||
@@ -181,22 +181,29 @@ export function buildWallGuideRailRows(
|
||||
lengthData: LengthQuantity[],
|
||||
baseDimension: string,
|
||||
mapping: MaterialMapping,
|
||||
productCode?: string,
|
||||
): GuideRailPartRow[] {
|
||||
const rows: GuideRailPartRow[] = [];
|
||||
const codePrefix = productCode?.replace(/\d+$/, '') || 'KSS';
|
||||
const isSteel = codePrefix === 'KTE';
|
||||
const isSUS = ['KSS', 'KQTS', 'KTE'].includes(codePrefix);
|
||||
const finishPrefix = isSUS ? 'RS' : 'RE';
|
||||
const bodyPrefix = isSteel ? 'RT' : 'RM';
|
||||
|
||||
for (const ld of lengthData) {
|
||||
if (ld.quantity <= 0) continue;
|
||||
|
||||
// ①②마감재
|
||||
const finishW = calcWeight(mapping.guideRailFinish, WALL_PART_WIDTH, ld.length);
|
||||
rows.push({
|
||||
partName: '①②마감재', lotPrefix: 'XX', material: mapping.guideRailFinish,
|
||||
partName: '①②마감재', lotPrefix: finishPrefix, material: mapping.guideRailFinish,
|
||||
length: ld.length, quantity: ld.quantity, weight: Math.round(finishW.weight * ld.quantity * 100) / 100,
|
||||
});
|
||||
|
||||
// ③본체
|
||||
const bodyW = calcWeight(mapping.bodyMaterial, WALL_PART_WIDTH, ld.length);
|
||||
rows.push({
|
||||
partName: '③본체', lotPrefix: 'RT', material: mapping.bodyMaterial,
|
||||
partName: '③본체', lotPrefix: bodyPrefix, material: mapping.bodyMaterial,
|
||||
length: ld.length, quantity: ld.quantity, weight: Math.round(bodyW.weight * ld.quantity * 100) / 100,
|
||||
});
|
||||
|
||||
@@ -216,7 +223,7 @@ export function buildWallGuideRailRows(
|
||||
if (mapping.guideRailExtraFinish) {
|
||||
const extraW = calcWeight(mapping.guideRailExtraFinish, WALL_PART_WIDTH, ld.length);
|
||||
rows.push({
|
||||
partName: '⑥별도마감', lotPrefix: 'RS', material: mapping.guideRailExtraFinish,
|
||||
partName: '⑥별도마감', lotPrefix: 'YY', material: mapping.guideRailExtraFinish,
|
||||
length: ld.length, quantity: ld.quantity, weight: Math.round(extraW.weight * ld.quantity * 100) / 100,
|
||||
});
|
||||
}
|
||||
@@ -244,21 +251,27 @@ export function buildSideGuideRailRows(
|
||||
lengthData: LengthQuantity[],
|
||||
baseDimension: string,
|
||||
mapping: MaterialMapping,
|
||||
productCode?: string,
|
||||
): GuideRailPartRow[] {
|
||||
const rows: GuideRailPartRow[] = [];
|
||||
const codePrefix = productCode?.replace(/\d+$/, '') || 'KSS';
|
||||
const isSteel = codePrefix === 'KTE';
|
||||
const isSUS = ['KSS', 'KQTS', 'KTE'].includes(codePrefix);
|
||||
const finishPrefix = isSUS ? 'SS' : 'SE';
|
||||
const bodyPrefix = isSteel ? 'ST' : 'SM';
|
||||
|
||||
for (const ld of lengthData) {
|
||||
if (ld.quantity <= 0) continue;
|
||||
|
||||
const finishW = calcWeight(mapping.guideRailFinish, SIDE_PART_WIDTH, ld.length);
|
||||
rows.push({
|
||||
partName: '①②마감재', lotPrefix: 'SS', material: mapping.guideRailFinish,
|
||||
partName: '①②마감재', lotPrefix: finishPrefix, material: mapping.guideRailFinish,
|
||||
length: ld.length, quantity: ld.quantity, weight: Math.round(finishW.weight * ld.quantity * 100) / 100,
|
||||
});
|
||||
|
||||
const bodyW = calcWeight(mapping.bodyMaterial, SIDE_PART_WIDTH, ld.length);
|
||||
rows.push({
|
||||
partName: '③본체', lotPrefix: 'ST', material: mapping.bodyMaterial,
|
||||
partName: '③본체', lotPrefix: bodyPrefix, material: mapping.bodyMaterial,
|
||||
length: ld.length, quantity: ld.quantity, weight: Math.round(bodyW.weight * ld.quantity * 100) / 100,
|
||||
});
|
||||
|
||||
@@ -295,9 +308,12 @@ export function buildSideGuideRailRows(
|
||||
export function buildBottomBarRows(
|
||||
bottomBar: BendingInfoExtended['bottomBar'],
|
||||
mapping: MaterialMapping,
|
||||
productCode?: string,
|
||||
): BottomBarPartRow[] {
|
||||
const rows: BottomBarPartRow[] = [];
|
||||
const lotPrefix = mapping.bottomBarFinish.includes('SUS') ? 'TS' : 'TE';
|
||||
const codePrefix = productCode?.replace(/\d+$/, '') || 'KSS';
|
||||
const isSteel = codePrefix === 'KTE';
|
||||
const lotPrefix = isSteel ? 'TS' : (mapping.bottomBarFinish.includes('SUS') ? 'BS' : 'BE');
|
||||
|
||||
// ①하단마감재 - 3000mm
|
||||
if (bottomBar.length3000Qty > 0) {
|
||||
@@ -321,7 +337,7 @@ export function buildBottomBarRows(
|
||||
|
||||
// ④별도마감재 (extraFinish !== '없음' 일 때만)
|
||||
if (mapping.bottomBarExtraFinish !== '없음' && mapping.bottomBarExtraFinish) {
|
||||
const extraLotPrefix = mapping.bottomBarExtraFinish.includes('SUS') ? 'TS' : 'TE';
|
||||
const extraLotPrefix = 'YY';
|
||||
|
||||
if (bottomBar.length3000Qty > 0) {
|
||||
const w = calcWeight(mapping.bottomBarExtraFinish, EXTRA_FINISH_WIDTH, 3000);
|
||||
@@ -570,3 +586,33 @@ export function fmtWeight(v: number): string {
|
||||
export function lengthToCode(lengthMm: number): string {
|
||||
return getSLengthCode(lengthMm, '') || String(lengthMm);
|
||||
}
|
||||
|
||||
/**
|
||||
* lotNoMap에서 LOT NO 조회
|
||||
*
|
||||
* bending_info의 길이와 실제 자재투입 길이가 다를 수 있으므로,
|
||||
* 정확한 매칭 실패 시 prefix만으로 fallback 매칭합니다.
|
||||
*
|
||||
* @param lotNoMap - item_code → lot_no 매핑 (e.g. 'BD-SS-35' → 'INIT-260221-BDSS35')
|
||||
* @param prefix - 세부품목 prefix (e.g. 'SS', 'SM', 'BS')
|
||||
* @param length - 길이(mm), optional
|
||||
*/
|
||||
export function lookupLotNo(
|
||||
lotNoMap: Record<string, string> | undefined,
|
||||
prefix: string,
|
||||
length?: number,
|
||||
): string {
|
||||
if (!lotNoMap) return '-';
|
||||
|
||||
// 1. 정확한 매칭 (prefix + lengthCode)
|
||||
if (length) {
|
||||
const code = lengthToCode(length);
|
||||
const exact = lotNoMap[`BD-${prefix}-${code}`];
|
||||
if (exact) return exact;
|
||||
}
|
||||
|
||||
// 2. Fallback: prefix만으로 매칭 (첫 번째 일치 항목)
|
||||
const prefixKey = `BD-${prefix}-`;
|
||||
const fallbackKey = Object.keys(lotNoMap).find(k => k.startsWith(prefixKey));
|
||||
return fallbackKey ? lotNoMap[fallbackKey] : '-';
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* - bending_wip: 재고생산(재공품) 중간검사
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -24,6 +24,8 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { InspectionTemplateData, InspectionTemplateSectionItem } from './types';
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
import { getInspectionConfig } from '@/components/production/WorkOrders/actions';
|
||||
import type { InspectionConfigData } from '@/components/production/WorkOrders/actions';
|
||||
|
||||
// 중간검사 공정 타입
|
||||
export type InspectionProcessType =
|
||||
@@ -71,6 +73,88 @@ interface InspectionInputModalProps {
|
||||
templateData?: InspectionTemplateData;
|
||||
/** 작업 아이템의 실제 치수 (reference_attribute 연동용) */
|
||||
workItemDimensions?: { width?: number; height?: number };
|
||||
/** 작업지시 ID (절곡 gap_points API 조회용) */
|
||||
workOrderId?: string;
|
||||
}
|
||||
|
||||
// ===== 절곡 7개 제품 검사 항목 (BendingInspectionContent의 INITIAL_PRODUCTS와 동일 구조) =====
|
||||
interface BendingGapPointDef {
|
||||
point: string;
|
||||
design: string;
|
||||
}
|
||||
|
||||
interface BendingProductDef {
|
||||
id: string;
|
||||
label: string;
|
||||
lengthDesign: string;
|
||||
widthDesign: string;
|
||||
gapPoints: BendingGapPointDef[];
|
||||
}
|
||||
|
||||
const BENDING_PRODUCTS: BendingProductDef[] = [
|
||||
{
|
||||
id: 'guide-rail-wall', label: '가이드레일 (벽면형)', lengthDesign: '3000', widthDesign: 'N/A',
|
||||
gapPoints: [
|
||||
{ point: '①', design: '30' }, { point: '②', design: '80' },
|
||||
{ point: '③', design: '45' }, { point: '④', design: '40' }, { point: '⑤', design: '34' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'guide-rail-side', label: '가이드레일 (측면형)', lengthDesign: '3000', widthDesign: 'N/A',
|
||||
gapPoints: [
|
||||
{ point: '①', design: '28' }, { point: '②', design: '75' },
|
||||
{ point: '③', design: '42' }, { point: '④', design: '38' }, { point: '⑤', design: '32' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'case', label: '케이스 (500X380)', lengthDesign: '3000', widthDesign: 'N/A',
|
||||
gapPoints: [
|
||||
{ point: '①', design: '380' }, { point: '②', design: '50' },
|
||||
{ point: '③', design: '240' }, { point: '④', design: '50' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'bottom-finish', label: '하단마감재 (60X40)', lengthDesign: '3000', widthDesign: 'N/A',
|
||||
gapPoints: [
|
||||
{ point: '②', design: '60' }, { point: '②', design: '64' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'bottom-l-bar', label: '하단L-BAR (17X60)', lengthDesign: '3000', widthDesign: 'N/A',
|
||||
gapPoints: [
|
||||
{ point: '①', design: '17' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'smoke-w50', label: '연기차단재 (W50)', lengthDesign: '3000', widthDesign: '',
|
||||
gapPoints: [
|
||||
{ point: '①', design: '50' }, { point: '②', design: '12' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'smoke-w80', label: '연기차단재 (W80)', lengthDesign: '3000', widthDesign: '',
|
||||
gapPoints: [
|
||||
{ point: '①', design: '80' }, { point: '②', design: '12' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface BendingProductState {
|
||||
id: string;
|
||||
bendingStatus: 'good' | 'bad' | null;
|
||||
lengthMeasured: string;
|
||||
widthMeasured: string;
|
||||
gapMeasured: string[];
|
||||
}
|
||||
|
||||
function createInitialBendingProducts(): BendingProductState[] {
|
||||
return BENDING_PRODUCTS.map(p => ({
|
||||
id: p.id,
|
||||
bendingStatus: null,
|
||||
lengthMeasured: '',
|
||||
widthMeasured: '',
|
||||
gapMeasured: p.gapPoints.map(() => ''),
|
||||
}));
|
||||
}
|
||||
|
||||
const PROCESS_TITLES: Record<InspectionProcessType, string> = {
|
||||
@@ -461,9 +545,11 @@ export function InspectionInputModal({
|
||||
onComplete,
|
||||
templateData,
|
||||
workItemDimensions,
|
||||
workOrderId,
|
||||
}: InspectionInputModalProps) {
|
||||
// 템플릿 모드 여부
|
||||
const useTemplateMode = !!(templateData?.has_template && templateData.template);
|
||||
// 절곡(bending)은 7제품 커스텀 폼 사용 → TemplateInspectionContent의 bending 셀 키와 연동
|
||||
const useTemplateMode = processType !== 'bending' && !!(templateData?.has_template && templateData.template);
|
||||
|
||||
const [formData, setFormData] = useState<InspectionData>({
|
||||
productName,
|
||||
@@ -475,11 +561,72 @@ export function InspectionInputModal({
|
||||
// 동적 폼 값 (템플릿 모드용)
|
||||
const [dynamicFormValues, setDynamicFormValues] = useState<Record<string, unknown>>({});
|
||||
|
||||
// 절곡용 간격 포인트 초기화
|
||||
// 이전 형식 데이터 로드 시 auto-judgment가 judgment를 덮어쓰지 않도록 보호
|
||||
const skipAutoJudgmentRef = useRef(false);
|
||||
|
||||
// 절곡용 간격 포인트 초기화 (레거시 — bending_wip 등에서 사용)
|
||||
const [gapPoints, setGapPoints] = useState<{ left: number | null; right: number | null }[]>(
|
||||
Array(5).fill(null).map(() => ({ left: null, right: null }))
|
||||
);
|
||||
|
||||
// 절곡 API 제품 정의 (gap_points 동적 로딩)
|
||||
const [apiProductDefs, setApiProductDefs] = useState<BendingProductDef[] | null>(null);
|
||||
const effectiveProductDefs = apiProductDefs || BENDING_PRODUCTS;
|
||||
|
||||
// 절곡 7개 제품별 상태 (bending 전용)
|
||||
const [bendingProducts, setBendingProducts] = useState<BendingProductState[]>(createInitialBendingProducts);
|
||||
|
||||
// API에서 절곡 제품 gap_points 동적 로딩
|
||||
useEffect(() => {
|
||||
if (!open || processType !== 'bending' || !workOrderId) return;
|
||||
let cancelled = false;
|
||||
getInspectionConfig(workOrderId).then(result => {
|
||||
if (cancelled) return;
|
||||
if (result.success && result.data?.items?.length) {
|
||||
const displayMap: Record<string, { label: string; len: string; wid: string }> = {
|
||||
guide_rail_wall: { label: '가이드레일 (벽면형)', len: '3000', wid: 'N/A' },
|
||||
guide_rail_side: { label: '가이드레일 (측면형)', len: '3000', wid: 'N/A' },
|
||||
case_box: { label: '케이스 (500X380)', len: '3000', wid: 'N/A' },
|
||||
bottom_bar: { label: '하단마감재 (60X40)', len: '3000', wid: 'N/A' },
|
||||
bottom_l_bar: { label: '하단L-BAR (17X60)', len: '3000', wid: 'N/A' },
|
||||
smoke_w50: { label: '연기차단재 (W50)', len: '3000', wid: '' },
|
||||
smoke_w80: { label: '연기차단재 (W80)', len: '3000', wid: '' },
|
||||
};
|
||||
const defs: BendingProductDef[] = result.data.items.map(item => {
|
||||
const d = displayMap[item.id] || { label: item.name, len: '-', wid: 'N/A' };
|
||||
return {
|
||||
id: item.id,
|
||||
label: d.label,
|
||||
lengthDesign: d.len,
|
||||
widthDesign: d.wid,
|
||||
gapPoints: item.gap_points.map(gp => ({ point: gp.point, design: gp.design_value })),
|
||||
};
|
||||
});
|
||||
setApiProductDefs(defs);
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [open, processType, workOrderId]);
|
||||
|
||||
// API 제품 정의 로딩 시 bendingProducts 갱신 (gap 개수 동기화)
|
||||
useEffect(() => {
|
||||
if (!apiProductDefs || processType !== 'bending') return;
|
||||
setBendingProducts(prev => {
|
||||
return apiProductDefs.map((def, idx) => {
|
||||
// 기존 입력값 보존 (ID 매칭 또는 인덱스 폴백)
|
||||
const existing = prev.find(p => p.id === def.id || p.id.replace(/[-_]/g, '') === def.id.replace(/[-_]/g, ''))
|
||||
|| (idx < prev.length ? prev[idx] : undefined);
|
||||
return {
|
||||
id: def.id,
|
||||
bendingStatus: existing?.bendingStatus ?? null,
|
||||
lengthMeasured: existing?.lengthMeasured ?? '',
|
||||
widthMeasured: existing?.widthMeasured ?? '',
|
||||
gapMeasured: def.gapPoints.map((_, gi) => existing?.gapMeasured?.[gi] ?? ''),
|
||||
};
|
||||
});
|
||||
});
|
||||
}, [apiProductDefs, processType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// initialData가 있으면 기존 저장 데이터로 복원
|
||||
@@ -495,6 +642,46 @@ export function InspectionInputModal({
|
||||
} else {
|
||||
setGapPoints(Array(5).fill(null).map(() => ({ left: null, right: null })));
|
||||
}
|
||||
// 절곡 제품별 데이터 복원
|
||||
const savedProducts = (initialData as unknown as Record<string, unknown>).products as Array<{
|
||||
id: string;
|
||||
bendingStatus: string;
|
||||
lengthMeasured: string;
|
||||
widthMeasured: string;
|
||||
gapPoints: Array<{ point: string; designValue: string; measured: string }>;
|
||||
}> | undefined;
|
||||
if (savedProducts && Array.isArray(savedProducts)) {
|
||||
setBendingProducts(effectiveProductDefs.map((def, idx) => {
|
||||
const saved = savedProducts.find(sp =>
|
||||
sp.id === def.id || sp.id.replace(/[-_]/g, '') === def.id.replace(/[-_]/g, '')
|
||||
) || (idx < savedProducts.length ? savedProducts[idx] : undefined);
|
||||
if (!saved) return { id: def.id, bendingStatus: null, lengthMeasured: '', widthMeasured: '', gapMeasured: def.gapPoints.map(() => '') };
|
||||
return {
|
||||
id: def.id,
|
||||
bendingStatus: saved.bendingStatus === '양호' ? 'good' : saved.bendingStatus === '불량' ? 'bad' : (saved.bendingStatus as 'good' | 'bad' | null),
|
||||
lengthMeasured: saved.lengthMeasured || '',
|
||||
widthMeasured: saved.widthMeasured || '',
|
||||
gapMeasured: def.gapPoints.map((_, gi) => saved.gapPoints?.[gi]?.measured || ''),
|
||||
};
|
||||
}));
|
||||
} else if (processType === 'bending' && initialData.judgment) {
|
||||
// 이전 형식 데이터 호환: products 배열 없이 저장된 경우
|
||||
// judgment 값으로 제품별 상태 추론 (pass → 전체 양호)
|
||||
const restoredStatus: 'good' | 'bad' | null =
|
||||
initialData.judgment === 'pass' ? 'good' : initialData.judgment === 'fail' ? 'bad' : null;
|
||||
setBendingProducts(effectiveProductDefs.map(def => ({
|
||||
id: def.id,
|
||||
bendingStatus: restoredStatus,
|
||||
lengthMeasured: '',
|
||||
widthMeasured: '',
|
||||
gapMeasured: def.gapPoints.map(() => ''),
|
||||
})));
|
||||
// 이전 형식은 lengthMeasured가 없어 autoJudgment가 null이 되므로
|
||||
// 로드된 judgment를 덮어쓰지 않도록 보호
|
||||
skipAutoJudgmentRef.current = true;
|
||||
} else {
|
||||
setBendingProducts(createInitialBendingProducts());
|
||||
}
|
||||
// 동적 폼 값 복원 (템플릿 기반 검사 데이터)
|
||||
if (initialData.templateValues) {
|
||||
setDynamicFormValues(initialData.templateValues);
|
||||
@@ -554,20 +741,37 @@ export function InspectionInputModal({
|
||||
}
|
||||
|
||||
setGapPoints(Array(5).fill(null).map(() => ({ left: null, right: null })));
|
||||
setBendingProducts(createInitialBendingProducts());
|
||||
setDynamicFormValues({});
|
||||
}
|
||||
}, [open, productName, specification, processType, initialData]);
|
||||
|
||||
// 자동 판정 계산 (템플릿 모드 vs 레거시 모드)
|
||||
// 자동 판정 계산 (템플릿 모드 vs 절곡 7제품 모드 vs 레거시 모드)
|
||||
const autoJudgment = useMemo(() => {
|
||||
if (useTemplateMode && templateData?.template) {
|
||||
return computeDynamicJudgment(templateData.template, dynamicFormValues, workItemDimensions);
|
||||
}
|
||||
// 절곡 7개 제품 전용 판정
|
||||
if (processType === 'bending') {
|
||||
let allGood = true;
|
||||
let allFilled = true;
|
||||
for (const p of bendingProducts) {
|
||||
if (p.bendingStatus === 'bad') return 'fail';
|
||||
if (p.bendingStatus !== 'good') { allGood = false; allFilled = false; }
|
||||
if (!p.lengthMeasured) allFilled = false;
|
||||
}
|
||||
if (allGood && allFilled) return 'pass';
|
||||
return null;
|
||||
}
|
||||
return computeJudgment(processType, formData);
|
||||
}, [useTemplateMode, templateData, dynamicFormValues, workItemDimensions, processType, formData]);
|
||||
}, [useTemplateMode, templateData, dynamicFormValues, workItemDimensions, processType, formData, bendingProducts]);
|
||||
|
||||
// 판정값 자동 동기화
|
||||
// 판정값 자동 동기화 (이전 형식 데이터 로드 시 첫 번째 동기화 건너뜀)
|
||||
useEffect(() => {
|
||||
if (skipAutoJudgmentRef.current) {
|
||||
skipAutoJudgmentRef.current = false;
|
||||
return;
|
||||
}
|
||||
setFormData((prev) => {
|
||||
if (prev.judgment === autoJudgment) return prev;
|
||||
return { ...prev, judgment: autoJudgment };
|
||||
@@ -575,13 +779,32 @@ export function InspectionInputModal({
|
||||
}, [autoJudgment]);
|
||||
|
||||
const handleComplete = () => {
|
||||
const data: InspectionData = {
|
||||
const baseData: InspectionData = {
|
||||
...formData,
|
||||
gapPoints: processType === 'bending' ? gapPoints : undefined,
|
||||
// 동적 폼 값을 templateValues로 병합
|
||||
...(useTemplateMode ? { templateValues: dynamicFormValues } : {}),
|
||||
};
|
||||
onComplete(data);
|
||||
|
||||
// 절곡: products 배열을 성적서와 동일 포맷으로 저장
|
||||
if (processType === 'bending') {
|
||||
const products = bendingProducts.map((p, idx) => ({
|
||||
id: p.id,
|
||||
bendingStatus: p.bendingStatus === 'good' ? '양호' : p.bendingStatus === 'bad' ? '불량' : null,
|
||||
lengthMeasured: p.lengthMeasured,
|
||||
widthMeasured: p.widthMeasured,
|
||||
gapPoints: (effectiveProductDefs[idx]?.gapPoints || []).map((gp, gi) => ({
|
||||
point: gp.point,
|
||||
designValue: gp.design,
|
||||
measured: p.gapMeasured[gi] || '',
|
||||
})),
|
||||
}));
|
||||
const data = { ...baseData, products } as unknown as InspectionData;
|
||||
onComplete(data);
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
onComplete(baseData);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
@@ -866,75 +1089,96 @@ export function InspectionInputModal({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ===== 절곡 검사 항목 ===== */}
|
||||
{/* ===== 절곡 검사 항목 (7개 제품별) ===== */}
|
||||
{!useTemplateMode && processType === 'bending' && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-sm font-bold">검모양 절곡상태</span>
|
||||
<StatusToggle
|
||||
value={formData.bendingStatus || null}
|
||||
onChange={(v) => setFormData((prev) => ({ ...prev, bendingStatus: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-sm font-bold">길이 ({formatDimension(workItemDimensions?.width)})</span>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={formatDimension(workItemDimensions?.width)}
|
||||
value={formData.length ?? ''}
|
||||
onChange={(e) => handleNumberChange('length', e.target.value)}
|
||||
className="h-11 rounded-lg border-gray-300"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-sm font-bold">너비 (N/A)</span>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="N/A"
|
||||
value={formData.width ?? 'N/A'}
|
||||
readOnly
|
||||
className="h-11 bg-gray-100 border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<span className="text-sm font-bold">간격</span>
|
||||
{gapPoints.map((point, index) => (
|
||||
<div key={index} className="grid grid-cols-3 gap-2 items-center">
|
||||
<span className="text-gray-500 text-sm font-medium">⑤{index + 1}</span>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={String(30 + index * 10)}
|
||||
value={point.left ?? ''}
|
||||
onChange={(e) => {
|
||||
const newPoints = [...gapPoints];
|
||||
newPoints[index] = {
|
||||
...newPoints[index],
|
||||
left: e.target.value === '' ? null : parseFloat(e.target.value),
|
||||
};
|
||||
setGapPoints(newPoints);
|
||||
}}
|
||||
className="h-11 rounded-lg border-gray-300"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={String(30 + index * 10)}
|
||||
value={point.right ?? ''}
|
||||
onChange={(e) => {
|
||||
const newPoints = [...gapPoints];
|
||||
newPoints[index] = {
|
||||
...newPoints[index],
|
||||
right: e.target.value === '' ? null : parseFloat(e.target.value),
|
||||
};
|
||||
setGapPoints(newPoints);
|
||||
}}
|
||||
className="h-11 rounded-lg border-gray-300"
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
{effectiveProductDefs.map((productDef, pIdx) => {
|
||||
const pState = bendingProducts[pIdx];
|
||||
if (!pState) return null;
|
||||
|
||||
const updateProduct = (updates: Partial<BendingProductState>) => {
|
||||
setBendingProducts(prev => prev.map((p, i) => i === pIdx ? { ...p, ...updates } : p));
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={productDef.id} className={cn(pIdx > 0 && 'border-t border-gray-200 pt-4')}>
|
||||
{/* 제품명 헤더 */}
|
||||
<div className="mb-3">
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{pIdx + 1}. {productDef.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 절곡상태 */}
|
||||
<div className="space-y-1.5 mb-3">
|
||||
<span className="text-xs text-gray-500 font-medium">절곡상태</span>
|
||||
<StatusToggle
|
||||
value={pState.bendingStatus}
|
||||
onChange={(v) => updateProduct({ bendingStatus: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 길이 / 너비 */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-3">
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs text-gray-500 font-medium">길이 ({productDef.lengthDesign})</span>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={productDef.lengthDesign}
|
||||
value={pState.lengthMeasured}
|
||||
onChange={(e) => updateProduct({ lengthMeasured: e.target.value })}
|
||||
className="h-10 rounded-lg border-gray-300 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs text-gray-500 font-medium">너비 ({productDef.widthDesign || '-'})</span>
|
||||
{productDef.widthDesign === 'N/A' ? (
|
||||
<Input
|
||||
type="text"
|
||||
value="N/A"
|
||||
readOnly
|
||||
className="h-10 bg-gray-100 border-gray-300 rounded-lg text-sm"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={productDef.widthDesign || '-'}
|
||||
value={pState.widthMeasured}
|
||||
onChange={(e) => updateProduct({ widthMeasured: e.target.value })}
|
||||
className="h-10 rounded-lg border-gray-300 text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 간격 포인트 */}
|
||||
{productDef.gapPoints.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs text-gray-500 font-medium">간격</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{productDef.gapPoints.map((gp, gi) => (
|
||||
<div key={gi} className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-gray-400 w-14 shrink-0">{gp.point} ({gp.design})</span>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={gp.design}
|
||||
value={pState.gapMeasured[gi] || ''}
|
||||
onChange={(e) => {
|
||||
const newGaps = [...pState.gapMeasured];
|
||||
newGaps[gi] = e.target.value;
|
||||
updateProduct({ gapMeasured: newGaps });
|
||||
}}
|
||||
className="h-9 rounded-lg border-gray-300 text-sm"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 부적합 내용 */}
|
||||
|
||||
@@ -5,10 +5,16 @@
|
||||
*
|
||||
* 로트를 체크박스로 선택하면 필요수량만큼 FIFO 순서로 자동 배분합니다.
|
||||
* 같은 품목의 여러 로트를 조합하여 필요수량을 충족시킬 수 있습니다.
|
||||
*
|
||||
* 기능:
|
||||
* - 기투입 LOT 표시 및 수정 (replace 모드)
|
||||
* - 선택완료 배지
|
||||
* - 필요수량 배정 완료 시에만 투입 가능
|
||||
* - FIFO 자동입력 버튼
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Loader2, Check } from 'lucide-react';
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { Loader2, Check, Zap } from 'lucide-react';
|
||||
import { ContentSkeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -78,8 +84,10 @@ export function MaterialInputModal({
|
||||
}: MaterialInputModalProps) {
|
||||
const [materials, setMaterials] = useState<MaterialForInput[]>([]);
|
||||
const [selectedLotKeys, setSelectedLotKeys] = useState<Set<string>>(new Set());
|
||||
const [manualAllocations, setManualAllocations] = useState<Map<string, number>>(new Map());
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const materialsLoadedRef = useRef(false);
|
||||
|
||||
// 목업 자재 데이터 (개발용)
|
||||
const MOCK_MATERIALS: MaterialForInput[] = Array.from({ length: 5 }, (_, i) => ({
|
||||
@@ -95,20 +103,34 @@ export function MaterialInputModal({
|
||||
fifoRank: i + 1,
|
||||
}));
|
||||
|
||||
// 로트 키 생성
|
||||
const getLotKey = (material: MaterialForInput) =>
|
||||
String(material.stockLotId ?? `item-${material.itemId}`);
|
||||
// 로트 키 생성 (그룹별 독립 — 같은 LOT가 여러 그룹에 있어도 구분)
|
||||
const getLotKey = useCallback((material: MaterialForInput, groupKey: string) =>
|
||||
`${String(material.stockLotId ?? `item-${material.itemId}`)}__${groupKey}`, []);
|
||||
|
||||
// 품목별 그룹핑
|
||||
// 기투입 LOT 존재 여부
|
||||
const hasPreInputted = useMemo(() => {
|
||||
return materials.some(m => {
|
||||
const itemInput = m as unknown as MaterialForItemInput;
|
||||
return (itemInput.lotInputtedQty ?? 0) > 0;
|
||||
});
|
||||
}, [materials]);
|
||||
|
||||
// 품목별 그룹핑 (BOM 엔트리별 고유키 사용 — 같은 item_id라도 category+partType 다르면 별도 그룹)
|
||||
const materialGroups: MaterialGroup[] = useMemo(() => {
|
||||
// dynamic_bom 항목은 (itemId, workOrderItemId) 쌍으로 그룹핑
|
||||
const groups = new Map<string, MaterialForInput[]>();
|
||||
for (const m of materials) {
|
||||
const groupKey = m.workOrderItemId ? `${m.itemId}_${m.workOrderItemId}` : String(m.itemId);
|
||||
const itemInput = m as unknown as MaterialForItemInput;
|
||||
const groupKey = itemInput.bomGroupKey
|
||||
?? (m.workOrderItemId ? `${m.itemId}_${m.workOrderItemId}` : String(m.itemId));
|
||||
const existing = groups.get(groupKey) || [];
|
||||
existing.push(m);
|
||||
groups.set(groupKey, existing);
|
||||
}
|
||||
// 작업일지와 동일한 카테고리 순서
|
||||
const categoryOrder: Record<string, number> = {
|
||||
guideRail: 0, bottomBar: 1, shutterBox: 2, smokeBarrier: 3,
|
||||
};
|
||||
|
||||
return Array.from(groups.entries()).map(([groupKey, lots]) => {
|
||||
const first = lots[0];
|
||||
const itemInput = first as unknown as MaterialForItemInput;
|
||||
@@ -129,37 +151,71 @@ export function MaterialInputModal({
|
||||
partType: first.partType,
|
||||
category: first.category,
|
||||
};
|
||||
}).sort((a, b) => {
|
||||
const catA = categoryOrder[a.category ?? ''] ?? 99;
|
||||
const catB = categoryOrder[b.category ?? ''] ?? 99;
|
||||
return catA - catB;
|
||||
});
|
||||
}, [materials]);
|
||||
|
||||
// 선택된 로트에 FIFO 순서로 자동 배분 계산
|
||||
// 그룹별 목표 수량 (기투입 있으면 전체 필요수량, 없으면 남은 필요수량)
|
||||
const getGroupTargetQty = useCallback((group: MaterialGroup) => {
|
||||
return group.alreadyInputted > 0 ? group.requiredQty : group.effectiveRequiredQty;
|
||||
}, []);
|
||||
|
||||
// 배정 수량 계산 (manual 우선 → 나머지 FIFO 자동배분, 물리LOT 교차그룹 추적)
|
||||
const allocations = useMemo(() => {
|
||||
const result = new Map<string, number>();
|
||||
const physicalUsed = new Map<number, number>(); // stockLotId → 그룹 간 누적 사용량
|
||||
|
||||
for (const group of materialGroups) {
|
||||
let remaining = group.effectiveRequiredQty;
|
||||
const targetQty = getGroupTargetQty(group);
|
||||
let remaining = targetQty;
|
||||
|
||||
// 1차: manual allocations 적용
|
||||
for (const lot of group.lots) {
|
||||
const lotKey = getLotKey(lot);
|
||||
if (selectedLotKeys.has(lotKey) && lot.stockLotId && remaining > 0) {
|
||||
const alloc = Math.min(lot.lotAvailableQty, remaining);
|
||||
const lotKey = getLotKey(lot, group.groupKey);
|
||||
if (selectedLotKeys.has(lotKey) && lot.stockLotId && manualAllocations.has(lotKey)) {
|
||||
const val = manualAllocations.get(lotKey)!;
|
||||
result.set(lotKey, val);
|
||||
remaining -= val;
|
||||
physicalUsed.set(lot.stockLotId, (physicalUsed.get(lot.stockLotId) || 0) + val);
|
||||
}
|
||||
}
|
||||
|
||||
// 2차: non-manual 선택 로트 FIFO 자동배분 (물리LOT 가용량 고려)
|
||||
for (const lot of group.lots) {
|
||||
const lotKey = getLotKey(lot, group.groupKey);
|
||||
if (selectedLotKeys.has(lotKey) && lot.stockLotId && !manualAllocations.has(lotKey)) {
|
||||
const itemInput = lot as unknown as MaterialForItemInput;
|
||||
const maxAvail = lot.lotAvailableQty + (itemInput.lotInputtedQty ?? 0);
|
||||
const used = physicalUsed.get(lot.stockLotId) || 0;
|
||||
const effectiveAvail = Math.max(0, maxAvail - used);
|
||||
const alloc = remaining > 0 ? Math.min(effectiveAvail, remaining) : 0;
|
||||
result.set(lotKey, alloc);
|
||||
remaining -= alloc;
|
||||
if (alloc > 0) {
|
||||
physicalUsed.set(lot.stockLotId, used + alloc);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [materialGroups, selectedLotKeys]);
|
||||
}, [materialGroups, selectedLotKeys, manualAllocations, getLotKey, getGroupTargetQty]);
|
||||
|
||||
// 전체 배정 완료 여부
|
||||
const allGroupsFulfilled = useMemo(() => {
|
||||
if (materialGroups.length === 0) return false;
|
||||
return materialGroups.every((group) => {
|
||||
const targetQty = getGroupTargetQty(group);
|
||||
if (targetQty <= 0) return true;
|
||||
const allocated = group.lots.reduce(
|
||||
(sum, lot) => sum + (allocations.get(getLotKey(lot)) || 0),
|
||||
(sum, lot) => sum + (allocations.get(getLotKey(lot, group.groupKey)) || 0),
|
||||
0
|
||||
);
|
||||
return group.effectiveRequiredQty <= 0 || allocated >= group.effectiveRequiredQty;
|
||||
return allocated >= targetQty;
|
||||
});
|
||||
}, [materialGroups, allocations]);
|
||||
}, [materialGroups, allocations, getLotKey, getGroupTargetQty]);
|
||||
|
||||
// 배정된 항목 존재 여부
|
||||
const hasAnyAllocation = useMemo(() => {
|
||||
@@ -172,6 +228,11 @@ export function MaterialInputModal({
|
||||
const next = new Set(prev);
|
||||
if (next.has(lotKey)) {
|
||||
next.delete(lotKey);
|
||||
setManualAllocations(prev => {
|
||||
const n = new Map(prev);
|
||||
n.delete(lotKey);
|
||||
return n;
|
||||
});
|
||||
} else {
|
||||
next.add(lotKey);
|
||||
}
|
||||
@@ -179,16 +240,60 @@ export function MaterialInputModal({
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 수량 수동 변경
|
||||
const handleAllocationChange = useCallback((lotKey: string, value: number, maxAvailable: number) => {
|
||||
const clamped = Math.max(0, Math.min(value, maxAvailable));
|
||||
setManualAllocations(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(lotKey, clamped);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// FIFO 자동입력 (물리LOT 교차그룹 가용량 추적)
|
||||
const handleAutoFill = useCallback(() => {
|
||||
const newSelected = new Set<string>();
|
||||
const newAllocations = new Map<string, number>();
|
||||
const physicalUsed = new Map<number, number>(); // stockLotId → 그룹 간 누적 사용량
|
||||
|
||||
for (const group of materialGroups) {
|
||||
const targetQty = getGroupTargetQty(group);
|
||||
if (targetQty <= 0) continue;
|
||||
|
||||
let remaining = targetQty;
|
||||
for (const lot of group.lots) {
|
||||
if (!lot.stockLotId || remaining <= 0) continue;
|
||||
const lotKey = getLotKey(lot, group.groupKey);
|
||||
const itemInput = lot as unknown as MaterialForItemInput;
|
||||
const maxAvail = lot.lotAvailableQty + (itemInput.lotInputtedQty ?? 0);
|
||||
const used = physicalUsed.get(lot.stockLotId) || 0;
|
||||
const effectiveAvail = Math.max(0, maxAvail - used);
|
||||
const alloc = Math.min(effectiveAvail, remaining);
|
||||
if (alloc > 0) {
|
||||
newSelected.add(lotKey);
|
||||
newAllocations.set(lotKey, alloc);
|
||||
remaining -= alloc;
|
||||
physicalUsed.set(lot.stockLotId, used + alloc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedLotKeys(newSelected);
|
||||
setManualAllocations(newAllocations);
|
||||
}, [materialGroups, getLotKey, getGroupTargetQty]);
|
||||
|
||||
// API로 자재 목록 로드
|
||||
const loadMaterials = useCallback(async () => {
|
||||
if (!order) return;
|
||||
|
||||
setIsLoading(true);
|
||||
materialsLoadedRef.current = false;
|
||||
try {
|
||||
// 목업 아이템인 경우 목업 자재 데이터 사용
|
||||
if (order.id.startsWith('mock-')) {
|
||||
setMaterials(MOCK_MATERIALS);
|
||||
setIsLoading(false);
|
||||
materialsLoadedRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -204,6 +309,7 @@ export function MaterialInputModal({
|
||||
workOrderItemId: m.workOrderItemId || itemId,
|
||||
}));
|
||||
setMaterials(tagged);
|
||||
materialsLoadedRef.current = true;
|
||||
} else {
|
||||
toast.error(result.error || '자재 목록 조회에 실패했습니다.');
|
||||
}
|
||||
@@ -212,6 +318,7 @@ export function MaterialInputModal({
|
||||
const result = await getMaterialsForWorkOrder(order.id);
|
||||
if (result.success) {
|
||||
setMaterials(result.data);
|
||||
materialsLoadedRef.current = true;
|
||||
} else {
|
||||
toast.error(result.error || '자재 목록 조회에 실패했습니다.');
|
||||
}
|
||||
@@ -230,27 +337,53 @@ export function MaterialInputModal({
|
||||
if (open && order) {
|
||||
loadMaterials();
|
||||
setSelectedLotKeys(new Set());
|
||||
setManualAllocations(new Map());
|
||||
}
|
||||
}, [open, order, loadMaterials]);
|
||||
|
||||
// 자재 로드 후 기투입 LOT 자동 선택 (그룹별 독립 처리)
|
||||
useEffect(() => {
|
||||
if (!materialsLoadedRef.current || materials.length === 0 || materialGroups.length === 0) return;
|
||||
|
||||
const preSelected = new Set<string>();
|
||||
const preAllocations = new Map<string, number>();
|
||||
|
||||
for (const group of materialGroups) {
|
||||
for (const m of group.lots) {
|
||||
const itemInput = m as unknown as MaterialForItemInput;
|
||||
const lotInputted = itemInput.lotInputtedQty ?? 0;
|
||||
if (lotInputted > 0 && m.stockLotId) {
|
||||
const lotKey = getLotKey(m, group.groupKey);
|
||||
preSelected.add(lotKey);
|
||||
preAllocations.set(lotKey, lotInputted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (preSelected.size > 0) {
|
||||
setSelectedLotKeys(prev => new Set([...prev, ...preSelected]));
|
||||
setManualAllocations(prev => new Map([...prev, ...preAllocations]));
|
||||
}
|
||||
// 한 번만 실행하도록 ref 초기화
|
||||
materialsLoadedRef.current = false;
|
||||
}, [materials, materialGroups, getLotKey]);
|
||||
|
||||
// 투입 등록
|
||||
const handleSubmit = async () => {
|
||||
if (!order) return;
|
||||
|
||||
// 배분된 로트만 추출 (dynamic_bom이면 work_order_item_id 포함)
|
||||
const inputs: { stock_lot_id: number; qty: number; work_order_item_id?: number }[] = [];
|
||||
for (const [lotKey, allocQty] of allocations) {
|
||||
if (allocQty > 0) {
|
||||
const material = materials.find((m) => getLotKey(m) === lotKey);
|
||||
if (material?.stockLotId) {
|
||||
const input: { stock_lot_id: number; qty: number; work_order_item_id?: number } = {
|
||||
stock_lot_id: material.stockLotId,
|
||||
// 배분된 로트를 그룹별 개별 엔트리로 추출 (bom_group_key 포함)
|
||||
const inputs: { stock_lot_id: number; qty: number; bom_group_key: string }[] = [];
|
||||
for (const group of materialGroups) {
|
||||
for (const lot of group.lots) {
|
||||
const lotKey = getLotKey(lot, group.groupKey);
|
||||
const allocQty = allocations.get(lotKey) || 0;
|
||||
if (allocQty > 0 && lot.stockLotId) {
|
||||
inputs.push({
|
||||
stock_lot_id: lot.stockLotId,
|
||||
qty: allocQty,
|
||||
};
|
||||
if (material.workOrderItemId) {
|
||||
input.work_order_item_id = material.workOrderItemId;
|
||||
}
|
||||
inputs.push(input);
|
||||
bom_group_key: group.groupKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -268,8 +401,8 @@ export function MaterialInputModal({
|
||||
?? (workOrderItemIds && workOrderItemIds.length > 0 ? workOrderItemIds[0] : null);
|
||||
|
||||
if (targetItemId) {
|
||||
const simpleInputs = inputs.map(({ stock_lot_id, qty }) => ({ stock_lot_id, qty }));
|
||||
result = await registerMaterialInputForItem(order.id, targetItemId, simpleInputs);
|
||||
// 기투입 LOT 있으면 replace 모드 (기존 투입 삭제 후 재등록)
|
||||
result = await registerMaterialInputForItem(order.id, targetItemId, inputs, hasPreInputted);
|
||||
} else {
|
||||
result = await registerMaterialInput(order.id, inputs);
|
||||
}
|
||||
@@ -278,18 +411,26 @@ export function MaterialInputModal({
|
||||
toast.success('자재 투입이 등록되었습니다.');
|
||||
|
||||
if (onSaveMaterials) {
|
||||
// 표시용: 같은 LOT는 합산 (자재투입목록 UI)
|
||||
const lotTotals = new Map<number, number>();
|
||||
for (const inp of inputs) {
|
||||
lotTotals.set(inp.stock_lot_id, (lotTotals.get(inp.stock_lot_id) || 0) + inp.qty);
|
||||
}
|
||||
const savedList: MaterialInput[] = [];
|
||||
for (const [lotKey, allocQty] of allocations) {
|
||||
if (allocQty > 0) {
|
||||
const material = materials.find((m) => getLotKey(m) === lotKey);
|
||||
if (material) {
|
||||
const processedLotIds = new Set<number>();
|
||||
for (const group of materialGroups) {
|
||||
for (const lot of group.lots) {
|
||||
if (!lot.stockLotId || processedLotIds.has(lot.stockLotId)) continue;
|
||||
const totalQty = lotTotals.get(lot.stockLotId) || 0;
|
||||
if (totalQty > 0) {
|
||||
processedLotIds.add(lot.stockLotId);
|
||||
savedList.push({
|
||||
id: String(material.stockLotId),
|
||||
lotNo: material.lotNo || '',
|
||||
materialName: material.materialName,
|
||||
quantity: material.lotAvailableQty,
|
||||
unit: material.unit,
|
||||
inputQuantity: allocQty,
|
||||
id: String(lot.stockLotId),
|
||||
lotNo: lot.lotNo || '',
|
||||
materialName: lot.materialName,
|
||||
quantity: lot.lotAvailableQty,
|
||||
unit: lot.unit,
|
||||
inputQuantity: totalQty,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -316,6 +457,7 @@ export function MaterialInputModal({
|
||||
|
||||
const resetAndClose = () => {
|
||||
setSelectedLotKeys(new Set());
|
||||
setManualAllocations(new Map());
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
@@ -329,9 +471,20 @@ export function MaterialInputModal({
|
||||
<DialogTitle className="text-xl font-semibold">
|
||||
자재 투입{workOrderItemName ? ` - ${workOrderItemName}` : ''}
|
||||
</DialogTitle>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
로트를 선택하면 필요수량만큼 자동 배분됩니다.
|
||||
</p>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<p className="text-sm text-gray-500">
|
||||
로트를 선택하면 필요수량만큼 자동 배분됩니다.
|
||||
</p>
|
||||
{!isLoading && materials.length > 0 && (
|
||||
<button
|
||||
onClick={handleAutoFill}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-semibold bg-blue-100 text-blue-700 rounded-full hover:bg-blue-200 transition-colors shrink-0"
|
||||
>
|
||||
<Zap className="h-3 w-3" />
|
||||
자동입력
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6 pb-6 space-y-4 flex-1 min-h-0 flex flex-col">
|
||||
@@ -344,13 +497,22 @@ export function MaterialInputModal({
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 flex-1 overflow-y-auto min-h-0">
|
||||
{materialGroups.map((group) => {
|
||||
{materialGroups.map((group, groupIdx) => {
|
||||
// 같은 카테고리 내 순번 계산 (①②③...)
|
||||
const categoryIndex = group.category
|
||||
? materialGroups.slice(0, groupIdx).filter(g => g.category === group.category).length
|
||||
: -1;
|
||||
const circledNumbers = ['①','②','③','④','⑤','⑥','⑦','⑧','⑨','⑩'];
|
||||
const circledNum = categoryIndex >= 0 && categoryIndex < circledNumbers.length
|
||||
? circledNumbers[categoryIndex] : '';
|
||||
|
||||
const targetQty = getGroupTargetQty(group);
|
||||
const groupAllocated = group.lots.reduce(
|
||||
(sum, lot) => sum + (allocations.get(getLotKey(lot)) || 0),
|
||||
(sum, lot) => sum + (allocations.get(getLotKey(lot, group.groupKey)) || 0),
|
||||
0
|
||||
);
|
||||
const isAlreadyComplete = group.effectiveRequiredQty <= 0;
|
||||
const isFulfilled = isAlreadyComplete || groupAllocated >= group.effectiveRequiredQty;
|
||||
const isGroupComplete = targetQty <= 0 && group.alreadyInputted <= 0;
|
||||
const isFulfilled = isGroupComplete || groupAllocated >= targetQty;
|
||||
|
||||
return (
|
||||
<div key={group.groupKey} className="border rounded-lg overflow-hidden">
|
||||
@@ -372,6 +534,11 @@ export function MaterialInputModal({
|
||||
group.category}
|
||||
</span>
|
||||
)}
|
||||
{group.partType && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-200 text-gray-600 font-medium">
|
||||
{circledNum}{group.partType}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
{group.materialName}
|
||||
</span>
|
||||
@@ -387,10 +554,10 @@ export function MaterialInputModal({
|
||||
<>
|
||||
필요:{' '}
|
||||
<span className="font-semibold text-gray-900">
|
||||
{fmtQty(group.effectiveRequiredQty)}
|
||||
{fmtQty(group.requiredQty)}
|
||||
</span>{' '}
|
||||
{group.unit}
|
||||
<span className="ml-1 text-gray-400">
|
||||
<span className="ml-1 text-blue-500">
|
||||
(기투입: {fmtQty(group.alreadyInputted)})
|
||||
</span>
|
||||
</>
|
||||
@@ -406,27 +573,20 @@ export function MaterialInputModal({
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-semibold px-2 py-0.5 rounded-full flex items-center gap-1 ${
|
||||
isAlreadyComplete
|
||||
isFulfilled
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: isFulfilled
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: groupAllocated > 0
|
||||
? 'bg-amber-100 text-amber-700'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
: groupAllocated > 0
|
||||
? 'bg-amber-100 text-amber-700'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{isAlreadyComplete ? (
|
||||
<>
|
||||
<Check className="h-3 w-3" />
|
||||
투입 완료
|
||||
</>
|
||||
) : isFulfilled ? (
|
||||
{isFulfilled ? (
|
||||
<>
|
||||
<Check className="h-3 w-3" />
|
||||
배정 완료
|
||||
</>
|
||||
) : (
|
||||
`${fmtQty(groupAllocated)} / ${fmtQty(group.effectiveRequiredQty)}`
|
||||
`${fmtQty(groupAllocated)} / ${fmtQty(targetQty)}`
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -437,7 +597,7 @@ export function MaterialInputModal({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-center w-20">선택</TableHead>
|
||||
<TableHead className="text-center w-24">선택</TableHead>
|
||||
<TableHead className="text-center">로트번호</TableHead>
|
||||
<TableHead className="text-center">가용수량</TableHead>
|
||||
<TableHead className="text-center">단위</TableHead>
|
||||
@@ -446,20 +606,24 @@ export function MaterialInputModal({
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{group.lots.map((lot, idx) => {
|
||||
const lotKey = getLotKey(lot);
|
||||
const lotKey = getLotKey(lot, group.groupKey);
|
||||
const hasStock = lot.stockLotId !== null;
|
||||
const isSelected = selectedLotKeys.has(lotKey);
|
||||
const allocated = allocations.get(lotKey) || 0;
|
||||
const canSelect = hasStock && !isAlreadyComplete && (!isFulfilled || isSelected);
|
||||
const itemInput = lot as unknown as MaterialForItemInput;
|
||||
const lotInputted = itemInput.lotInputtedQty ?? 0;
|
||||
const isPreInputted = lotInputted > 0;
|
||||
// 가용수량 = 현재 가용 + 기투입분 (replace 시 복원되므로)
|
||||
const effectiveAvailable = lot.lotAvailableQty + lotInputted;
|
||||
const canSelect = hasStock && (!isFulfilled || isSelected);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={`${lotKey}-${idx}`}
|
||||
className={
|
||||
isSelected && allocated > 0
|
||||
? 'bg-blue-50/50'
|
||||
: ''
|
||||
}
|
||||
className={cn(
|
||||
isSelected && allocated > 0 ? 'bg-blue-50/50' : '',
|
||||
isPreInputted && isSelected ? 'bg-blue-50/70' : ''
|
||||
)}
|
||||
>
|
||||
<TableCell className="text-center">
|
||||
{hasStock ? (
|
||||
@@ -467,7 +631,7 @@ export function MaterialInputModal({
|
||||
onClick={() => toggleLot(lotKey)}
|
||||
disabled={!canSelect}
|
||||
className={cn(
|
||||
'min-w-[56px] px-3 py-1.5 rounded-lg text-xs font-semibold transition-all',
|
||||
'min-w-[64px] px-3 py-1.5 rounded-lg text-xs font-semibold transition-all',
|
||||
isSelected
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: canSelect
|
||||
@@ -475,20 +639,34 @@ export function MaterialInputModal({
|
||||
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{isSelected ? '선택됨' : '선택'}
|
||||
{isSelected ? '선택완료' : '선택'}
|
||||
</button>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm">
|
||||
{lot.lotNo || (
|
||||
<span className="text-gray-400">
|
||||
재고 없음
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{lot.lotNo || (
|
||||
<span className="text-gray-400">
|
||||
재고 없음
|
||||
</span>
|
||||
)}
|
||||
{isPreInputted && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-100 text-blue-600 font-medium">
|
||||
기투입
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm">
|
||||
{hasStock ? (
|
||||
fmtQty(lot.lotAvailableQty)
|
||||
isPreInputted ? (
|
||||
<span>
|
||||
{fmtQty(lot.lotAvailableQty)}
|
||||
<span className="text-blue-500 text-xs ml-1">(+{fmtQty(lotInputted)})</span>
|
||||
</span>
|
||||
) : (
|
||||
fmtQty(lot.lotAvailableQty)
|
||||
)
|
||||
) : (
|
||||
<span className="text-red-500">0</span>
|
||||
)}
|
||||
@@ -497,7 +675,19 @@ export function MaterialInputModal({
|
||||
{lot.unit}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm font-medium">
|
||||
{allocated > 0 ? (
|
||||
{isSelected && hasStock ? (
|
||||
<input
|
||||
type="number"
|
||||
value={allocated || ''}
|
||||
onChange={(e) => {
|
||||
const val = parseFloat(e.target.value) || 0;
|
||||
handleAllocationChange(lotKey, val, effectiveAvailable);
|
||||
}}
|
||||
className="w-20 text-center text-blue-600 font-semibold border border-blue-200 rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-400 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
min={0}
|
||||
max={effectiveAvailable}
|
||||
/>
|
||||
) : allocated > 0 ? (
|
||||
<span className="text-blue-600">
|
||||
{fmtQty(allocated)}
|
||||
</span>
|
||||
@@ -529,7 +719,7 @@ export function MaterialInputModal({
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !hasAnyAllocation}
|
||||
disabled={isSubmitting || !allGroupsFulfilled || !hasAnyAllocation}
|
||||
className="flex-1 py-6 text-base font-medium bg-gray-900 hover:bg-gray-800"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
@@ -546,4 +736,4 @@ export function MaterialInputModal({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ export function WorkCard({
|
||||
{/* 헤더 박스: 품목명 + 수량 */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-100">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{order.productName}
|
||||
{order.productCode !== '-' ? order.productCode : order.productName}
|
||||
</h3>
|
||||
<div className="text-right">
|
||||
<span className="text-2xl font-bold text-gray-900">{order.quantity}</span>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useCallback, memo } from 'react';
|
||||
import { ChevronDown, ChevronUp, SquarePen, Trash2, ImageIcon } from 'lucide-react';
|
||||
import { ChevronDown, ChevronUp, SquarePen, Trash2 } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -291,45 +291,26 @@ import type { BendingInfo, WipInfo } from './types';
|
||||
function BendingExtraInfo({ info }: { info: BendingInfo }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 도면 + 공통사항 (가로 배치) */}
|
||||
<div className="flex gap-3">
|
||||
{/* 도면 이미지 */}
|
||||
<div className="flex-shrink-0 w-24 h-24 border rounded-lg bg-gray-50 flex items-center justify-center overflow-hidden">
|
||||
{info.drawingUrl ? (
|
||||
<img
|
||||
src={info.drawingUrl}
|
||||
alt="도면"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1 text-gray-400">
|
||||
<ImageIcon className="h-6 w-6" />
|
||||
<span className="text-[10px]">도면</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 공통사항 */}
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<p className="text-xs font-medium text-gray-500">공통사항</p>
|
||||
<div className="space-y-1">
|
||||
<div className="flex gap-2 text-xs">
|
||||
<span className="text-gray-500 w-14">종류</span>
|
||||
<span className="text-gray-900 font-medium">{info.common.kind}</span>
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs">
|
||||
<span className="text-gray-500 w-14">유형</span>
|
||||
<span className="text-gray-900 font-medium">{info.common.type}</span>
|
||||
</div>
|
||||
{info.common.lengthQuantities.map((lq, i) => (
|
||||
<div key={i} className="flex gap-2 text-xs">
|
||||
<span className="text-gray-500 w-14">{i === 0 ? '길이별 수량' : ''}</span>
|
||||
<span className="text-gray-900 font-medium">
|
||||
{formatNumber(lq.length)}mm X {lq.quantity}개
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{/* 공통사항 */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500">공통사항</p>
|
||||
<div className="space-y-1 mt-1.5">
|
||||
<div className="flex gap-2 text-xs">
|
||||
<span className="text-gray-500 w-14">종류</span>
|
||||
<span className="text-gray-900 font-medium">{info.common.kind}</span>
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs">
|
||||
<span className="text-gray-500 w-14">유형</span>
|
||||
<span className="text-gray-900 font-medium">{info.common.type}</span>
|
||||
</div>
|
||||
{info.common.lengthQuantities.map((lq, i) => (
|
||||
<div key={i} className="flex gap-2 text-xs">
|
||||
<span className="text-gray-500 w-14">{i === 0 ? '길이별 수량' : ''}</span>
|
||||
<span className="text-gray-900 font-medium">
|
||||
{formatNumber(lq.length)}mm X {lq.quantity}개
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -361,35 +342,16 @@ function BendingExtraInfo({ info }: { info: BendingInfo }) {
|
||||
// ===== 재공품 전용: 도면 + 공통사항 (규격, 길이별 수량) =====
|
||||
function WipExtraInfo({ info }: { info: WipInfo }) {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
{/* 도면 이미지 (큰 영역) */}
|
||||
<div className="flex-1 min-h-[160px] border rounded-lg bg-gray-50 flex items-center justify-center overflow-hidden">
|
||||
{info.drawingUrl ? (
|
||||
<img
|
||||
src={info.drawingUrl}
|
||||
alt="도면"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1 text-gray-400">
|
||||
<ImageIcon className="h-8 w-8" />
|
||||
<span className="text-xs">IMG</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 공통사항 */}
|
||||
<div className="flex-1 space-y-0">
|
||||
<p className="text-xs font-medium text-gray-500 mb-2">공통사항</p>
|
||||
<div className="border rounded-lg divide-y">
|
||||
<div className="flex">
|
||||
<span className="px-3 py-2.5 text-xs text-gray-500 bg-gray-50 w-20 flex-shrink-0 border-r">규격</span>
|
||||
<span className="px-3 py-2.5 text-xs font-semibold text-gray-900 flex-1 text-right">{info.specification}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="px-3 py-2.5 text-xs text-gray-500 bg-gray-50 w-20 flex-shrink-0 border-r">길이별 수량</span>
|
||||
<span className="px-3 py-2.5 text-xs font-semibold text-gray-900 flex-1 text-right">{info.lengthQuantity}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-2">공통사항</p>
|
||||
<div className="border rounded-lg divide-y">
|
||||
<div className="flex">
|
||||
<span className="px-3 py-2.5 text-xs text-gray-500 bg-gray-50 w-20 flex-shrink-0 border-r">규격</span>
|
||||
<span className="px-3 py-2.5 text-xs font-semibold text-gray-900 flex-1 text-right">{info.specification}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="px-3 py-2.5 text-xs text-gray-500 bg-gray-50 w-20 flex-shrink-0 border-r">길이별 수량</span>
|
||||
<span className="px-3 py-2.5 text-xs font-semibold text-gray-900 flex-1 text-right">{info.lengthQuantity}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
* - 양식 미매핑 시 processType 폴백
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Loader2, Save } from 'lucide-react';
|
||||
import { DocumentViewer } from '@/components/document-system';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
import { getWorkOrderById, getMaterialInputLots } from '../WorkOrders/actions';
|
||||
import { saveWorkLog } from './actions';
|
||||
import { getWorkOrderById, getMaterialInputLots, patchDocumentSnapshot } from '../WorkOrders/actions';
|
||||
import { saveWorkLog, getWorkLog } from './actions';
|
||||
import type { MaterialInputLot } from '../WorkOrders/actions';
|
||||
import type { WorkOrder, ProcessType } from '../WorkOrders/types';
|
||||
import { WorkLogContent } from './WorkLogContent';
|
||||
@@ -63,6 +63,9 @@ export function WorkLogModal({
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const contentWrapperRef = useRef<HTMLDivElement>(null);
|
||||
// Lazy Snapshot 대상 문서 ID
|
||||
const [snapshotDocumentId, setSnapshotDocumentId] = useState<number | null>(null);
|
||||
|
||||
// 목업 WorkOrder 생성
|
||||
const createMockOrder = (id: string, pType?: ProcessType): WorkOrder => ({
|
||||
@@ -115,8 +118,9 @@ export function WorkLogModal({
|
||||
Promise.all([
|
||||
getWorkOrderById(workOrderId),
|
||||
getMaterialInputLots(workOrderId),
|
||||
getWorkLog(workOrderId),
|
||||
])
|
||||
.then(([orderResult, lotsResult]) => {
|
||||
.then(([orderResult, lotsResult, workLogResult]) => {
|
||||
if (orderResult.success && orderResult.data) {
|
||||
setOrder(orderResult.data);
|
||||
} else {
|
||||
@@ -125,6 +129,13 @@ export function WorkLogModal({
|
||||
if (lotsResult.success) {
|
||||
setMaterialLots(lotsResult.data);
|
||||
}
|
||||
// Lazy Snapshot: 문서가 있고 rendered_html이 없으면 스냅샷 대상
|
||||
if (workLogResult.success && workLogResult.data?.document) {
|
||||
const doc = workLogResult.data.document as { id?: number; rendered_html?: string | null };
|
||||
if (doc.id && !doc.rendered_html) {
|
||||
setSnapshotDocumentId(doc.id);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('서버 오류가 발생했습니다.');
|
||||
@@ -136,10 +147,29 @@ export function WorkLogModal({
|
||||
// 모달 닫힐 때 상태 초기화
|
||||
setOrder(null);
|
||||
setMaterialLots([]);
|
||||
setSnapshotDocumentId(null);
|
||||
setError(null);
|
||||
}
|
||||
}, [open, workOrderId, processType]);
|
||||
|
||||
// Lazy Snapshot: 콘텐츠 렌더링 완료 후 rendered_html이 없는 문서에 스냅샷 저장
|
||||
useEffect(() => {
|
||||
if (!snapshotDocumentId || isLoading || !order) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
const html = contentWrapperRef.current?.innerHTML;
|
||||
if (html && html.length > 50) {
|
||||
patchDocumentSnapshot(snapshotDocumentId, html).then((result) => {
|
||||
if (result.success) {
|
||||
setSnapshotDocumentId(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [snapshotDocumentId, isLoading, order]);
|
||||
|
||||
// 저장 핸들러
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!workOrderId || !order) return;
|
||||
@@ -155,9 +185,13 @@ export function WorkLogModal({
|
||||
unit: item.unit || 'EA',
|
||||
}));
|
||||
|
||||
// HTML 스냅샷 캡처 (MNG 출력용)
|
||||
const renderedHtml = contentWrapperRef.current?.innerHTML || undefined;
|
||||
|
||||
const result = await saveWorkLog(workOrderId, {
|
||||
table_data: tableData,
|
||||
title: workLogTemplateName || '작업일지',
|
||||
rendered_html: renderedHtml,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
@@ -255,7 +289,9 @@ export function WorkLogModal({
|
||||
<p className="text-muted-foreground">{error || '데이터를 불러올 수 없습니다.'}</p>
|
||||
</div>
|
||||
) : (
|
||||
renderContent()
|
||||
<div ref={contentWrapperRef}>
|
||||
{renderContent()}
|
||||
</div>
|
||||
)}
|
||||
</DocumentViewer>
|
||||
);
|
||||
|
||||
@@ -74,8 +74,10 @@ export function WorkOrderListPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 제품코드 - 제품명 */}
|
||||
<p className="text-sm text-gray-600 truncate ml-8">{order.productCode} - {order.productName}</p>
|
||||
{/* 제품코드 (제품명) */}
|
||||
<p className="text-sm text-gray-600 truncate ml-8">
|
||||
{order.productCode !== '-' ? order.productCode : order.productName}
|
||||
</p>
|
||||
|
||||
{/* 현장명 + 수량 */}
|
||||
<div className="flex items-center justify-between mt-1.5 ml-8">
|
||||
|
||||
@@ -316,6 +316,8 @@ export async function registerMaterialInput(
|
||||
export interface MaterialForItemInput extends MaterialForInput {
|
||||
alreadyInputted: number; // 이미 투입된 수량
|
||||
remainingRequiredQty: number; // 남은 필요 수량
|
||||
lotInputtedQty: number; // 해당 LOT의 기투입 수량
|
||||
bomGroupKey?: string; // BOM 엔트리별 고유 그룹키 (category+partType 기반)
|
||||
}
|
||||
|
||||
export async function getMaterialsForItem(
|
||||
@@ -330,12 +332,13 @@ export async function getMaterialsForItem(
|
||||
stock_lot_id: number | null; item_id: number; lot_no: string | null;
|
||||
material_code: string; material_name: string; specification: string;
|
||||
unit: string; bom_qty: number; required_qty: number;
|
||||
already_inputted: number; remaining_required_qty: number;
|
||||
already_inputted: number; remaining_required_qty: number; lot_inputted_qty: number;
|
||||
lot_available_qty: number; fifo_rank: number;
|
||||
lot_qty: number; lot_reserved_qty: number;
|
||||
receipt_date: string | null; supplier: string | null;
|
||||
// dynamic_bom 추가 필드
|
||||
work_order_item_id?: number; lot_prefix?: string; part_type?: string; category?: string;
|
||||
bom_group_key?: string;
|
||||
}
|
||||
const result = await executeServerAction<MaterialItemApiItem[]>({
|
||||
url: `${API_URL}/api/v1/work-orders/${workOrderId}/items/${itemId}/materials`,
|
||||
@@ -352,8 +355,10 @@ export async function getMaterialsForItem(
|
||||
fifoRank: item.fifo_rank,
|
||||
alreadyInputted: item.already_inputted,
|
||||
remainingRequiredQty: item.remaining_required_qty,
|
||||
lotInputtedQty: item.lot_inputted_qty ?? 0,
|
||||
workOrderItemId: item.work_order_item_id, lotPrefix: item.lot_prefix,
|
||||
partType: item.part_type, category: item.category,
|
||||
bomGroupKey: item.bom_group_key,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -362,12 +367,13 @@ export async function getMaterialsForItem(
|
||||
export async function registerMaterialInputForItem(
|
||||
workOrderId: string,
|
||||
itemId: number,
|
||||
inputs: { stock_lot_id: number; qty: number }[]
|
||||
inputs: { stock_lot_id: number; qty: number; bom_group_key?: string }[],
|
||||
replace = false
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const result = await executeServerAction({
|
||||
url: `${API_URL}/api/v1/work-orders/${workOrderId}/items/${itemId}/material-inputs`,
|
||||
method: 'POST',
|
||||
body: { inputs },
|
||||
body: { inputs, replace },
|
||||
errorMessage: '개소별 자재 투입 등록에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, error: result.error };
|
||||
@@ -672,9 +678,9 @@ export async function getWorkOrderDetail(
|
||||
}
|
||||
if (opts.is_wip) {
|
||||
workItem.isWip = true;
|
||||
const wi = opts.wip_info as { specification: string; length_quantity: string; drawing_url?: string } | undefined;
|
||||
const wi = opts.wip_info as { specification: string; length_quantity: string } | undefined;
|
||||
if (wi) {
|
||||
workItem.wipInfo = { specification: wi.specification, lengthQuantity: wi.length_quantity, drawingUrl: wi.drawing_url };
|
||||
workItem.wipInfo = { specification: wi.specification, lengthQuantity: wi.length_quantity };
|
||||
}
|
||||
}
|
||||
if (opts.is_joint_bar) {
|
||||
@@ -738,6 +744,7 @@ export async function saveWorkLog(
|
||||
table_data?: Array<Record<string, unknown>>;
|
||||
remarks?: string;
|
||||
title?: string;
|
||||
rendered_html?: string;
|
||||
}
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
|
||||
@@ -45,7 +45,7 @@ import { getMyWorkOrders, completeWorkOrder, saveItemInspection, getWorkOrderIns
|
||||
import type { StepProgressItem, DepartmentOption, DepartmentUser } from './actions';
|
||||
import type { InspectionTemplateData } from './types';
|
||||
import { getProcessList } from '@/components/process-management/actions';
|
||||
import type { InspectionSetting, Process } from '@/types/process';
|
||||
import type { InspectionSetting, InspectionScope, Process } from '@/types/process';
|
||||
import type { WorkOrder } from '../ProductionDashboard/types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import type {
|
||||
@@ -348,15 +348,28 @@ export default function WorkerScreen() {
|
||||
// 작업지시별 단계 진행 캐시: { [workOrderId]: StepProgressItem[] }
|
||||
const [stepProgressMap, setStepProgressMap] = useState<Record<string, StepProgressItem[]>>({});
|
||||
|
||||
// 데이터 로드
|
||||
// 데이터 로드 (작업목록 + 공정목록 + 부서목록 병렬)
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await getMyWorkOrders();
|
||||
if (result.success) {
|
||||
setWorkOrders(result.data);
|
||||
const [workOrderResult, processResult, deptResult] = await Promise.all([
|
||||
getMyWorkOrders(),
|
||||
getProcessList({ size: 100 }),
|
||||
getDepartments(),
|
||||
]);
|
||||
|
||||
if (workOrderResult.success) {
|
||||
setWorkOrders(workOrderResult.data);
|
||||
} else {
|
||||
toast.error(result.error || '작업 목록 조회에 실패했습니다.');
|
||||
toast.error(workOrderResult.error || '작업 목록 조회에 실패했습니다.');
|
||||
}
|
||||
|
||||
if (processResult.success && processResult.data?.items) {
|
||||
setProcessListCache(processResult.data.items);
|
||||
}
|
||||
|
||||
if (deptResult.success) {
|
||||
setDepartmentList(deptResult.data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
@@ -369,10 +382,6 @@ export default function WorkerScreen() {
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
// 부서 목록 로드
|
||||
getDepartments().then((res) => {
|
||||
if (res.success) setDepartmentList(res.data);
|
||||
});
|
||||
}, [loadData]);
|
||||
|
||||
// 부서 선택 시 해당 부서 사용자 목록 로드
|
||||
@@ -416,6 +425,8 @@ export default function WorkerScreen() {
|
||||
const [isInspectionInputModalOpen, setIsInspectionInputModalOpen] = useState(false);
|
||||
// 공정의 중간검사 설정
|
||||
const [currentInspectionSetting, setCurrentInspectionSetting] = useState<InspectionSetting | undefined>();
|
||||
// 공정의 검사 범위 설정
|
||||
const [currentInspectionScope, setCurrentInspectionScope] = useState<InspectionScope | undefined>();
|
||||
// 문서 템플릿 데이터 (document_template 기반 동적 검사용)
|
||||
const [inspectionTemplateData, setInspectionTemplateData] = useState<InspectionTemplateData | undefined>();
|
||||
const [inspectionDimensions, setInspectionDimensions] = useState<{ width?: number; height?: number }>({});
|
||||
@@ -453,21 +464,6 @@ export default function WorkerScreen() {
|
||||
// 공정 목록 캐시
|
||||
const [processListCache, setProcessListCache] = useState<Process[]>([]);
|
||||
|
||||
// 공정 목록 조회 (최초 1회)
|
||||
useEffect(() => {
|
||||
const fetchProcessList = async () => {
|
||||
try {
|
||||
const result = await getProcessList({ size: 100 });
|
||||
if (result.success && result.data?.items) {
|
||||
setProcessListCache(result.data.items);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch process list:', error);
|
||||
}
|
||||
};
|
||||
fetchProcessList();
|
||||
}, []);
|
||||
|
||||
// 활성 공정 목록 (탭용) - 공정관리에서 등록된 활성 공정만
|
||||
const processTabs = useMemo(() => {
|
||||
return processListCache.filter((p) => p.status === '사용중');
|
||||
@@ -513,8 +509,10 @@ export default function WorkerScreen() {
|
||||
(step) => step.needsInspection && step.inspectionSetting
|
||||
);
|
||||
setCurrentInspectionSetting(inspectionStep?.inspectionSetting);
|
||||
setCurrentInspectionScope(inspectionStep?.inspectionScope);
|
||||
} else {
|
||||
setCurrentInspectionSetting(undefined);
|
||||
setCurrentInspectionScope(undefined);
|
||||
}
|
||||
}, [activeTab, processListCache]);
|
||||
|
||||
@@ -714,7 +712,7 @@ export default function WorkerScreen() {
|
||||
workOrderId: selectedOrder.id,
|
||||
itemNo: index + 1,
|
||||
itemCode: selectedOrder.orderNo || '-',
|
||||
itemName: `${selectedOrder.productCode !== '-' ? selectedOrder.productCode + ' - ' : ''}${itemSummary}`,
|
||||
itemName: selectedOrder.productCode !== '-' ? selectedOrder.productCode : itemSummary,
|
||||
floor: (opts.floor as string) || '-',
|
||||
code: (opts.code as string) || '-',
|
||||
width: (opts.width as number) || 0,
|
||||
@@ -740,15 +738,15 @@ export default function WorkerScreen() {
|
||||
detail_parts: { part_name: string; material: string; barcy_info: string }[];
|
||||
};
|
||||
workItem.bendingInfo = {
|
||||
common: { kind: bi.common.kind, type: bi.common.type, lengthQuantities: bi.common.length_quantities || [] },
|
||||
common: { kind: bi.common?.kind || '', type: bi.common?.type || '', lengthQuantities: bi.common?.length_quantities || [] },
|
||||
detailParts: (bi.detail_parts || []).map(dp => ({ partName: dp.part_name, material: dp.material, barcyInfo: dp.barcy_info })),
|
||||
};
|
||||
}
|
||||
if (opts.is_wip) {
|
||||
workItem.isWip = true;
|
||||
const wi = opts.wip_info as { specification: string; length_quantity: string; drawing_url?: string } | undefined;
|
||||
const wi = opts.wip_info as { specification: string; length_quantity: string } | undefined;
|
||||
if (wi) {
|
||||
workItem.wipInfo = { specification: wi.specification, lengthQuantity: wi.length_quantity, drawingUrl: wi.drawing_url };
|
||||
workItem.wipInfo = { specification: wi.specification, lengthQuantity: wi.length_quantity };
|
||||
}
|
||||
}
|
||||
if (opts.is_joint_bar) {
|
||||
@@ -774,7 +772,7 @@ export default function WorkerScreen() {
|
||||
workOrderId: selectedOrder.id,
|
||||
itemNo: 1,
|
||||
itemCode: selectedOrder.orderNo || '-',
|
||||
itemName: `${selectedOrder.productCode !== '-' ? selectedOrder.productCode + ' - ' : ''}${selectedOrder.productName || '-'}`,
|
||||
itemName: selectedOrder.productCode !== '-' ? selectedOrder.productCode : (selectedOrder.productName || '-'),
|
||||
floor: '-',
|
||||
code: '-',
|
||||
width: 0,
|
||||
@@ -809,6 +807,50 @@ export default function WorkerScreen() {
|
||||
return [...apiItems, ...mockItems];
|
||||
}, [filteredWorkOrders, selectedSidebarOrderId, activeProcessTabKey, stepCompletionMap, bendingSubMode, slatSubMode, activeProcessSteps, inputMaterialsMap, stepProgressMap]);
|
||||
|
||||
// ===== 검사 범위(scope) 기반 검사 단계 활성화/비활성화 =====
|
||||
// 수주 단위로 적용: API 아이템(실제 수주 개소)에만 scope 적용
|
||||
// 목업 아이템은 각각 독립 1개소이므로 항상 검사 버튼 유지
|
||||
const scopedWorkItems: WorkItemData[] = useMemo(() => {
|
||||
if (!currentInspectionScope || currentInspectionScope.type === 'all') {
|
||||
return workItems;
|
||||
}
|
||||
|
||||
// 실제 수주 아이템만 분리 (목업 제외)
|
||||
const apiItems = workItems.filter((item) => !item.id.startsWith('mock-'));
|
||||
const apiCount = apiItems.length;
|
||||
if (apiCount === 0) return workItems;
|
||||
|
||||
// 검사 단계를 아예 제거하는 헬퍼
|
||||
const removeInspectionSteps = (item: WorkItemData): WorkItemData => ({
|
||||
...item,
|
||||
steps: item.steps.filter((step) => !step.isInspection && !step.needsInspection),
|
||||
});
|
||||
|
||||
let realIdx = 0;
|
||||
|
||||
if (currentInspectionScope.type === 'sampling') {
|
||||
const sampleSize = currentInspectionScope.sampleSize || 1;
|
||||
return workItems.map((item) => {
|
||||
// 목업은 독립 1개소 → 검사 유지
|
||||
if (item.id.startsWith('mock-')) return item;
|
||||
const isInSampleRange = realIdx >= apiCount - sampleSize;
|
||||
realIdx++;
|
||||
return isInSampleRange ? item : removeInspectionSteps(item);
|
||||
});
|
||||
}
|
||||
|
||||
if (currentInspectionScope.type === 'group') {
|
||||
return workItems.map((item) => {
|
||||
if (item.id.startsWith('mock-')) return item;
|
||||
const isLast = realIdx === apiCount - 1;
|
||||
realIdx++;
|
||||
return isLast ? item : removeInspectionSteps(item);
|
||||
});
|
||||
}
|
||||
|
||||
return workItems;
|
||||
}, [workItems, currentInspectionScope]);
|
||||
|
||||
// ===== 작업지시 선택 시 기존 검사 데이터 로드 =====
|
||||
// workItems 선언 이후에 위치해야 workItems.length 참조 가능
|
||||
// workItems.length 의존성: selectedSidebarOrderId 변경 시점에 workItems가 아직 비어있을 수 있음
|
||||
@@ -827,20 +869,39 @@ export default function WorkerScreen() {
|
||||
const completionUpdates: Record<string, boolean> = {};
|
||||
setInspectionDataMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const apiItem of result.data!.items) {
|
||||
if (!apiItem.inspection_data) continue;
|
||||
// workItems에서 apiItemId가 일치하는 항목 찾기
|
||||
const match = workItems.find((w) => w.apiItemId === apiItem.item_id);
|
||||
if (match) {
|
||||
next.set(match.id, apiItem.inspection_data as unknown as InspectionData);
|
||||
// 검사 step 완료 처리 (실제 step name 사용)
|
||||
const inspStep = match.steps.find((s) => s.isInspection || s.needsInspection);
|
||||
if (inspStep) {
|
||||
const stepKey = `${match.id.replace('-node-', '-')}-${inspStep.name}`;
|
||||
completionUpdates[stepKey] = true;
|
||||
|
||||
// 절곡 공정: 수주 단위 검사 → 어떤 item이든 inspection_data 있으면 모든 개소가 공유
|
||||
const isBendingProcess = workItems.some(w => w.processType === 'bending');
|
||||
if (isBendingProcess) {
|
||||
const bendingItem = result.data!.items.find(i => i.inspection_data);
|
||||
if (bendingItem?.inspection_data) {
|
||||
for (const w of workItems) {
|
||||
next.set(w.id, bendingItem.inspection_data as unknown as InspectionData);
|
||||
const inspStep = w.steps.find((s) => s.isInspection || s.needsInspection);
|
||||
if (inspStep) {
|
||||
const stepKey = `${w.id.replace('-node-', '-')}-${inspStep.name}`;
|
||||
completionUpdates[stepKey] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 기존: item별 개별 매칭
|
||||
for (const apiItem of result.data!.items) {
|
||||
if (!apiItem.inspection_data) continue;
|
||||
// workItems에서 apiItemId가 일치하는 항목 찾기
|
||||
const match = workItems.find((w) => w.apiItemId === apiItem.item_id);
|
||||
if (match) {
|
||||
next.set(match.id, apiItem.inspection_data as unknown as InspectionData);
|
||||
// 검사 step 완료 처리 (실제 step name 사용)
|
||||
const inspStep = match.steps.find((s) => s.isInspection || s.needsInspection);
|
||||
if (inspStep) {
|
||||
const stepKey = `${match.id.replace('-node-', '-')}-${inspStep.name}`;
|
||||
completionUpdates[stepKey] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
// stepCompletionMap 일괄 업데이트
|
||||
@@ -1300,9 +1361,18 @@ export default function WorkerScreen() {
|
||||
`${selectedOrder.id.replace('-node-', '-')}-${stepName}`;
|
||||
|
||||
// 메모리에 즉시 반영
|
||||
// 절곡: 수주 단위 검사 → 모든 개소가 동일한 검사 데이터 공유
|
||||
const inspProcessType = getInspectionProcessType();
|
||||
const isBendingInsp = inspProcessType === 'bending' || inspProcessType === 'bending_wip';
|
||||
setInspectionDataMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(selectedOrder.id, data);
|
||||
if (isBendingInsp) {
|
||||
for (const w of workItems) {
|
||||
next.set(w.id, data);
|
||||
}
|
||||
} else {
|
||||
next.set(selectedOrder.id, data);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
@@ -1402,6 +1472,9 @@ export default function WorkerScreen() {
|
||||
</div>
|
||||
|
||||
{/* 공정별 탭 (공정관리 API 기반 동적 생성) */}
|
||||
{isLoading ? (
|
||||
<ContentSkeleton type="list" rows={1} />
|
||||
) : (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v)}
|
||||
@@ -1601,14 +1674,14 @@ export default function WorkerScreen() {
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
{workItems.map((item, index) => {
|
||||
{scopedWorkItems.map((item, index) => {
|
||||
const isFirstMock = item.id.startsWith('mock-') &&
|
||||
(index === 0 || !workItems[index - 1]?.id.startsWith('mock-'));
|
||||
(index === 0 || !scopedWorkItems[index - 1]?.id.startsWith('mock-'));
|
||||
return (
|
||||
<div key={item.id}>
|
||||
{isFirstMock && (
|
||||
<div className="mb-3 pt-1 space-y-2">
|
||||
{workItems.some((i) => !i.id.startsWith('mock-')) && <div className="border-t border-dashed border-gray-300" />}
|
||||
{scopedWorkItems.some((i) => !i.id.startsWith('mock-')) && <div className="border-t border-dashed border-gray-300" />}
|
||||
<span className="text-[10px] font-semibold text-orange-600 bg-orange-50 px-2 py-0.5 rounded inline-block">
|
||||
목업 데이터
|
||||
</span>
|
||||
@@ -1632,11 +1705,12 @@ export default function WorkerScreen() {
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 하단 고정 버튼 */}
|
||||
{(hasWipItems || activeProcessSettings.needsWorkLog || activeProcessSettings.hasDocumentTemplate) && (
|
||||
<div className={`fixed bottom-4 left-3 right-3 px-3 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:left-auto md:right-[24px] md:px-6 ${sidebarCollapsed ? 'md:left-[113px]' : 'md:left-[304px]'}`}>
|
||||
<div className={`fixed bottom-4 left-4 right-4 px-4 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:px-6 md:right-[24px] ${sidebarCollapsed ? 'md:left-[120px]' : 'md:left-[280px]'}`}>
|
||||
<div className="flex gap-2 md:gap-3">
|
||||
{hasWipItems ? (
|
||||
<Button
|
||||
@@ -1763,12 +1837,13 @@ export default function WorkerScreen() {
|
||||
open={isInspectionInputModalOpen}
|
||||
onOpenChange={setIsInspectionInputModalOpen}
|
||||
processType={getInspectionProcessType()}
|
||||
productName={selectedOrder?.productName || workItems[0]?.itemName || ''}
|
||||
productName={selectedOrder?.productCode && selectedOrder.productCode !== '-' ? selectedOrder.productCode : (selectedOrder?.productName || workItems[0]?.itemName || '')}
|
||||
specification={workItems[0]?.slatJointBarInfo?.specification || workItems[0]?.wipInfo?.specification || ''}
|
||||
initialData={selectedOrder ? inspectionDataMap.get(selectedOrder.id) : undefined}
|
||||
onComplete={handleInspectionComplete}
|
||||
templateData={inspectionTemplateData}
|
||||
workItemDimensions={inspectionDimensions}
|
||||
workOrderId={workItems.find(w => w.id === selectedOrder?.id)?.workOrderId}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
|
||||
@@ -62,7 +62,6 @@ export interface WorkItemData {
|
||||
|
||||
// ===== 재공품 전용 정보 =====
|
||||
export interface WipInfo {
|
||||
drawingUrl?: string; // 도면 이미지 URL
|
||||
specification: string; // 규격 (EGI 1.55T (W576))
|
||||
lengthQuantity: string; // 길이별 수량 (4,000mm X 6개)
|
||||
}
|
||||
@@ -90,7 +89,6 @@ export interface SlatJointBarInfo {
|
||||
|
||||
// ===== 절곡 전용 정보 =====
|
||||
export interface BendingInfo {
|
||||
drawingUrl?: string; // 도면 이미지 URL
|
||||
common: BendingCommonInfo; // 공통사항
|
||||
detailParts: BendingDetailPart[]; // 세부부품
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user