feat: [생산지시] 목록/상세 API 연동 + 작업자 화면 개선

- ProductionOrders 목록/상세 페이지 API 연동
- 절곡 중간검사 입력 모달 (7개 제품 항목 통합)
- 자재투입 다중 BOM 그룹 LOT 독립 관리
- 작업자 화면 제품명 productCode만 표시
- BOM 공정 분류 접이식 카드 UI
- 검사성적서 TemplateInspectionContent API 연동
This commit is contained in:
2026-03-07 03:02:52 +09:00
parent c150d80725
commit 8b6da749a9
24 changed files with 1688 additions and 1156 deletions

View File

@@ -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>

View File

@@ -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,
}}
/>
);
}

View 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: '생산지시 상세 조회에 실패했습니다.',
});
}

View 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;
}

View File

@@ -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 (
<>

View File

@@ -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>

View File

@@ -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

View File

@@ -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,
})));
}
}
// 부적합 내용 로드

View File

@@ -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>

View File

@@ -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,

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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] : '-';
}

View File

@@ -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>
)}
{/* 부적합 내용 */}

View File

@@ -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 ? (

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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">

View File

@@ -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;

View File

@@ -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>
);

View File

@@ -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[]; // 세부부품
}