feat(WEB): 생산/검사 기능 대폭 확장 및 작업자화면 검사입력 추가
생산관리: - WipProductionModal 기능 개선 - WorkOrderDetail/Edit 확장 (+265줄) - 검사성적서 콘텐츠 5종 대폭 확장 (벤딩/벤딩WIP/스크린/슬랫/슬랫조인트바) - InspectionReportModal 기능 강화 작업자화면: - WorkerScreen 기능 대폭 확장 (+211줄) - WorkItemCard 개선 - InspectionInputModal 신규 추가 (작업자 검사입력) 공정관리: - StepForm 검사항목 설정 기능 추가 - InspectionSettingModal 신규 추가 - InspectionPreviewModal 신규 추가 - process.ts 타입 확장 (+102줄) 자재관리: - StockStatus 상세/목록/타입/목데이터 개선 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,7 @@ interface StockDetailData {
|
||||
unit: string;
|
||||
calculatedQty: number;
|
||||
safetyStock: number;
|
||||
wipStatus: 'active' | 'inactive';
|
||||
useStatus: 'active' | 'inactive';
|
||||
}
|
||||
|
||||
@@ -57,7 +58,7 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 폼 데이터 (수정 모드용)
|
||||
// 폼 데이터 (수정 모드용) - wipStatus는 읽기 전용이므로 제외
|
||||
const [formData, setFormData] = useState<{
|
||||
safetyStock: number;
|
||||
useStatus: 'active' | 'inactive';
|
||||
@@ -90,6 +91,7 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
|
||||
unit: data.unit,
|
||||
calculatedQty: data.currentStock, // 재고량
|
||||
safetyStock: data.safetyStock,
|
||||
wipStatus: 'active', // 재공품 상태 (기본값: 사용)
|
||||
useStatus: data.status === null ? 'active' : 'active', // 기본값
|
||||
};
|
||||
setDetail(detailData);
|
||||
@@ -201,8 +203,9 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
|
||||
{renderReadOnlyField('안전재고', detail.safetyStock)}
|
||||
</div>
|
||||
|
||||
{/* Row 3: 상태 */}
|
||||
{/* Row 3: 재공품, 상태 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{renderReadOnlyField('재공품', USE_STATUS_LABELS[detail.wipStatus])}
|
||||
{renderReadOnlyField('상태', USE_STATUS_LABELS[detail.useStatus])}
|
||||
</div>
|
||||
</div>
|
||||
@@ -252,8 +255,11 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: 상태 (수정 가능) */}
|
||||
{/* Row 3: 재공품 (읽기 전용), 상태 (수정 가능) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{/* 재공품 (읽기 전용) */}
|
||||
{renderReadOnlyField('재공품', USE_STATUS_LABELS[detail.wipStatus], true)}
|
||||
{/* 상태 (수정 가능) */}
|
||||
<div>
|
||||
<Label htmlFor="useStatus" className="text-sm text-muted-foreground">
|
||||
상태
|
||||
|
||||
@@ -60,6 +60,7 @@ export function StockStatusList() {
|
||||
// ===== 검색 및 필터 상태 =====
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterValues, setFilterValues] = useState<Record<string, string | string[]>>({
|
||||
wipStatus: 'all',
|
||||
useStatus: 'all',
|
||||
});
|
||||
|
||||
@@ -134,6 +135,7 @@ export function StockStatusList() {
|
||||
{ header: '단위', key: 'unit' },
|
||||
{ header: '재고량', key: 'calculatedQty' },
|
||||
{ header: '안전재고', key: 'safetyStock' },
|
||||
{ header: '재공품', key: 'wipQty' },
|
||||
{ header: '상태', key: 'useStatus', transform: (value) => USE_STATUS_LABELS[value as 'active' | 'inactive'] || '-' },
|
||||
];
|
||||
|
||||
@@ -156,6 +158,7 @@ export function StockStatusList() {
|
||||
actualQty: hasStock ? (parseFloat(String(stock?.actual_qty ?? stock?.stock_qty)) || 0) : 0,
|
||||
stockQty: hasStock ? (parseFloat(String(stock?.stock_qty)) || 0) : 0,
|
||||
safetyStock: hasStock ? (parseFloat(String(stock?.safety_stock)) || 0) : 0,
|
||||
wipQty: hasStock ? (parseFloat(String(stock?.wip_qty)) || 0) : 0,
|
||||
lotCount: hasStock ? (Number(stock?.lot_count) || 0) : 0,
|
||||
lotDaysElapsed: hasStock ? (Number(stock?.days_elapsed) || 0) : 0,
|
||||
status: hasStock ? (stock?.status as StockStatusType | null) : null,
|
||||
@@ -194,14 +197,23 @@ export function StockStatusList() {
|
||||
},
|
||||
];
|
||||
|
||||
// ===== 필터 설정 (전체/사용/미사용) =====
|
||||
// ===== 필터 설정 (재공품, 상태) =====
|
||||
// 참고: IntegratedListTemplateV2에서 자동으로 '전체' 옵션을 추가하므로 options에서 제외
|
||||
const filterConfig: FilterFieldConfig[] = [
|
||||
{
|
||||
key: 'wipStatus',
|
||||
label: '재공품',
|
||||
type: 'single',
|
||||
options: [
|
||||
{ value: 'active', label: '사용' },
|
||||
{ value: 'inactive', label: '미사용' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'useStatus',
|
||||
label: '상태',
|
||||
type: 'single',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'active', label: '사용' },
|
||||
{ value: 'inactive', label: '미사용' },
|
||||
],
|
||||
@@ -219,6 +231,7 @@ export function StockStatusList() {
|
||||
{ key: 'unit', label: '단위', className: 'w-[60px] text-center' },
|
||||
{ key: 'calculatedQty', label: '재고량', className: 'w-[80px] text-center' },
|
||||
{ key: 'safetyStock', label: '안전재고', className: 'w-[80px] text-center' },
|
||||
{ key: 'wipQty', label: '재공품', className: 'w-[80px] text-center' },
|
||||
{ key: 'useStatus', label: '상태', className: 'w-[80px] text-center' },
|
||||
];
|
||||
|
||||
@@ -250,6 +263,7 @@ export function StockStatusList() {
|
||||
<TableCell className="text-center">{item.unit}</TableCell>
|
||||
<TableCell className="text-center">{item.calculatedQty}</TableCell>
|
||||
<TableCell className="text-center">{item.safetyStock}</TableCell>
|
||||
<TableCell className="text-center">{item.wipQty}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={item.useStatus === 'inactive' ? 'text-gray-400' : ''}>
|
||||
{USE_STATUS_LABELS[item.useStatus]}
|
||||
@@ -296,6 +310,7 @@ export function StockStatusList() {
|
||||
<InfoField label="단위" value={item.unit} />
|
||||
<InfoField label="재고량" value={`${item.calculatedQty}`} />
|
||||
<InfoField label="안전재고" value={`${item.safetyStock}`} />
|
||||
<InfoField label="재공품" value={`${item.wipQty}`} />
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
@@ -361,6 +376,13 @@ export function StockStatusList() {
|
||||
customFilterFn: (items, fv) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
return items.filter((item) => {
|
||||
// 재공품 필터 (사용: wipQty > 0, 미사용: wipQty === 0)
|
||||
const wipStatusVal = fv.wipStatus as string;
|
||||
if (wipStatusVal && wipStatusVal !== 'all') {
|
||||
if (wipStatusVal === 'active' && item.wipQty === 0) return false;
|
||||
if (wipStatusVal === 'inactive' && item.wipQty > 0) return false;
|
||||
}
|
||||
// 상태 필터
|
||||
const useStatusVal = fv.useStatus as string;
|
||||
if (useStatusVal && useStatusVal !== 'all' && item.useStatus !== useStatusVal) {
|
||||
return false;
|
||||
|
||||
@@ -137,6 +137,7 @@ function transformApiToListItem(data: ItemApiData): StockItem {
|
||||
actualQty: hasStock ? (parseFloat(String((stock as unknown as Record<string, unknown>).actual_qty ?? stock.stock_qty)) || 0) : 0,
|
||||
stockQty: hasStock ? (parseFloat(String(stock.stock_qty)) || 0) : 0,
|
||||
safetyStock: hasStock ? (parseFloat(String(stock.safety_stock)) || 0) : 0,
|
||||
wipQty: hasStock ? (parseFloat(String((stock as unknown as Record<string, unknown>).wip_qty)) || 0) : 0,
|
||||
lotCount: hasStock ? (stock.lot_count || 0) : 0,
|
||||
lotDaysElapsed: hasStock ? (stock.days_elapsed || 0) : 0,
|
||||
status: hasStock ? stock.status : null,
|
||||
|
||||
@@ -51,6 +51,7 @@ const rawMaterialItems: StockItem[] = [
|
||||
actualQty: 500,
|
||||
stockQty: 500,
|
||||
safetyStock: 100,
|
||||
wipQty: 30,
|
||||
lotCount: 3,
|
||||
lotDaysElapsed: 21,
|
||||
status: 'normal',
|
||||
@@ -70,6 +71,7 @@ const rawMaterialItems: StockItem[] = [
|
||||
actualQty: 350,
|
||||
stockQty: 350,
|
||||
safetyStock: 80,
|
||||
wipQty: 20,
|
||||
lotCount: 2,
|
||||
lotDaysElapsed: 15,
|
||||
status: 'normal',
|
||||
@@ -89,6 +91,7 @@ const rawMaterialItems: StockItem[] = [
|
||||
actualQty: 280,
|
||||
stockQty: 280,
|
||||
safetyStock: 70,
|
||||
wipQty: 15,
|
||||
lotCount: 2,
|
||||
lotDaysElapsed: 18,
|
||||
status: 'normal',
|
||||
@@ -108,6 +111,7 @@ const rawMaterialItems: StockItem[] = [
|
||||
actualQty: 420,
|
||||
stockQty: 420,
|
||||
safetyStock: 90,
|
||||
wipQty: 25,
|
||||
lotCount: 4,
|
||||
lotDaysElapsed: 12,
|
||||
status: 'normal',
|
||||
@@ -139,6 +143,7 @@ const bentPartItems: StockItem[] = Array.from({ length: 41 }, (_, i) => {
|
||||
actualQty: stockQty,
|
||||
stockQty,
|
||||
safetyStock,
|
||||
wipQty: seededInt(seed + 5, 0, 50),
|
||||
lotCount: seededInt(seed + 2, 1, 5),
|
||||
lotDaysElapsed: seededInt(seed + 3, 0, 45),
|
||||
status: getStockStatus(stockQty, safetyStock),
|
||||
@@ -172,6 +177,7 @@ const purchasedPartItems: StockItem[] = [
|
||||
actualQty: stockQty,
|
||||
stockQty,
|
||||
safetyStock,
|
||||
wipQty: seededInt(seed + 5, 0, 30),
|
||||
lotCount: seededInt(seed + 2, 2, 5),
|
||||
lotDaysElapsed: seededInt(seed + 3, 0, 40),
|
||||
status: getStockStatus(stockQty, safetyStock),
|
||||
@@ -202,6 +208,7 @@ const purchasedPartItems: StockItem[] = [
|
||||
actualQty: stockQty,
|
||||
stockQty,
|
||||
safetyStock,
|
||||
wipQty: seededInt(seed + 5, 0, 25),
|
||||
lotCount: seededInt(seed + 2, 2, 4),
|
||||
lotDaysElapsed: seededInt(seed + 3, 0, 35),
|
||||
status: getStockStatus(stockQty, safetyStock),
|
||||
@@ -234,6 +241,7 @@ const purchasedPartItems: StockItem[] = [
|
||||
actualQty: stockQty,
|
||||
stockQty,
|
||||
safetyStock,
|
||||
wipQty: seededInt(seed + 5, 0, 10),
|
||||
lotCount: seededInt(seed + 2, 1, 3),
|
||||
lotDaysElapsed: seededInt(seed + 3, 0, 30),
|
||||
status: getStockStatus(stockQty, safetyStock),
|
||||
@@ -264,6 +272,7 @@ const purchasedPartItems: StockItem[] = [
|
||||
actualQty: stockQty,
|
||||
stockQty,
|
||||
safetyStock,
|
||||
wipQty: seededInt(seed + 5, 0, 100),
|
||||
lotCount: seededInt(seed + 2, 3, 6),
|
||||
lotDaysElapsed: seededInt(seed + 3, 0, 25),
|
||||
status: getStockStatus(stockQty, safetyStock),
|
||||
@@ -292,6 +301,7 @@ const purchasedPartItems: StockItem[] = [
|
||||
actualQty: stockQty,
|
||||
stockQty,
|
||||
safetyStock,
|
||||
wipQty: seededInt(seed + 5, 0, 20),
|
||||
lotCount: seededInt(seed + 2, 2, 4),
|
||||
lotDaysElapsed: seededInt(seed + 3, 0, 20),
|
||||
status: getStockStatus(stockQty, safetyStock),
|
||||
@@ -322,6 +332,7 @@ const purchasedPartItems: StockItem[] = [
|
||||
actualQty: stockQty,
|
||||
stockQty,
|
||||
safetyStock,
|
||||
wipQty: seededInt(seed + 5, 0, 40),
|
||||
lotCount: seededInt(seed + 2, 2, 5),
|
||||
lotDaysElapsed: seededInt(seed + 3, 0, 30),
|
||||
status: getStockStatus(stockQty, safetyStock),
|
||||
@@ -346,6 +357,7 @@ const subMaterialItems: StockItem[] = [
|
||||
actualQty: 5000,
|
||||
stockQty: 5000,
|
||||
safetyStock: 1000,
|
||||
wipQty: 100,
|
||||
lotCount: 3,
|
||||
lotDaysElapsed: 28,
|
||||
status: 'normal',
|
||||
@@ -365,6 +377,7 @@ const subMaterialItems: StockItem[] = [
|
||||
actualQty: 120,
|
||||
stockQty: 120,
|
||||
safetyStock: 30,
|
||||
wipQty: 10,
|
||||
lotCount: 1,
|
||||
lotDaysElapsed: 5,
|
||||
status: 'normal',
|
||||
@@ -384,6 +397,7 @@ const subMaterialItems: StockItem[] = [
|
||||
actualQty: 800,
|
||||
stockQty: 800,
|
||||
safetyStock: 200,
|
||||
wipQty: 50,
|
||||
lotCount: 2,
|
||||
lotDaysElapsed: 12,
|
||||
status: 'normal',
|
||||
@@ -403,6 +417,7 @@ const subMaterialItems: StockItem[] = [
|
||||
actualQty: 200,
|
||||
stockQty: 200,
|
||||
safetyStock: 50,
|
||||
wipQty: 15,
|
||||
lotCount: 5,
|
||||
lotDaysElapsed: 37,
|
||||
status: 'normal',
|
||||
@@ -422,6 +437,7 @@ const subMaterialItems: StockItem[] = [
|
||||
actualQty: 150,
|
||||
stockQty: 150,
|
||||
safetyStock: 40,
|
||||
wipQty: 8,
|
||||
lotCount: 2,
|
||||
lotDaysElapsed: 10,
|
||||
status: 'normal',
|
||||
@@ -441,6 +457,7 @@ const subMaterialItems: StockItem[] = [
|
||||
actualQty: 3000,
|
||||
stockQty: 3000,
|
||||
safetyStock: 500,
|
||||
wipQty: 200,
|
||||
lotCount: 4,
|
||||
lotDaysElapsed: 8,
|
||||
status: 'normal',
|
||||
@@ -460,6 +477,7 @@ const subMaterialItems: StockItem[] = [
|
||||
actualQty: 2500,
|
||||
stockQty: 2500,
|
||||
safetyStock: 400,
|
||||
wipQty: 150,
|
||||
lotCount: 3,
|
||||
lotDaysElapsed: 15,
|
||||
status: 'normal',
|
||||
@@ -483,6 +501,7 @@ const consumableItems: StockItem[] = [
|
||||
actualQty: 200,
|
||||
stockQty: 200,
|
||||
safetyStock: 50,
|
||||
wipQty: 20,
|
||||
lotCount: 2,
|
||||
lotDaysElapsed: 8,
|
||||
status: 'normal',
|
||||
@@ -502,6 +521,7 @@ const consumableItems: StockItem[] = [
|
||||
actualQty: 350,
|
||||
stockQty: 350,
|
||||
safetyStock: 80,
|
||||
wipQty: 30,
|
||||
lotCount: 3,
|
||||
lotDaysElapsed: 5,
|
||||
status: 'normal',
|
||||
|
||||
@@ -64,6 +64,7 @@ export interface StockItem {
|
||||
actualQty: number; // 실제 재고량 (Stock.actual_qty)
|
||||
stockQty: number; // Stock.stock_qty (없으면 0)
|
||||
safetyStock: number; // Stock.safety_stock (없으면 0)
|
||||
wipQty: number; // 재공품 수량 (Stock.wip_qty, 없으면 0)
|
||||
lotCount: number; // Stock.lot_count (없으면 0)
|
||||
lotDaysElapsed: number; // Stock.days_elapsed (없으면 0)
|
||||
status: StockStatusType | null; // Stock.status (없으면 null)
|
||||
|
||||
282
src/components/process-management/InspectionPreviewModal.tsx
Normal file
282
src/components/process-management/InspectionPreviewModal.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 중간검사 미리보기 모달
|
||||
*
|
||||
* 설정된 검사 항목들로 실제 성적서가 어떻게 보일지 미리보기
|
||||
*/
|
||||
|
||||
import { Fragment } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { InspectionSetting } from '@/types/process';
|
||||
|
||||
interface InspectionPreviewModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
inspectionSetting?: InspectionSetting;
|
||||
}
|
||||
|
||||
export function InspectionPreviewModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
inspectionSetting,
|
||||
}: InspectionPreviewModalProps) {
|
||||
if (!inspectionSetting) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>중간검사 미리보기</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
검사 설정이 없습니다. 먼저 검사 설정을 완료해주세요.
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// 활성화된 겉모양 항목들
|
||||
const activeAppearanceItems = [
|
||||
{ key: 'bendingStatus', label: '절곡상태', enabled: inspectionSetting.appearance.bendingStatus.enabled },
|
||||
{ key: 'processingStatus', label: '가공상태', enabled: inspectionSetting.appearance.processingStatus.enabled },
|
||||
{ key: 'sewingStatus', label: '재봉상태', enabled: inspectionSetting.appearance.sewingStatus.enabled },
|
||||
{ key: 'assemblyStatus', label: '조립상태', enabled: inspectionSetting.appearance.assemblyStatus.enabled },
|
||||
].filter((item) => item.enabled);
|
||||
|
||||
// 활성화된 치수 항목들
|
||||
const activeDimensionItems = [
|
||||
{ key: 'length', label: '길이', ...inspectionSetting.dimension.length },
|
||||
{ key: 'width', label: '너비', ...inspectionSetting.dimension.width },
|
||||
{ key: 'height1', label: '1 높이', ...inspectionSetting.dimension.height1 },
|
||||
{ key: 'height2', label: '2 높이', ...inspectionSetting.dimension.height2 },
|
||||
{ key: 'gap', label: '간격', ...inspectionSetting.dimension.gap },
|
||||
].filter((item) => item.enabled);
|
||||
|
||||
// 샘플 데이터 (미리보기용)
|
||||
const sampleRows = [1, 2, 3, 4, 5];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[95vw] max-w-[1400px] sm:max-w-[1400px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>중간검사 미리보기</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 mt-4">
|
||||
{/* 헤더 정보 */}
|
||||
<div className="flex items-center gap-4 p-4 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">기준서명:</span>
|
||||
<Badge variant="outline">{inspectionSetting.standardName || '미설정'}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">활성 항목:</span>
|
||||
<Badge>{activeAppearanceItems.length + activeDimensionItems.length}개</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 중간검사 기준서 */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="bg-muted/50 px-4 py-2 font-semibold text-sm border-b">
|
||||
중간검사 기준서
|
||||
</div>
|
||||
<div className="p-4 grid grid-cols-2 gap-4">
|
||||
{/* 도해 이미지 영역 */}
|
||||
<div className="border rounded-lg p-4 min-h-[200px] flex items-center justify-center bg-muted/30">
|
||||
{inspectionSetting.schematicImage ? (
|
||||
<img
|
||||
src={inspectionSetting.schematicImage}
|
||||
alt="도해 이미지"
|
||||
className="max-w-full max-h-[180px] object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">도해 이미지 없음</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 검사기준 이미지 또는 검사 항목 테이블 */}
|
||||
<div className="border rounded-lg overflow-hidden min-h-[200px] flex items-center justify-center bg-muted/30">
|
||||
{inspectionSetting.inspectionStandardImage ? (
|
||||
<img
|
||||
src={inspectionSetting.inspectionStandardImage}
|
||||
alt="검사기준 이미지"
|
||||
className="max-w-full max-h-[180px] object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="border-b px-3 py-2 text-left">검사항목</th>
|
||||
<th className="border-b px-3 py-2 text-left">검사방법</th>
|
||||
<th className="border-b px-3 py-2 text-left">포인트</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{activeAppearanceItems.map((item) => (
|
||||
<tr key={item.key} className="border-b last:border-b-0">
|
||||
<td className="px-3 py-2">{item.label}</td>
|
||||
<td className="px-3 py-2">양자택일</td>
|
||||
<td className="px-3 py-2">-</td>
|
||||
</tr>
|
||||
))}
|
||||
{activeDimensionItems.map((item) => (
|
||||
<tr key={item.key} className="border-b last:border-b-0">
|
||||
<td className="px-3 py-2">{item.label}</td>
|
||||
<td className="px-3 py-2">{item.method}</td>
|
||||
<td className="px-3 py-2">{item.point}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 중간검사 DATA */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="bg-muted/50 px-4 py-2 font-semibold text-sm border-b">
|
||||
중간검사 DATA
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="border-b border-r px-3 py-2 text-center w-12">No.</th>
|
||||
{/* 겉모양 항목들 */}
|
||||
{activeAppearanceItems.map((item) => (
|
||||
<th
|
||||
key={item.key}
|
||||
className="border-b border-r px-3 py-2 text-center min-w-[80px]"
|
||||
>
|
||||
{item.label}
|
||||
</th>
|
||||
))}
|
||||
{/* 치수 항목들 */}
|
||||
{activeDimensionItems.map((item) => (
|
||||
<th
|
||||
key={item.key}
|
||||
className="border-b border-r px-3 py-2 text-center"
|
||||
colSpan={2}
|
||||
>
|
||||
{item.label} (mm)
|
||||
</th>
|
||||
))}
|
||||
{/* 판정 */}
|
||||
{inspectionSetting.judgment && (
|
||||
<th className="border-b px-3 py-2 text-center w-20">
|
||||
판정
|
||||
<br />
|
||||
<span className="text-xs">(적/부)</span>
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
{/* 치수 서브헤더 */}
|
||||
{activeDimensionItems.length > 0 && (
|
||||
<tr className="bg-muted/30">
|
||||
<th className="border-b border-r px-3 py-1"></th>
|
||||
{activeAppearanceItems.map((item) => (
|
||||
<th key={item.key} className="border-b border-r px-3 py-1 text-xs">
|
||||
양호/불량
|
||||
</th>
|
||||
))}
|
||||
{activeDimensionItems.map((item) => (
|
||||
<Fragment key={`${item.key}-header`}>
|
||||
<th className="border-b border-r px-3 py-1 text-xs">
|
||||
도면치수
|
||||
</th>
|
||||
<th className="border-b border-r px-3 py-1 text-xs">
|
||||
측정값
|
||||
</th>
|
||||
</Fragment>
|
||||
))}
|
||||
{inspectionSetting.judgment && (
|
||||
<th className="border-b px-3 py-1"></th>
|
||||
)}
|
||||
</tr>
|
||||
)}
|
||||
</thead>
|
||||
<tbody>
|
||||
{sampleRows.map((row) => (
|
||||
<tr key={row} className="border-b last:border-b-0 hover:bg-muted/20">
|
||||
<td className="border-r px-3 py-2 text-center">{row}</td>
|
||||
{/* 겉모양 샘플 데이터 */}
|
||||
{activeAppearanceItems.map((item) => (
|
||||
<td key={item.key} className="border-r px-3 py-2 text-center">
|
||||
<span className="text-muted-foreground">☐ 양호</span>
|
||||
<br />
|
||||
<span className="text-muted-foreground">☐ 불량</span>
|
||||
</td>
|
||||
))}
|
||||
{/* 치수 샘플 데이터 */}
|
||||
{activeDimensionItems.map((item) => (
|
||||
<Fragment key={`${item.key}-data-${row}`}>
|
||||
<td className="border-r px-3 py-2 text-center text-muted-foreground">
|
||||
-
|
||||
</td>
|
||||
<td className="border-r px-3 py-2 text-center text-muted-foreground">
|
||||
-
|
||||
</td>
|
||||
</Fragment>
|
||||
))}
|
||||
{/* 판정 샘플 */}
|
||||
{inspectionSetting.judgment && (
|
||||
<td className="px-3 py-2 text-center">
|
||||
<span className="text-muted-foreground">-</span>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 부적합 내용 */}
|
||||
{inspectionSetting.nonConformingContent && (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="bg-muted/50 px-4 py-2 font-semibold text-sm border-r">
|
||||
부적합 내용
|
||||
</div>
|
||||
<div className="bg-muted/50 px-4 py-2 font-semibold text-sm">
|
||||
종합판정
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="px-4 py-3 border-r min-h-[60px] text-muted-foreground text-sm">
|
||||
(부적합 사항 입력 영역)
|
||||
</div>
|
||||
<div className="px-4 py-3 text-center text-muted-foreground text-sm">
|
||||
합격 / 불합격
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex justify-end mt-6">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
293
src/components/process-management/InspectionSettingModal.tsx
Normal file
293
src/components/process-management/InspectionSettingModal.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 중간검사 설정 모달
|
||||
*
|
||||
* 기획서 Page 9 기준:
|
||||
* - 왼쪽: 기준서명, 도해 이미지, 검사기준 이미지, 겉모양 항목들 ON/OFF
|
||||
* - 오른쪽: 치수 항목들 (포인트, 방법, ON/OFF), 판정, 부적합 내용
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { ImageUpload } from '@/components/ui/image-upload';
|
||||
import type {
|
||||
InspectionSetting,
|
||||
InspectionPointType,
|
||||
InspectionMethodType,
|
||||
} from '@/types/process';
|
||||
import {
|
||||
INSPECTION_POINT_OPTIONS,
|
||||
INSPECTION_METHOD_OPTIONS,
|
||||
DEFAULT_INSPECTION_SETTING,
|
||||
} from '@/types/process';
|
||||
|
||||
interface InspectionSettingModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
initialData?: InspectionSetting;
|
||||
onSave: (data: InspectionSetting) => void;
|
||||
}
|
||||
|
||||
export function InspectionSettingModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
initialData,
|
||||
onSave,
|
||||
}: InspectionSettingModalProps) {
|
||||
const [formData, setFormData] = useState<InspectionSetting>(
|
||||
initialData || DEFAULT_INSPECTION_SETTING
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFormData(initialData || DEFAULT_INSPECTION_SETTING);
|
||||
}
|
||||
}, [open, initialData]);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(formData);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
// 겉모양 항목 토글
|
||||
const toggleAppearance = (key: keyof typeof formData.appearance) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
appearance: {
|
||||
...prev.appearance,
|
||||
[key]: { ...prev.appearance[key], enabled: !prev.appearance[key].enabled },
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
// 치수 항목 토글
|
||||
const toggleDimension = (key: keyof typeof formData.dimension) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
dimension: {
|
||||
...prev.dimension,
|
||||
[key]: { ...prev.dimension[key], enabled: !prev.dimension[key].enabled },
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
// 치수 포인트 변경
|
||||
const setDimensionPoint = (
|
||||
key: keyof typeof formData.dimension,
|
||||
point: InspectionPointType
|
||||
) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
dimension: {
|
||||
...prev.dimension,
|
||||
[key]: { ...prev.dimension[key], point },
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
// 치수 방법 변경
|
||||
const setDimensionMethod = (
|
||||
key: keyof typeof formData.dimension,
|
||||
method: InspectionMethodType
|
||||
) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
dimension: {
|
||||
...prev.dimension,
|
||||
[key]: { ...prev.dimension[key], method },
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[95vw] max-w-[1200px] sm:max-w-[1200px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>중간검사 설정</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-4">
|
||||
{/* 왼쪽 패널 - 기본 정보 및 겉모양 */}
|
||||
<div className="space-y-6 border rounded-lg p-4 bg-muted/30">
|
||||
<h3 className="font-semibold text-sm border-b pb-2">중간검사 설정</h3>
|
||||
|
||||
{/* 기준서명 */}
|
||||
<div className="space-y-2">
|
||||
<Label>기준서명</Label>
|
||||
<Input
|
||||
value={formData.standardName}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, standardName: e.target.value }))
|
||||
}
|
||||
placeholder="예: KDPS-20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 중간검사 기준서 이미지 (통합) */}
|
||||
<div className="space-y-2">
|
||||
<Label>중간검사 기준서 이미지</Label>
|
||||
<ImageUpload
|
||||
value={formData.schematicImage}
|
||||
onChange={(file) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
// 두 필드 모두 동일 이미지로 설정 (호환성)
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
schematicImage: url,
|
||||
inspectionStandardImage: url,
|
||||
}));
|
||||
}}
|
||||
onRemove={() => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
schematicImage: undefined,
|
||||
inspectionStandardImage: undefined,
|
||||
}));
|
||||
}}
|
||||
aspectRatio="wide"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
hint="도해 및 검사기준이 포함된 통합 이미지를 업로드하세요"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 겉모양 항목들 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>겉모양 절곡상태</Label>
|
||||
<Switch
|
||||
checked={formData.appearance.bendingStatus.enabled}
|
||||
onCheckedChange={() => toggleAppearance('bendingStatus')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>겉모양 가공상태</Label>
|
||||
<Switch
|
||||
checked={formData.appearance.processingStatus.enabled}
|
||||
onCheckedChange={() => toggleAppearance('processingStatus')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>겉모양 재봉상태</Label>
|
||||
<Switch
|
||||
checked={formData.appearance.sewingStatus.enabled}
|
||||
onCheckedChange={() => toggleAppearance('sewingStatus')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>겉모양 조립상태</Label>
|
||||
<Switch
|
||||
checked={formData.appearance.assemblyStatus.enabled}
|
||||
onCheckedChange={() => toggleAppearance('assemblyStatus')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽 패널 - 치수 및 기타 */}
|
||||
<div className="space-y-6 border rounded-lg p-4 bg-muted/30">
|
||||
<h3 className="font-semibold text-sm border-b pb-2">중간검사</h3>
|
||||
|
||||
{/* 치수 항목들 */}
|
||||
{[
|
||||
{ key: 'length' as const, label: '길이' },
|
||||
{ key: 'width' as const, label: '너비' },
|
||||
{ key: 'height1' as const, label: '1 높이' },
|
||||
{ key: 'height2' as const, label: '2 높이' },
|
||||
{ key: 'gap' as const, label: '간격' },
|
||||
].map(({ key, label }) => (
|
||||
<div key={key} className="grid grid-cols-[60px_1fr_100px_50px] items-center gap-3">
|
||||
<Label className="shrink-0">{label}</Label>
|
||||
<Select
|
||||
value={formData.dimension[key].point}
|
||||
onValueChange={(v) => setDimensionPoint(key, v as InspectionPointType)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{INSPECTION_POINT_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={formData.dimension[key].method}
|
||||
onValueChange={(v) => setDimensionMethod(key, v as InspectionMethodType)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{INSPECTION_METHOD_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Switch
|
||||
checked={formData.dimension[key].enabled}
|
||||
onCheckedChange={() => toggleDimension(key)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 판정 */}
|
||||
<div className="flex items-center justify-between pt-4 border-t">
|
||||
<Label>판정</Label>
|
||||
<Switch
|
||||
checked={formData.judgment}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData((prev) => ({ ...prev, judgment: checked }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 부적합 내용 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>부적합 내용</Label>
|
||||
<Switch
|
||||
checked={formData.nonConformingContent}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData((prev) => ({ ...prev, nonConformingContent: checked }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave}>저장</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { useRouter } from 'next/navigation';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -23,14 +24,23 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { toast } from 'sonner';
|
||||
import type { ProcessStep, StepConnectionType, StepCompletionType } from '@/types/process';
|
||||
import { Settings, Eye } from 'lucide-react';
|
||||
import type {
|
||||
ProcessStep,
|
||||
StepConnectionType,
|
||||
StepCompletionType,
|
||||
InspectionSetting,
|
||||
} from '@/types/process';
|
||||
import {
|
||||
STEP_CONNECTION_TYPE_OPTIONS,
|
||||
STEP_COMPLETION_TYPE_OPTIONS,
|
||||
STEP_CONNECTION_TARGET_OPTIONS,
|
||||
DEFAULT_INSPECTION_SETTING,
|
||||
} from '@/types/process';
|
||||
import { createProcessStep, updateProcessStep } from './actions';
|
||||
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types';
|
||||
import { InspectionSettingModal } from './InspectionSettingModal';
|
||||
import { InspectionPreviewModal } from './InspectionPreviewModal';
|
||||
|
||||
const stepCreateConfig: DetailConfig = {
|
||||
title: '단계',
|
||||
@@ -94,8 +104,20 @@ export function StepForm({ mode, processId, initialData }: StepFormProps) {
|
||||
initialData?.completionType || '클릭 시 완료'
|
||||
);
|
||||
|
||||
// 검사 설정
|
||||
const [inspectionSetting, setInspectionSetting] = useState<InspectionSetting>(
|
||||
initialData?.inspectionSetting || DEFAULT_INSPECTION_SETTING
|
||||
);
|
||||
|
||||
// 모달 상태
|
||||
const [isInspectionSettingOpen, setIsInspectionSettingOpen] = useState(false);
|
||||
const [isInspectionPreviewOpen, setIsInspectionPreviewOpen] = useState(false);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 검사여부가 "필요"인지 확인
|
||||
const isInspectionEnabled = needsInspection === '필요';
|
||||
|
||||
// 제출
|
||||
const handleSubmit = async (): Promise<{ success: boolean; error?: string }> => {
|
||||
if (!stepName.trim()) {
|
||||
@@ -114,6 +136,7 @@ export function StepForm({ mode, processId, initialData }: StepFormProps) {
|
||||
connectionType,
|
||||
connectionTarget: connectionType === '팝업' ? connectionTarget : undefined,
|
||||
completionType,
|
||||
inspectionSetting: isInspectionEnabled ? inspectionSetting : undefined,
|
||||
};
|
||||
|
||||
setIsLoading(true);
|
||||
@@ -236,7 +259,7 @@ export function StepForm({ mode, processId, initialData }: StepFormProps) {
|
||||
<CardTitle className="text-base">연결 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 items-end">
|
||||
<div className="space-y-2">
|
||||
<Label>유형</Label>
|
||||
<Select
|
||||
@@ -270,6 +293,28 @@ export function StepForm({ mode, processId, initialData }: StepFormProps) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 검사여부가 "필요"일 때 버튼 표시 */}
|
||||
{isInspectionEnabled && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
className="bg-amber-500 hover:bg-amber-600 text-white"
|
||||
onClick={() => setIsInspectionSettingOpen(true)}
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
검사 설정
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsInspectionPreviewOpen(true)}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
검사 미리보기
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -314,19 +359,37 @@ export function StepForm({ mode, processId, initialData }: StepFormProps) {
|
||||
connectionTarget,
|
||||
completionType,
|
||||
initialData?.stepCode,
|
||||
isInspectionEnabled,
|
||||
]
|
||||
);
|
||||
|
||||
const config = isEdit ? stepEditConfig : stepCreateConfig;
|
||||
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
config={config}
|
||||
mode={isEdit ? 'edit' : 'create'}
|
||||
isLoading={false}
|
||||
onCancel={handleCancel}
|
||||
onSubmit={handleSubmit}
|
||||
renderForm={renderFormContent}
|
||||
/>
|
||||
<>
|
||||
<IntegratedDetailTemplate
|
||||
config={config}
|
||||
mode={isEdit ? 'edit' : 'create'}
|
||||
isLoading={false}
|
||||
onCancel={handleCancel}
|
||||
onSubmit={handleSubmit}
|
||||
renderForm={renderFormContent}
|
||||
/>
|
||||
|
||||
{/* 검사 설정 모달 */}
|
||||
<InspectionSettingModal
|
||||
open={isInspectionSettingOpen}
|
||||
onOpenChange={setIsInspectionSettingOpen}
|
||||
initialData={inspectionSetting}
|
||||
onSave={setInspectionSetting}
|
||||
/>
|
||||
|
||||
{/* 검사 미리보기 모달 */}
|
||||
<InspectionPreviewModal
|
||||
open={isInspectionPreviewOpen}
|
||||
onOpenChange={setIsInspectionPreviewOpen}
|
||||
inspectionSetting={isInspectionEnabled ? inspectionSetting : undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
/**
|
||||
* 재공품 생산 모달
|
||||
*
|
||||
* 기획서 기준:
|
||||
* - 품목 선택 (검색) → 재공품 목록 테이블 추가
|
||||
* 기획서 기준 (스크린샷 2026-02-05 오후 6.59.14):
|
||||
* - 품목 선택: 검색창 + 검색 결과 테이블 (바로 표시)
|
||||
* - 테이블: 품목코드, 품목명, 규격, 단위, 재고량, 안전재고, 수량(입력)
|
||||
* - 행 삭제 (X 버튼)
|
||||
* - 우선순위: 긴급/우선/일반 토글 (디폴트: 일반)
|
||||
* - "총 N건" 표시
|
||||
* - 우선순위: 긴급(검정)/우선(검정)/일반(주황) 토글
|
||||
* - 부서 Select (디폴트: 생산부서)
|
||||
* - 비고 Textarea
|
||||
* - 하단: 취소 / 생산지시 확정
|
||||
* - 하단: 취소(검정) / 생산지시 확정(주황)
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -52,10 +52,13 @@ type Priority = '긴급' | '우선' | '일반';
|
||||
|
||||
// Mock 재공품 데이터 (품목관리 > 재공품/사용 상태 '사용' 품목)
|
||||
const MOCK_WIP_ITEMS: Omit<WipItem, 'quantity'>[] = [
|
||||
{ id: 'wip-1', itemCode: 'WIP-GR-001', itemName: '가이드레일(벽면형)', specification: '120X70 EGI 1.6T', unit: 'EA', stockQuantity: 150, safetyStock: 50 },
|
||||
{ id: 'wip-2', itemCode: 'WIP-CS-001', itemName: '케이스(500X380)', specification: '500X380 EGI 1.6T', unit: 'EA', stockQuantity: 80, safetyStock: 30 },
|
||||
{ id: 'wip-3', itemCode: 'WIP-BF-001', itemName: '하단마감재(60X40)', specification: '60X40 EGI 1.6T', unit: 'EA', stockQuantity: 200, safetyStock: 100 },
|
||||
{ id: 'wip-4', itemCode: 'WIP-LB-001', itemName: '하단L-BAR(17X60)', specification: '17X60 EGI 1.6T', unit: 'EA', stockQuantity: 120, safetyStock: 40 },
|
||||
{ id: 'wip-1', itemCode: 'WIP-GR-001', itemName: '가이드레일(벽면형)', specification: '120X70 EGI 1.6T', unit: 'EA', stockQuantity: 100, safetyStock: 30 },
|
||||
{ id: 'wip-2', itemCode: 'WIP-CS-001', itemName: '케이스(500X380)', specification: '500X380 EGI 1.6T', unit: 'EA', stockQuantity: 100, safetyStock: 30 },
|
||||
{ id: 'wip-3', itemCode: 'WIP-BF-001', itemName: '하단마감재(60X40)', specification: '60X40 EGI 1.6T', unit: 'EA', stockQuantity: 100, safetyStock: 30 },
|
||||
{ id: 'wip-4', itemCode: 'WIP-LB-001', itemName: '하단L-BAR(17X60)', specification: '17X60 EGI 1.6T', unit: 'EA', stockQuantity: 100, safetyStock: 30 },
|
||||
{ id: 'wip-5', itemCode: 'WIP-TB-001', itemName: '상단L-BAR(17X50)', specification: '17X50 EGI 1.6T', unit: 'EA', stockQuantity: 100, safetyStock: 30 },
|
||||
{ id: 'wip-6', itemCode: 'WIP-EL-001', itemName: '엘바(16I75)', specification: '16I75 EGI 1.6T', unit: 'EA', stockQuantity: 100, safetyStock: 30 },
|
||||
{ id: 'wip-7', itemCode: 'WIP-HJ-001', itemName: '하장바(A각)', specification: '16|75|16|75|16 EGI 1.6T', unit: 'EA', stockQuantity: 100, safetyStock: 30 },
|
||||
];
|
||||
|
||||
interface WipProductionModalProps {
|
||||
@@ -65,62 +68,51 @@ interface WipProductionModalProps {
|
||||
|
||||
export function WipProductionModal({ open, onOpenChange }: WipProductionModalProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedItems, setSelectedItems] = useState<WipItem[]>([]);
|
||||
const [itemQuantities, setItemQuantities] = useState<Record<string, number>>({});
|
||||
const [priority, setPriority] = useState<Priority>('일반');
|
||||
const [department, setDepartment] = useState('생산부서');
|
||||
const [note, setNote] = useState('');
|
||||
|
||||
// 검색 결과 필터링
|
||||
const searchResults = searchTerm.trim()
|
||||
? MOCK_WIP_ITEMS.filter(
|
||||
(item) =>
|
||||
!selectedItems.some((s) => s.id === item.id) &&
|
||||
(item.itemCode.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.itemName.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
)
|
||||
: [];
|
||||
|
||||
// 품목 추가
|
||||
const handleAddItem = useCallback((item: Omit<WipItem, 'quantity'>) => {
|
||||
setSelectedItems((prev) => [...prev, { ...item, quantity: 0 }]);
|
||||
setSearchTerm('');
|
||||
}, []);
|
||||
|
||||
// 품목 삭제
|
||||
const handleRemoveItem = useCallback((id: string) => {
|
||||
setSelectedItems((prev) => prev.filter((item) => item.id !== id));
|
||||
}, []);
|
||||
// 검색 결과 필터링 (검색어 없으면 전체 표시)
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!searchTerm.trim()) {
|
||||
return MOCK_WIP_ITEMS;
|
||||
}
|
||||
return MOCK_WIP_ITEMS.filter(
|
||||
(item) =>
|
||||
item.itemCode.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.itemName.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}, [searchTerm]);
|
||||
|
||||
// 수량 변경
|
||||
const handleQuantityChange = useCallback((id: string, value: string) => {
|
||||
const qty = parseInt(value) || 0;
|
||||
setSelectedItems((prev) =>
|
||||
prev.map((item) => (item.id === id ? { ...item, quantity: qty } : item))
|
||||
);
|
||||
setItemQuantities((prev) => ({
|
||||
...prev,
|
||||
[id]: qty,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 생산지시 확정
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (selectedItems.length === 0) {
|
||||
toast.error('품목을 추가해주세요.');
|
||||
return;
|
||||
}
|
||||
const invalidItems = selectedItems.filter((item) => item.quantity <= 0);
|
||||
if (invalidItems.length > 0) {
|
||||
const itemsWithQuantity = filteredItems.filter((item) => (itemQuantities[item.id] || 0) > 0);
|
||||
|
||||
if (itemsWithQuantity.length === 0) {
|
||||
toast.error('수량을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: API 연동
|
||||
toast.success(`재공품 생산지시가 확정되었습니다. (${selectedItems.length}건)`);
|
||||
toast.success(`재공품 생산지시가 확정되었습니다. (${itemsWithQuantity.length}건)`);
|
||||
handleReset();
|
||||
onOpenChange(false);
|
||||
}, [selectedItems, onOpenChange]);
|
||||
}, [filteredItems, itemQuantities, onOpenChange]);
|
||||
|
||||
// 초기화
|
||||
const handleReset = useCallback(() => {
|
||||
setSearchTerm('');
|
||||
setSelectedItems([]);
|
||||
setItemQuantities({});
|
||||
setPriority('일반');
|
||||
setDepartment('생산부서');
|
||||
setNote('');
|
||||
@@ -132,104 +124,87 @@ export function WipProductionModal({ open, onOpenChange }: WipProductionModalPro
|
||||
}, [handleReset, onOpenChange]);
|
||||
|
||||
const priorityOptions: Priority[] = ['긴급', '우선', '일반'];
|
||||
const priorityColors: Record<Priority, string> = {
|
||||
'긴급': 'bg-red-500 text-white hover:bg-red-600',
|
||||
'우선': 'bg-orange-500 text-white hover:bg-orange-600',
|
||||
'일반': 'bg-gray-500 text-white hover:bg-gray-600',
|
||||
};
|
||||
const priorityInactiveColors: Record<Priority, string> = {
|
||||
'긴급': 'bg-white text-red-500 border-red-300 hover:bg-red-50',
|
||||
'우선': 'bg-white text-orange-500 border-orange-300 hover:bg-orange-50',
|
||||
'일반': 'bg-white text-gray-500 border-gray-300 hover:bg-gray-50',
|
||||
|
||||
// 선택된 버튼은 각각 다른 색상, 미선택은 회색 outline
|
||||
const getPriorityStyle = (opt: Priority) => {
|
||||
const isSelected = priority === opt;
|
||||
if (isSelected) {
|
||||
if (opt === '긴급') return 'bg-red-500 text-white hover:bg-red-600';
|
||||
if (opt === '우선') return 'bg-amber-500 text-white hover:bg-amber-600';
|
||||
return 'bg-orange-400 text-white hover:bg-orange-500'; // 일반
|
||||
}
|
||||
return 'bg-gray-200 text-gray-700 hover:bg-gray-300';
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent className="sm:max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>재공품 생산</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5">
|
||||
{/* 품목 검색 */}
|
||||
<div className="space-y-2">
|
||||
{/* 품목 선택 섹션 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">품목 선택</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="품목코드 또는 품목명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
{/* 검색 결과 드롭다운 */}
|
||||
{searchResults.length > 0 && (
|
||||
<div className="border rounded-md bg-white shadow-md max-h-40 overflow-y-auto">
|
||||
{searchResults.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-left hover:bg-gray-50 text-sm"
|
||||
onClick={() => handleAddItem(item)}
|
||||
>
|
||||
<span className="text-muted-foreground font-mono text-xs">{item.itemCode}</span>
|
||||
<span className="font-medium">{item.itemName}</span>
|
||||
<span className="text-muted-foreground text-xs">{item.specification}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 재공품 목록 테이블 */}
|
||||
{selectedItems.length > 0 && (
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th className="px-3 py-2 text-left font-medium text-xs">품목코드</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-xs">품목명</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-xs">규격</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-xs">단위</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-xs">재고량</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-xs">안전재고</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-xs w-24">수량</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-xs w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{selectedItems.map((item) => (
|
||||
<tr key={item.id} className="border-t">
|
||||
<td className="px-3 py-2 font-mono text-xs">{item.itemCode}</td>
|
||||
<td className="px-3 py-2">{item.itemName}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">{item.specification}</td>
|
||||
<td className="px-3 py-2 text-center">{item.unit}</td>
|
||||
<td className="px-3 py-2 text-center">{item.stockQuantity}</td>
|
||||
<td className="px-3 py-2 text-center">{item.safetyStock}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={item.quantity || ''}
|
||||
onChange={(e) => handleQuantityChange(item.id, e.target.value)}
|
||||
className="h-8 text-center text-sm"
|
||||
placeholder="0"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<button
|
||||
onClick={() => handleRemoveItem(item.id)}
|
||||
className="text-gray-400 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
{/* 검색창 */}
|
||||
<div className="border rounded-lg p-4 space-y-3">
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder="검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Search className="absolute right-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{/* 총 건수 */}
|
||||
<p className="text-sm">
|
||||
총 <span className="font-bold">{filteredItems.length}</span>건
|
||||
</p>
|
||||
|
||||
{/* 품목 테이블 */}
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-900 text-white">
|
||||
<th className="px-3 py-2 text-center font-medium text-xs">품목코드</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-xs">품목명</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-xs">규격</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-xs">단위</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-xs">재고량</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-xs">안전재고</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-xs w-24">수량</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredItems.map((item) => (
|
||||
<tr key={item.id} className="border-t hover:bg-gray-50">
|
||||
<td className="px-3 py-2 text-center text-xs">{item.itemCode}</td>
|
||||
<td className="px-3 py-2 text-center">{item.itemName}</td>
|
||||
<td className="px-3 py-2 text-center text-xs text-muted-foreground">{item.specification}</td>
|
||||
<td className="px-3 py-2 text-center">{item.unit}</td>
|
||||
<td className="px-3 py-2 text-center">{item.stockQuantity}</td>
|
||||
<td className="px-3 py-2 text-center">{item.safetyStock}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={itemQuantities[item.id] || ''}
|
||||
onChange={(e) => handleQuantityChange(item.id, e.target.value)}
|
||||
className="h-8 text-center text-sm"
|
||||
placeholder=""
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 우선순위 */}
|
||||
<div className="space-y-2">
|
||||
@@ -238,11 +213,8 @@ export function WipProductionModal({ open, onOpenChange }: WipProductionModalPro
|
||||
{priorityOptions.map((opt) => (
|
||||
<Button
|
||||
key={opt}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`flex-1 ${
|
||||
priority === opt ? priorityColors[opt] : priorityInactiveColors[opt]
|
||||
}`}
|
||||
className={`flex-1 ${getPriorityStyle(opt)}`}
|
||||
onClick={() => setPriority(opt)}
|
||||
>
|
||||
{opt}
|
||||
@@ -272,19 +244,23 @@ export function WipProductionModal({ open, onOpenChange }: WipProductionModalPro
|
||||
<Textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="비고 사항을 입력하세요..."
|
||||
placeholder=""
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
className="bg-gray-900 text-white hover:bg-gray-800 hover:text-white"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
className="bg-orange-400 hover:bg-orange-500"
|
||||
>
|
||||
생산지시 확정
|
||||
</Button>
|
||||
|
||||
@@ -62,18 +62,22 @@ function ProcessStepPills({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{steps.map((step, index) => {
|
||||
const isCompleted = index < currentStep;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step.key || `step-${index}`}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-full bg-gray-900 text-white border border-gray-900"
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium ${
|
||||
isCompleted
|
||||
? 'bg-emerald-500 text-white'
|
||||
: 'bg-gray-800 text-white'
|
||||
}`}
|
||||
>
|
||||
<span>{step.label}</span>
|
||||
{isCompleted && (
|
||||
<span className="text-green-400 font-medium">완료</span>
|
||||
<span className="font-semibold">완료</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -202,7 +206,33 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
|
||||
try {
|
||||
const result = await getWorkOrderById(orderId);
|
||||
if (result.success && result.data) {
|
||||
setOrder(result.data);
|
||||
const orderData = result.data;
|
||||
// 품목이 없으면 목업 데이터 추가 (개발/테스트용)
|
||||
if (!orderData.items || orderData.items.length === 0) {
|
||||
orderData.items = [
|
||||
{
|
||||
id: 'mock-1',
|
||||
no: 1,
|
||||
status: 'waiting',
|
||||
productName: 'KWW503 (와이어)',
|
||||
floorCode: '-',
|
||||
specification: '8,260 X 8,350 mm',
|
||||
quantity: 500,
|
||||
unit: 'm',
|
||||
},
|
||||
{
|
||||
id: 'mock-2',
|
||||
no: 2,
|
||||
status: 'waiting',
|
||||
productName: '스크린 원단',
|
||||
floorCode: '-',
|
||||
specification: '1,200 X 2,400 mm',
|
||||
quantity: 100,
|
||||
unit: 'EA',
|
||||
},
|
||||
];
|
||||
}
|
||||
setOrder(orderData);
|
||||
} else {
|
||||
toast.error(result.error || '작업지시 조회에 실패했습니다.');
|
||||
}
|
||||
|
||||
@@ -4,13 +4,16 @@
|
||||
* 작업지시 수정 페이지
|
||||
* WorkOrderCreate 패턴 기반
|
||||
* IntegratedDetailTemplate 마이그레이션 (2025-01-20)
|
||||
* 공정 진행 토글 + 품목 테이블 추가 (2026-02-05)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -18,15 +21,35 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
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';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { getWorkOrderById, updateWorkOrder, getProcessOptions, type ProcessOption } from './actions';
|
||||
import type { WorkOrder } from './types';
|
||||
import type { WorkOrder, WorkOrderItem, ProcessStep } from './types';
|
||||
import { SCREEN_PROCESS_STEPS } from './types';
|
||||
import { workOrderEditConfig } from './workOrderConfig';
|
||||
|
||||
// 공정 단계 완료 상태 타입
|
||||
interface ProcessStepStatus {
|
||||
[key: string]: boolean;
|
||||
}
|
||||
|
||||
// 수정 가능한 품목 타입
|
||||
interface EditableItem extends WorkOrderItem {
|
||||
isEditing?: boolean;
|
||||
editQuantity?: number;
|
||||
}
|
||||
|
||||
// Validation 에러 타입
|
||||
interface ValidationErrors {
|
||||
[key: string]: string;
|
||||
@@ -81,6 +104,13 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
|
||||
const [processOptions, setProcessOptions] = useState<ProcessOption[]>([]);
|
||||
const [isLoadingProcesses, setIsLoadingProcesses] = useState(true);
|
||||
|
||||
// 공정 진행 상태
|
||||
const [processSteps, setProcessSteps] = useState<ProcessStep[]>([]);
|
||||
const [stepStatus, setStepStatus] = useState<ProcessStepStatus>({});
|
||||
|
||||
// 품목 목록 (수정 가능)
|
||||
const [items, setItems] = useState<EditableItem[]>([]);
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
@@ -108,6 +138,46 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
|
||||
if (order.assignees) {
|
||||
setAssigneeNames(order.assignees.map(a => a.name));
|
||||
}
|
||||
// 공정 단계 설정 (동적 또는 하드코딩 폴백)
|
||||
const steps = order.workSteps && order.workSteps.length > 0
|
||||
? order.workSteps
|
||||
: SCREEN_PROCESS_STEPS;
|
||||
setProcessSteps(steps);
|
||||
// 공정 단계 완료 상태 초기화 (currentStep 기준)
|
||||
const initialStatus: ProcessStepStatus = {};
|
||||
steps.forEach((step, idx) => {
|
||||
initialStatus[step.key] = idx < order.currentStep;
|
||||
});
|
||||
setStepStatus(initialStatus);
|
||||
// 품목 목록 설정 (없으면 목업 데이터 추가 - 개발용)
|
||||
const orderItems = order.items?.map(item => ({ ...item })) || [];
|
||||
if (orderItems.length === 0) {
|
||||
// 개발/테스트용 목업 데이터
|
||||
setItems([
|
||||
{
|
||||
id: 'mock-1',
|
||||
no: 1,
|
||||
status: 'waiting',
|
||||
productName: 'KWW503 (와이어)',
|
||||
floorCode: '-',
|
||||
specification: '8,260 X 8,350 mm',
|
||||
quantity: 500,
|
||||
unit: 'm',
|
||||
},
|
||||
{
|
||||
id: 'mock-2',
|
||||
no: 2,
|
||||
status: 'waiting',
|
||||
productName: '스크린 원단',
|
||||
floorCode: '-',
|
||||
specification: '1,200 X 2,400 mm',
|
||||
quantity: 100,
|
||||
unit: 'EA',
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
setItems(orderItems);
|
||||
}
|
||||
} else {
|
||||
toast.error(orderResult.error || '작업지시 조회에 실패했습니다.');
|
||||
router.push('/production/work-orders');
|
||||
@@ -193,6 +263,66 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
|
||||
router.back();
|
||||
};
|
||||
|
||||
// 공정 단계 토글
|
||||
const handleStepToggle = useCallback((stepKey: string) => {
|
||||
setStepStatus(prev => ({
|
||||
...prev,
|
||||
[stepKey]: !prev[stepKey],
|
||||
}));
|
||||
// TODO: API 호출로 서버에 상태 저장
|
||||
toast.success(`공정 단계 상태가 변경되었습니다.`);
|
||||
}, []);
|
||||
|
||||
// 품목 수량 수정 시작
|
||||
const handleItemEditStart = useCallback((itemId: string) => {
|
||||
setItems(prev =>
|
||||
prev.map(item =>
|
||||
item.id === itemId
|
||||
? { ...item, isEditing: true, editQuantity: item.quantity }
|
||||
: item
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 품목 수량 변경
|
||||
const handleItemQuantityChange = useCallback((itemId: string, quantity: number) => {
|
||||
setItems(prev =>
|
||||
prev.map(item =>
|
||||
item.id === itemId ? { ...item, editQuantity: quantity } : item
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 품목 수량 수정 저장
|
||||
const handleItemEditSave = useCallback((itemId: string) => {
|
||||
setItems(prev =>
|
||||
prev.map(item =>
|
||||
item.id === itemId
|
||||
? { ...item, quantity: item.editQuantity || item.quantity, isEditing: false }
|
||||
: item
|
||||
)
|
||||
);
|
||||
// TODO: API 호출로 서버에 저장
|
||||
toast.success('수량이 수정되었습니다.');
|
||||
}, []);
|
||||
|
||||
// 품목 수량 수정 취소
|
||||
const handleItemEditCancel = useCallback((itemId: string) => {
|
||||
setItems(prev =>
|
||||
prev.map(item =>
|
||||
item.id === itemId ? { ...item, isEditing: false } : item
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 품목 삭제
|
||||
const handleItemDelete = useCallback((itemId: string) => {
|
||||
if (!confirm('정말 삭제하시겠습니까?')) return;
|
||||
setItems(prev => prev.filter(item => item.id !== itemId));
|
||||
// TODO: API 호출로 서버에서 삭제
|
||||
toast.success('품목이 삭제되었습니다.');
|
||||
}, []);
|
||||
|
||||
// 동적 config (작업지시 번호 포함)
|
||||
const dynamicConfig = {
|
||||
...workOrderEditConfig,
|
||||
@@ -357,8 +487,139 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 공정 진행 섹션 */}
|
||||
<section className="bg-white border rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-4">공정 진행</h3>
|
||||
|
||||
{/* 품목 정보 헤더 + 공정 단계 토글 */}
|
||||
<div className="border rounded-lg p-4 mb-6">
|
||||
{/* 품목 정보 헤더 */}
|
||||
<p className="font-semibold mb-3">
|
||||
{items.length > 0 ? (
|
||||
<>
|
||||
{items[0].productName}
|
||||
{items[0].specification !== '-' ? ` ${items[0].specification}` : ''}
|
||||
{` ${items[0].quantity}${items[0].unit !== '-' ? items[0].unit : '개'}`}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{workOrder?.processCode} ({workOrder?.processName})
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* 공정 단계 토글 버튼 */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{processSteps.map((step) => {
|
||||
const isCompleted = stepStatus[step.key] || false;
|
||||
return (
|
||||
<button
|
||||
key={step.key}
|
||||
type="button"
|
||||
onClick={() => handleStepToggle(step.key)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-colors cursor-pointer ${
|
||||
isCompleted
|
||||
? 'bg-emerald-500 text-white hover:bg-emerald-600'
|
||||
: 'bg-gray-800 text-white hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span>{step.label}</span>
|
||||
{isCompleted && <span className="font-semibold">완료</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품목 테이블 */}
|
||||
{items.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead>로트번호</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead className="w-32 text-right">수량</TableHead>
|
||||
<TableHead className="w-20">단위</TableHead>
|
||||
<TableHead className="w-24 text-center">관리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{workOrder?.lotNo || '-'}</TableCell>
|
||||
<TableCell className="font-medium">{item.productName}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{item.isEditing ? (
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<Input
|
||||
type="number"
|
||||
value={item.editQuantity}
|
||||
onChange={(e) =>
|
||||
handleItemQuantityChange(item.id, parseInt(e.target.value) || 0)
|
||||
}
|
||||
className="w-20 h-8 text-right"
|
||||
min={0}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 px-2 text-green-600 hover:text-green-700"
|
||||
onClick={() => handleItemEditSave(item.id)}
|
||||
>
|
||||
✓
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 px-2 text-gray-500 hover:text-gray-600"
|
||||
onClick={() => handleItemEditCancel(item.id)}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
item.quantity
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{item.unit}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => handleItemEditStart(item.id)}
|
||||
disabled={item.isEditing}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 text-red-500 hover:text-red-600"
|
||||
onClick={() => handleItemDelete(item.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center py-8">
|
||||
등록된 품목이 없습니다.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
), [formData, validationErrors, processOptions, isLoadingProcesses, assigneeNames, workOrder]);
|
||||
), [formData, validationErrors, processOptions, isLoadingProcesses, assigneeNames, workOrder, processSteps, stepStatus, items, handleStepToggle, handleItemEditStart, handleItemQuantityChange, handleItemEditSave, handleItemEditCancel, handleItemDelete]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -14,16 +14,28 @@
|
||||
* - 부적합 내용 / 종합판정(자동)
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
|
||||
import { useState, useCallback, useMemo, forwardRef, useImperativeHandle, useEffect } from 'react';
|
||||
import type { WorkOrder } from '../types';
|
||||
import type { InspectionData } from '@/components/production/WorkerScreen/InspectionInputModal';
|
||||
import type { WorkItemData } from '@/components/production/WorkerScreen/types';
|
||||
import type { InspectionDataMap } from './InspectionReportModal';
|
||||
|
||||
export interface InspectionContentRef {
|
||||
getInspectionData: () => unknown;
|
||||
}
|
||||
|
||||
interface BendingInspectionContentProps {
|
||||
export interface BendingInspectionContentProps {
|
||||
data: WorkOrder;
|
||||
readOnly?: boolean;
|
||||
inspectionData?: InspectionData;
|
||||
/** 작업 아이템 목록 - 행 개수 동적 생성용 */
|
||||
workItems?: WorkItemData[];
|
||||
/** 아이템별 검사 데이터 맵 */
|
||||
inspectionDataMap?: InspectionDataMap;
|
||||
/** 기준서 도해 이미지 URL */
|
||||
schematicImage?: string;
|
||||
/** 검사기준 이미지 URL */
|
||||
inspectionStandardImage?: string;
|
||||
}
|
||||
|
||||
type CheckStatus = '양호' | '불량' | null;
|
||||
@@ -47,9 +59,22 @@ interface ProductRow {
|
||||
gapPoints: GapPoint[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 절곡 검사성적서 - 가이드레일 타입별 행 구조
|
||||
*
|
||||
* | 타입 조합 | 가이드레일 행 개수 |
|
||||
* |-----------------------|-------------------|
|
||||
* | 벽면형/벽면형 (벽벽) | 1행 |
|
||||
* | 측면형/측면형 (측측) | 1행 |
|
||||
* | 벽면형/측면형 (혼합형) | 2행 (규격이 달라서) |
|
||||
*
|
||||
* TODO: 실제 구현 시 공정 데이터에서 타입 정보를 받아서
|
||||
* INITIAL_PRODUCTS를 동적으로 생성해야 함
|
||||
*/
|
||||
const INITIAL_PRODUCTS: Omit<ProductRow, 'bendingStatus' | 'lengthMeasured' | 'widthMeasured'>[] = [
|
||||
// 현재 목업: 혼합형(벽/측)인 경우 가이드레일 2행
|
||||
{
|
||||
id: 'guide-rail', category: 'KWE01', productName: '가이드레일', productType: '벽면형',
|
||||
id: 'guide-rail-wall', category: 'KWE01', productName: '가이드레일', productType: '벽면형',
|
||||
lengthDesign: '3000', widthDesign: 'N/A',
|
||||
gapPoints: [
|
||||
{ point: '①', designValue: '30', measured: '' },
|
||||
@@ -59,6 +84,17 @@ const INITIAL_PRODUCTS: Omit<ProductRow, 'bendingStatus' | 'lengthMeasured' | 'w
|
||||
{ point: '⑤', designValue: '34', measured: '' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'guide-rail-side', category: 'KWE01', productName: '가이드레일', productType: '측면형',
|
||||
lengthDesign: '3000', widthDesign: 'N/A',
|
||||
gapPoints: [
|
||||
{ point: '①', designValue: '28', measured: '' },
|
||||
{ point: '②', designValue: '75', measured: '' },
|
||||
{ point: '③', designValue: '42', measured: '' },
|
||||
{ point: '④', designValue: '38', measured: '' },
|
||||
{ point: '⑤', designValue: '32', measured: '' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'case', category: 'KWE01', productName: '케이스', productType: '500X380',
|
||||
lengthDesign: '3000', widthDesign: 'N/A',
|
||||
@@ -102,7 +138,21 @@ const INITIAL_PRODUCTS: Omit<ProductRow, 'bendingStatus' | 'lengthMeasured' | 'w
|
||||
},
|
||||
];
|
||||
|
||||
export const BendingInspectionContent = forwardRef<InspectionContentRef, BendingInspectionContentProps>(function BendingInspectionContent({ data: order, readOnly = false }, ref) {
|
||||
// 상태 변환 함수: 'good'/'bad' → '양호'/'불량'
|
||||
const convertToCheckStatus = (status: 'good' | 'bad' | null | undefined): CheckStatus => {
|
||||
if (status === 'good') return '양호';
|
||||
if (status === 'bad') return '불량';
|
||||
return null;
|
||||
};
|
||||
|
||||
export const BendingInspectionContent = forwardRef<InspectionContentRef, BendingInspectionContentProps>(function BendingInspectionContent({
|
||||
data: order,
|
||||
readOnly = false,
|
||||
workItems,
|
||||
inspectionDataMap,
|
||||
schematicImage,
|
||||
inspectionStandardImage,
|
||||
}, ref) {
|
||||
const fullDate = new Date().toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
@@ -130,6 +180,21 @@ export const BendingInspectionContent = forwardRef<InspectionContentRef, Bending
|
||||
|
||||
const [inadequateContent, setInadequateContent] = useState('');
|
||||
|
||||
// workItems의 첫 번째 아이템 검사 데이터로 절곡상태 적용
|
||||
useEffect(() => {
|
||||
if (workItems && workItems.length > 0 && inspectionDataMap) {
|
||||
const firstItem = workItems[0];
|
||||
const itemData = inspectionDataMap.get(firstItem.id);
|
||||
if (itemData?.bendingStatus) {
|
||||
const bendingStatusValue = convertToCheckStatus(itemData.bendingStatus);
|
||||
setProducts(prev => prev.map(p => ({
|
||||
...p,
|
||||
bendingStatus: bendingStatusValue,
|
||||
})));
|
||||
}
|
||||
}
|
||||
}, [workItems, inspectionDataMap]);
|
||||
|
||||
const handleStatusChange = useCallback((productId: string, value: CheckStatus) => {
|
||||
if (readOnly) return;
|
||||
setProducts(prev => prev.map(p =>
|
||||
@@ -312,11 +377,17 @@ export const BendingInspectionContent = forwardRef<InspectionContentRef, Bending
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 p-0" colSpan={7}>
|
||||
<div className="grid grid-cols-3 divide-x divide-gray-400">
|
||||
<div className="h-28 flex items-center justify-center text-gray-300 text-xs">IMG</div>
|
||||
<div className="h-28 flex items-center justify-center text-gray-300 text-xs">IMG</div>
|
||||
<div className="h-28 flex items-center justify-center text-gray-300 text-xs">IMG</div>
|
||||
</div>
|
||||
{schematicImage ? (
|
||||
<div className="h-28 flex items-center justify-center p-2">
|
||||
<img src={schematicImage} alt="기준서 도해" className="max-h-24 mx-auto object-contain" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 divide-x divide-gray-400">
|
||||
<div className="h-28 flex items-center justify-center text-gray-300 text-xs">IMG</div>
|
||||
<div className="h-28 flex items-center justify-center text-gray-300 text-xs">IMG</div>
|
||||
<div className="h-28 flex items-center justify-center text-gray-300 text-xs">IMG</div>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{/* 기준서 헤더 */}
|
||||
@@ -384,8 +455,12 @@ export const BendingInspectionContent = forwardRef<InspectionContentRef, Bending
|
||||
</tr>
|
||||
{/* 겉모양 | 절곡상태 (row 1) */}
|
||||
<tr>
|
||||
<td className="border border-gray-400 p-2 text-center text-gray-300 align-middle" rowSpan={5}>
|
||||
<div className="h-32 flex items-center justify-center">도해 이미지 영역</div>
|
||||
<td className="border border-gray-400 p-2 text-center align-middle" rowSpan={5}>
|
||||
{inspectionStandardImage ? (
|
||||
<img src={inspectionStandardImage} alt="검사기준 도해" className="max-h-32 mx-auto object-contain" />
|
||||
) : (
|
||||
<div className="h-32 flex items-center justify-center text-gray-300">도해 이미지 영역</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="border border-gray-400 px-2 py-1 font-medium" rowSpan={2}>겉모양</td>
|
||||
<td className="border border-gray-400 px-2 py-1 font-medium" rowSpan={2}>절곡상태</td>
|
||||
|
||||
@@ -12,22 +12,42 @@
|
||||
* - 부적합 내용 / 종합판정 (자동)
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
|
||||
import { useState, useCallback, useMemo, forwardRef, useImperativeHandle, useEffect } from 'react';
|
||||
import type { WorkOrder } from '../types';
|
||||
import type { InspectionData } from '@/components/production/WorkerScreen/InspectionInputModal';
|
||||
import type { WorkItemData } from '@/components/production/WorkerScreen/types';
|
||||
import type { InspectionDataMap } from './InspectionReportModal';
|
||||
|
||||
export interface InspectionContentRef {
|
||||
getInspectionData: () => unknown;
|
||||
}
|
||||
|
||||
interface BendingWipInspectionContentProps {
|
||||
export interface BendingWipInspectionContentProps {
|
||||
data: WorkOrder;
|
||||
readOnly?: boolean;
|
||||
inspectionData?: InspectionData;
|
||||
/** 작업 아이템 목록 - 행 개수 동적 생성용 */
|
||||
workItems?: WorkItemData[];
|
||||
/** 아이템별 검사 데이터 맵 */
|
||||
inspectionDataMap?: InspectionDataMap;
|
||||
/** 기준서 도해 이미지 URL */
|
||||
schematicImage?: string;
|
||||
/** 검사기준 이미지 URL */
|
||||
inspectionStandardImage?: string;
|
||||
}
|
||||
|
||||
type CheckStatus = '양호' | '불량' | null;
|
||||
|
||||
// 상태 변환 함수: 'good'/'bad' → '양호'/'불량'
|
||||
const convertToCheckStatus = (status: 'good' | 'bad' | null | undefined): CheckStatus => {
|
||||
if (status === 'good') return '양호';
|
||||
if (status === 'bad') return '불량';
|
||||
return null;
|
||||
};
|
||||
|
||||
interface InspectionRow {
|
||||
id: number;
|
||||
itemId?: string; // 작업 아이템 ID (연동용)
|
||||
productName: string; // 제품명
|
||||
processStatus: CheckStatus; // 절곡상태
|
||||
lengthDesign: string; // 길이 도면치수
|
||||
@@ -41,7 +61,14 @@ interface InspectionRow {
|
||||
|
||||
const DEFAULT_ROW_COUNT = 6;
|
||||
|
||||
export const BendingWipInspectionContent = forwardRef<InspectionContentRef, BendingWipInspectionContentProps>(function BendingWipInspectionContent({ data: order, readOnly = false }, ref) {
|
||||
export const BendingWipInspectionContent = forwardRef<InspectionContentRef, BendingWipInspectionContentProps>(function BendingWipInspectionContent({
|
||||
data: order,
|
||||
readOnly = false,
|
||||
workItems,
|
||||
inspectionDataMap,
|
||||
schematicImage,
|
||||
inspectionStandardImage,
|
||||
}, ref) {
|
||||
const fullDate = new Date().toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
@@ -57,26 +84,56 @@ export const BendingWipInspectionContent = forwardRef<InspectionContentRef, Bend
|
||||
const documentNo = order.workOrderNo || 'ABC123';
|
||||
const primaryAssignee = order.assignees?.find(a => a.isPrimary)?.name || order.assignee || '-';
|
||||
|
||||
// workItems 기반 행 개수 결정 (workItems가 있으면 그 개수, 없으면 order.items 또는 기본값)
|
||||
const rowCount = workItems?.length || order.items?.length || DEFAULT_ROW_COUNT;
|
||||
|
||||
// 아이템 기반 초기 행 생성
|
||||
const [rows, setRows] = useState<InspectionRow[]>(() => {
|
||||
const items = order.items || [];
|
||||
const count = Math.max(items.length, DEFAULT_ROW_COUNT);
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: i + 1,
|
||||
productName: items[i]?.productName || '',
|
||||
processStatus: null,
|
||||
lengthDesign: '4000',
|
||||
lengthMeasured: '',
|
||||
widthDesign: 'N/A',
|
||||
widthMeasured: 'N/A',
|
||||
spacingPoint: '',
|
||||
spacingDesign: '380',
|
||||
spacingMeasured: '',
|
||||
}));
|
||||
return Array.from({ length: rowCount }, (_, i) => {
|
||||
const item = workItems?.[i];
|
||||
const orderItem = order.items?.[i];
|
||||
const itemData = item && inspectionDataMap?.get(item.id);
|
||||
return {
|
||||
id: i + 1,
|
||||
itemId: item?.id,
|
||||
productName: item?.itemName || orderItem?.productName || '',
|
||||
processStatus: itemData ? convertToCheckStatus(itemData.bendingStatus) : null,
|
||||
lengthDesign: '4000',
|
||||
lengthMeasured: '',
|
||||
widthDesign: 'N/A',
|
||||
widthMeasured: 'N/A',
|
||||
spacingPoint: '',
|
||||
spacingDesign: '380',
|
||||
spacingMeasured: '',
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const [inadequateContent, setInadequateContent] = useState('');
|
||||
|
||||
// workItems 또는 inspectionDataMap 변경 시 행 업데이트
|
||||
useEffect(() => {
|
||||
const newRowCount = workItems?.length || order.items?.length || DEFAULT_ROW_COUNT;
|
||||
setRows(Array.from({ length: newRowCount }, (_, i) => {
|
||||
const item = workItems?.[i];
|
||||
const orderItem = order.items?.[i];
|
||||
const itemData = item && inspectionDataMap?.get(item.id);
|
||||
return {
|
||||
id: i + 1,
|
||||
itemId: item?.id,
|
||||
productName: item?.itemName || orderItem?.productName || '',
|
||||
processStatus: itemData ? convertToCheckStatus(itemData.bendingStatus) : null,
|
||||
lengthDesign: '4000',
|
||||
lengthMeasured: '',
|
||||
widthDesign: 'N/A',
|
||||
widthMeasured: 'N/A',
|
||||
spacingPoint: '',
|
||||
spacingDesign: '380',
|
||||
spacingMeasured: '',
|
||||
};
|
||||
}));
|
||||
}, [workItems, inspectionDataMap, order.items]);
|
||||
|
||||
const handleStatusChange = useCallback((rowId: number, value: CheckStatus) => {
|
||||
if (readOnly) return;
|
||||
setRows(prev => prev.map(row =>
|
||||
@@ -230,7 +287,11 @@ export const BendingWipInspectionContent = forwardRef<InspectionContentRef, Bend
|
||||
{/* 도해 영역 - 넓게 */}
|
||||
<td className="border border-gray-400 p-3 text-center align-middle" rowSpan={4}>
|
||||
<div className="text-xs font-medium text-gray-500 mb-2 text-left">도해</div>
|
||||
<div className="h-32 border border-gray-300 rounded flex items-center justify-center text-gray-300 text-sm">IMG</div>
|
||||
{schematicImage ? (
|
||||
<img src={schematicImage} alt="기준서 도해" className="max-h-32 mx-auto object-contain" />
|
||||
) : (
|
||||
<div className="h-32 border border-gray-300 rounded flex items-center justify-center text-gray-300 text-sm">IMG</div>
|
||||
)}
|
||||
</td>
|
||||
{/* 헤더 행 */}
|
||||
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center" colSpan={2}>검사항목</th>
|
||||
|
||||
@@ -22,6 +22,9 @@ import { BendingInspectionContent } from './BendingInspectionContent';
|
||||
import { BendingWipInspectionContent } from './BendingWipInspectionContent';
|
||||
import { SlatJointBarInspectionContent } from './SlatJointBarInspectionContent';
|
||||
import type { InspectionContentRef } from './ScreenInspectionContent';
|
||||
import type { InspectionData } from '@/components/production/WorkerScreen/InspectionInputModal';
|
||||
import type { WorkItemData } from '@/components/production/WorkerScreen/types';
|
||||
import type { InspectionSetting } from '@/types/process';
|
||||
|
||||
const PROCESS_LABELS: Record<ProcessType, string> = {
|
||||
screen: '스크린',
|
||||
@@ -30,6 +33,9 @@ const PROCESS_LABELS: Record<ProcessType, string> = {
|
||||
bending_wip: '절곡 재공품',
|
||||
};
|
||||
|
||||
// 작업 아이템별 검사 데이터 맵 타입
|
||||
export type InspectionDataMap = Map<string, InspectionData>;
|
||||
|
||||
interface InspectionReportModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -37,6 +43,13 @@ interface InspectionReportModalProps {
|
||||
processType?: ProcessType;
|
||||
readOnly?: boolean;
|
||||
isJointBar?: boolean;
|
||||
inspectionData?: InspectionData;
|
||||
/** 작업 아이템 목록 - 행 개수 동적 생성용 */
|
||||
workItems?: WorkItemData[];
|
||||
/** 아이템별 검사 데이터 맵 */
|
||||
inspectionDataMap?: InspectionDataMap;
|
||||
/** 중간검사 설정 - 도해/검사기준 이미지 표시용 */
|
||||
inspectionSetting?: InspectionSetting;
|
||||
}
|
||||
|
||||
export function InspectionReportModal({
|
||||
@@ -46,6 +59,10 @@ export function InspectionReportModal({
|
||||
processType = 'screen',
|
||||
readOnly = true,
|
||||
isJointBar = false,
|
||||
inspectionData,
|
||||
workItems,
|
||||
inspectionDataMap,
|
||||
inspectionSetting,
|
||||
}: InspectionReportModalProps) {
|
||||
const [order, setOrder] = useState<WorkOrder | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -152,21 +169,34 @@ export function InspectionReportModal({
|
||||
const renderContent = () => {
|
||||
if (!order) return null;
|
||||
|
||||
// 공통 props
|
||||
const commonProps = {
|
||||
ref: contentRef,
|
||||
data: order,
|
||||
readOnly,
|
||||
inspectionData,
|
||||
workItems,
|
||||
inspectionDataMap,
|
||||
// 중간검사 설정에서 등록한 이미지
|
||||
schematicImage: inspectionSetting?.schematicImage,
|
||||
inspectionStandardImage: inspectionSetting?.inspectionStandardImage,
|
||||
};
|
||||
|
||||
switch (processType) {
|
||||
case 'screen':
|
||||
return <ScreenInspectionContent ref={contentRef} data={order} readOnly={readOnly} />;
|
||||
return <ScreenInspectionContent {...commonProps} />;
|
||||
case 'slat':
|
||||
// 조인트바 여부 체크: isJointBar prop 또는 items에서 자동 감지
|
||||
if (isJointBar || order.items?.some(item => item.productName?.includes('조인트바'))) {
|
||||
return <SlatJointBarInspectionContent ref={contentRef} data={order} readOnly={readOnly} />;
|
||||
return <SlatJointBarInspectionContent {...commonProps} />;
|
||||
}
|
||||
return <SlatInspectionContent ref={contentRef} data={order} readOnly={readOnly} />;
|
||||
return <SlatInspectionContent {...commonProps} />;
|
||||
case 'bending':
|
||||
return <BendingInspectionContent ref={contentRef} data={order} readOnly={readOnly} />;
|
||||
return <BendingInspectionContent {...commonProps} />;
|
||||
case 'bending_wip':
|
||||
return <BendingWipInspectionContent ref={contentRef} data={order} readOnly={readOnly} />;
|
||||
return <BendingWipInspectionContent {...commonProps} />;
|
||||
default:
|
||||
return <ScreenInspectionContent ref={contentRef} data={order} readOnly={readOnly} />;
|
||||
return <ScreenInspectionContent {...commonProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -13,16 +13,28 @@
|
||||
* - 부적합 내용 / 종합판정(자동)
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
|
||||
import { useState, useCallback, useMemo, forwardRef, useImperativeHandle, useEffect } from 'react';
|
||||
import type { WorkOrder } from '../types';
|
||||
import type { InspectionData } from '@/components/production/WorkerScreen/InspectionInputModal';
|
||||
import type { WorkItemData } from '@/components/production/WorkerScreen/types';
|
||||
import type { InspectionDataMap } from './InspectionReportModal';
|
||||
|
||||
export interface InspectionContentRef {
|
||||
getInspectionData: () => unknown;
|
||||
}
|
||||
|
||||
interface ScreenInspectionContentProps {
|
||||
export interface ScreenInspectionContentProps {
|
||||
data: WorkOrder;
|
||||
readOnly?: boolean;
|
||||
inspectionData?: InspectionData;
|
||||
/** 작업 아이템 목록 - 행 개수 동적 생성용 */
|
||||
workItems?: WorkItemData[];
|
||||
/** 아이템별 검사 데이터 맵 */
|
||||
inspectionDataMap?: InspectionDataMap;
|
||||
/** 기준서 도해 이미지 URL */
|
||||
schematicImage?: string;
|
||||
/** 검사기준 이미지 URL */
|
||||
inspectionStandardImage?: string;
|
||||
}
|
||||
|
||||
type CheckStatus = '양호' | '불량' | null;
|
||||
@@ -30,6 +42,8 @@ type GapResult = 'OK' | 'NG' | null;
|
||||
|
||||
interface InspectionRow {
|
||||
id: number;
|
||||
itemId?: string; // 작업 아이템 ID
|
||||
itemName?: string; // 작업 아이템 이름
|
||||
processStatus: CheckStatus; // 가공상태 결모양
|
||||
sewingStatus: CheckStatus; // 재봉상태 결모양
|
||||
assemblyStatus: CheckStatus; // 조립상태
|
||||
@@ -43,7 +57,14 @@ interface InspectionRow {
|
||||
|
||||
const DEFAULT_ROW_COUNT = 6;
|
||||
|
||||
export const ScreenInspectionContent = forwardRef<InspectionContentRef, ScreenInspectionContentProps>(function ScreenInspectionContent({ data: order, readOnly = false }, ref) {
|
||||
export const ScreenInspectionContent = forwardRef<InspectionContentRef, ScreenInspectionContentProps>(function ScreenInspectionContent({
|
||||
data: order,
|
||||
readOnly = false,
|
||||
workItems,
|
||||
inspectionDataMap,
|
||||
schematicImage,
|
||||
inspectionStandardImage,
|
||||
}, ref) {
|
||||
const fullDate = new Date().toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
@@ -59,20 +80,67 @@ export const ScreenInspectionContent = forwardRef<InspectionContentRef, ScreenIn
|
||||
const documentNo = order.workOrderNo || 'ABC123';
|
||||
const primaryAssignee = order.assignees?.find(a => a.isPrimary)?.name || order.assignee || '-';
|
||||
|
||||
const [rows, setRows] = useState<InspectionRow[]>(() =>
|
||||
Array.from({ length: DEFAULT_ROW_COUNT }, (_, i) => ({
|
||||
id: i + 1,
|
||||
processStatus: null,
|
||||
sewingStatus: null,
|
||||
assemblyStatus: null,
|
||||
lengthDesign: '7,400',
|
||||
lengthMeasured: '',
|
||||
widthDesign: '2,950',
|
||||
widthMeasured: '',
|
||||
gapStandard: '400 이하',
|
||||
gapResult: null,
|
||||
}))
|
||||
);
|
||||
// 행 개수: workItems가 있으면 그 개수, 없으면 기본값
|
||||
const rowCount = workItems?.length || DEFAULT_ROW_COUNT;
|
||||
|
||||
// InspectionData를 InspectionRow로 변환하는 함수
|
||||
const convertToCheckStatus = (status: 'good' | 'bad' | null | undefined): CheckStatus => {
|
||||
if (status === 'good') return '양호';
|
||||
if (status === 'bad') return '불량';
|
||||
return null;
|
||||
};
|
||||
|
||||
const convertToGapResult = (status: 'ok' | 'ng' | null | undefined): GapResult => {
|
||||
if (status === 'ok') return 'OK';
|
||||
if (status === 'ng') return 'NG';
|
||||
return null;
|
||||
};
|
||||
|
||||
const [rows, setRows] = useState<InspectionRow[]>(() => {
|
||||
return Array.from({ length: rowCount }, (_, i) => {
|
||||
const item = workItems?.[i];
|
||||
const itemData = item && inspectionDataMap?.get(item.id);
|
||||
|
||||
return {
|
||||
id: i + 1,
|
||||
itemId: item?.id,
|
||||
itemName: item?.itemName || '',
|
||||
processStatus: itemData ? convertToCheckStatus(itemData.processingStatus) : null,
|
||||
sewingStatus: itemData ? convertToCheckStatus(itemData.sewingStatus) : null,
|
||||
assemblyStatus: itemData ? convertToCheckStatus(itemData.assemblyStatus) : null,
|
||||
lengthDesign: '7,400',
|
||||
lengthMeasured: itemData?.length?.toString() || '',
|
||||
widthDesign: '2,950',
|
||||
widthMeasured: itemData?.width?.toString() || '',
|
||||
gapStandard: '400 이하',
|
||||
gapResult: itemData ? convertToGapResult(itemData.gapStatus) : null,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// workItems나 inspectionDataMap이 변경되면 rows 업데이트
|
||||
useEffect(() => {
|
||||
const newRowCount = workItems?.length || DEFAULT_ROW_COUNT;
|
||||
setRows(Array.from({ length: newRowCount }, (_, i) => {
|
||||
const item = workItems?.[i];
|
||||
const itemData = item && inspectionDataMap?.get(item.id);
|
||||
|
||||
return {
|
||||
id: i + 1,
|
||||
itemId: item?.id,
|
||||
itemName: item?.itemName || '',
|
||||
processStatus: itemData ? convertToCheckStatus(itemData.processingStatus) : null,
|
||||
sewingStatus: itemData ? convertToCheckStatus(itemData.sewingStatus) : null,
|
||||
assemblyStatus: itemData ? convertToCheckStatus(itemData.assemblyStatus) : null,
|
||||
lengthDesign: '7,400',
|
||||
lengthMeasured: itemData?.length?.toString() || '',
|
||||
widthDesign: '2,950',
|
||||
widthMeasured: itemData?.width?.toString() || '',
|
||||
gapStandard: '400 이하',
|
||||
gapResult: itemData ? convertToGapResult(itemData.gapStatus) : null,
|
||||
};
|
||||
}));
|
||||
}, [workItems, inspectionDataMap]);
|
||||
|
||||
const [inadequateContent, setInadequateContent] = useState('');
|
||||
|
||||
@@ -248,8 +316,12 @@ export const ScreenInspectionContent = forwardRef<InspectionContentRef, ScreenIn
|
||||
<tbody>
|
||||
<tr>
|
||||
{/* 도해 영역 */}
|
||||
<td className="border border-gray-400 p-4 text-center text-gray-300 align-middle w-1/4" rowSpan={8}>
|
||||
<div className="h-40 flex items-center justify-center">도해 이미지 영역</div>
|
||||
<td className="border border-gray-400 p-2 text-center align-middle w-1/4" rowSpan={8}>
|
||||
{schematicImage ? (
|
||||
<img src={schematicImage} alt="기준서 도해" className="max-h-40 mx-auto object-contain" />
|
||||
) : (
|
||||
<div className="h-40 flex items-center justify-center text-gray-300">도해 이미지 영역</div>
|
||||
)}
|
||||
</td>
|
||||
{/* 헤더 행 */}
|
||||
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center" colSpan={2}>검사항목</th>
|
||||
|
||||
@@ -14,22 +14,36 @@
|
||||
* - 부적합 내용 / 종합판정(자동)
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
|
||||
import { useState, useCallback, useMemo, forwardRef, useImperativeHandle, useEffect } from 'react';
|
||||
import type { WorkOrder } from '../types';
|
||||
import type { InspectionData } from '@/components/production/WorkerScreen/InspectionInputModal';
|
||||
import type { WorkItemData } from '@/components/production/WorkerScreen/types';
|
||||
import type { InspectionDataMap } from './InspectionReportModal';
|
||||
|
||||
export interface InspectionContentRef {
|
||||
getInspectionData: () => unknown;
|
||||
}
|
||||
|
||||
interface SlatInspectionContentProps {
|
||||
export interface SlatInspectionContentProps {
|
||||
data: WorkOrder;
|
||||
readOnly?: boolean;
|
||||
inspectionData?: InspectionData;
|
||||
/** 작업 아이템 목록 - 행 개수 동적 생성용 */
|
||||
workItems?: WorkItemData[];
|
||||
/** 아이템별 검사 데이터 맵 */
|
||||
inspectionDataMap?: InspectionDataMap;
|
||||
/** 기준서 도해 이미지 URL */
|
||||
schematicImage?: string;
|
||||
/** 검사기준 이미지 URL */
|
||||
inspectionStandardImage?: string;
|
||||
}
|
||||
|
||||
type CheckStatus = '양호' | '불량' | null;
|
||||
|
||||
interface InspectionRow {
|
||||
id: number;
|
||||
itemId?: string; // 작업 아이템 ID
|
||||
itemName?: string; // 작업 아이템 이름
|
||||
processStatus: CheckStatus; // 가공상태 결모양
|
||||
assemblyStatus: CheckStatus; // 조립상태 결모양
|
||||
height1Standard: string; // ① 높이 기준치 (표시용)
|
||||
@@ -42,7 +56,14 @@ interface InspectionRow {
|
||||
|
||||
const DEFAULT_ROW_COUNT = 6;
|
||||
|
||||
export const SlatInspectionContent = forwardRef<InspectionContentRef, SlatInspectionContentProps>(function SlatInspectionContent({ data: order, readOnly = false }, ref) {
|
||||
export const SlatInspectionContent = forwardRef<InspectionContentRef, SlatInspectionContentProps>(function SlatInspectionContent({
|
||||
data: order,
|
||||
readOnly = false,
|
||||
workItems,
|
||||
inspectionDataMap,
|
||||
schematicImage,
|
||||
inspectionStandardImage,
|
||||
}, ref) {
|
||||
const fullDate = new Date().toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
@@ -58,19 +79,59 @@ export const SlatInspectionContent = forwardRef<InspectionContentRef, SlatInspec
|
||||
const documentNo = order.workOrderNo || 'ABC123';
|
||||
const primaryAssignee = order.assignees?.find(a => a.isPrimary)?.name || order.assignee || '-';
|
||||
|
||||
const [rows, setRows] = useState<InspectionRow[]>(() =>
|
||||
Array.from({ length: DEFAULT_ROW_COUNT }, (_, i) => ({
|
||||
id: i + 1,
|
||||
processStatus: null,
|
||||
assemblyStatus: null,
|
||||
height1Standard: '16.5 ± 1',
|
||||
height1Measured: '',
|
||||
height2Standard: '14.5 ± 1',
|
||||
height2Measured: '',
|
||||
lengthDesign: '0',
|
||||
lengthMeasured: '',
|
||||
}))
|
||||
);
|
||||
// 행 개수: workItems가 있으면 그 개수, 없으면 기본값
|
||||
const rowCount = workItems?.length || DEFAULT_ROW_COUNT;
|
||||
|
||||
// InspectionData를 InspectionRow로 변환하는 함수
|
||||
const convertToCheckStatus = (status: 'good' | 'bad' | null | undefined): CheckStatus => {
|
||||
if (status === 'good') return '양호';
|
||||
if (status === 'bad') return '불량';
|
||||
return null;
|
||||
};
|
||||
|
||||
const [rows, setRows] = useState<InspectionRow[]>(() => {
|
||||
return Array.from({ length: rowCount }, (_, i) => {
|
||||
const item = workItems?.[i];
|
||||
const itemData = item && inspectionDataMap?.get(item.id);
|
||||
|
||||
return {
|
||||
id: i + 1,
|
||||
itemId: item?.id,
|
||||
itemName: item?.itemName || '',
|
||||
processStatus: itemData ? convertToCheckStatus(itemData.processingStatus) : null,
|
||||
assemblyStatus: itemData ? convertToCheckStatus(itemData.assemblyStatus) : null,
|
||||
height1Standard: '16.5 ± 1',
|
||||
height1Measured: itemData?.height1?.toString() || '',
|
||||
height2Standard: '14.5 ± 1',
|
||||
height2Measured: itemData?.height2?.toString() || '',
|
||||
lengthDesign: '0',
|
||||
lengthMeasured: itemData?.length?.toString() || '',
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// workItems나 inspectionDataMap이 변경되면 rows 업데이트
|
||||
useEffect(() => {
|
||||
const newRowCount = workItems?.length || DEFAULT_ROW_COUNT;
|
||||
setRows(Array.from({ length: newRowCount }, (_, i) => {
|
||||
const item = workItems?.[i];
|
||||
const itemData = item && inspectionDataMap?.get(item.id);
|
||||
|
||||
return {
|
||||
id: i + 1,
|
||||
itemId: item?.id,
|
||||
itemName: item?.itemName || '',
|
||||
processStatus: itemData ? convertToCheckStatus(itemData.processingStatus) : null,
|
||||
assemblyStatus: itemData ? convertToCheckStatus(itemData.assemblyStatus) : null,
|
||||
height1Standard: '16.5 ± 1',
|
||||
height1Measured: itemData?.height1?.toString() || '',
|
||||
height2Standard: '14.5 ± 1',
|
||||
height2Measured: itemData?.height2?.toString() || '',
|
||||
lengthDesign: '0',
|
||||
lengthMeasured: itemData?.length?.toString() || '',
|
||||
};
|
||||
}));
|
||||
}, [workItems, inspectionDataMap]);
|
||||
|
||||
const [inadequateContent, setInadequateContent] = useState('');
|
||||
|
||||
@@ -225,8 +286,12 @@ export const SlatInspectionContent = forwardRef<InspectionContentRef, SlatInspec
|
||||
<tbody>
|
||||
<tr>
|
||||
{/* 도해 영역 */}
|
||||
<td className="border border-gray-400 p-4 text-center text-gray-300 align-middle w-1/5" rowSpan={7}>
|
||||
<div className="h-40 flex items-center justify-center">도해 이미지 영역</div>
|
||||
<td className="border border-gray-400 p-2 text-center align-middle w-1/5" rowSpan={7}>
|
||||
{schematicImage ? (
|
||||
<img src={schematicImage} alt="기준서 도해" className="max-h-40 mx-auto object-contain" />
|
||||
) : (
|
||||
<div className="h-40 flex items-center justify-center text-gray-300">도해 이미지 영역</div>
|
||||
)}
|
||||
</td>
|
||||
{/* 헤더 행 */}
|
||||
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center" colSpan={3}>검사항목</th>
|
||||
|
||||
@@ -13,22 +13,36 @@
|
||||
* - 부적합 내용 / 종합판정 (자동)
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
|
||||
import { useState, useCallback, useMemo, forwardRef, useImperativeHandle, useEffect } from 'react';
|
||||
import type { WorkOrder } from '../types';
|
||||
import type { InspectionData } from '@/components/production/WorkerScreen/InspectionInputModal';
|
||||
import type { WorkItemData } from '@/components/production/WorkerScreen/types';
|
||||
import type { InspectionDataMap } from './InspectionReportModal';
|
||||
|
||||
export interface InspectionContentRef {
|
||||
getInspectionData: () => unknown;
|
||||
}
|
||||
|
||||
interface SlatJointBarInspectionContentProps {
|
||||
export interface SlatJointBarInspectionContentProps {
|
||||
data: WorkOrder;
|
||||
readOnly?: boolean;
|
||||
inspectionData?: InspectionData;
|
||||
/** 작업 아이템 목록 - 행 개수 동적 생성용 */
|
||||
workItems?: WorkItemData[];
|
||||
/** 아이템별 검사 데이터 맵 */
|
||||
inspectionDataMap?: InspectionDataMap;
|
||||
/** 기준서 도해 이미지 URL */
|
||||
schematicImage?: string;
|
||||
/** 검사기준 이미지 URL */
|
||||
inspectionStandardImage?: string;
|
||||
}
|
||||
|
||||
type CheckStatus = '양호' | '불량' | null;
|
||||
|
||||
interface InspectionRow {
|
||||
id: number;
|
||||
itemId?: string; // 작업 아이템 ID (연동용)
|
||||
itemName?: string; // 작업 아이템명 (연동용)
|
||||
processStatus: CheckStatus; // 가공상태
|
||||
assemblyStatus: CheckStatus; // 조립상태
|
||||
height1Standard: string; // ①높이 기준치
|
||||
@@ -43,7 +57,21 @@ interface InspectionRow {
|
||||
|
||||
const DEFAULT_ROW_COUNT = 6;
|
||||
|
||||
export const SlatJointBarInspectionContent = forwardRef<InspectionContentRef, SlatJointBarInspectionContentProps>(function SlatJointBarInspectionContent({ data: order, readOnly = false }, ref) {
|
||||
// 상태 변환 함수: 'good'/'bad' → '양호'/'불량'
|
||||
const convertToCheckStatus = (status: 'good' | 'bad' | null | undefined): CheckStatus => {
|
||||
if (status === 'good') return '양호';
|
||||
if (status === 'bad') return '불량';
|
||||
return null;
|
||||
};
|
||||
|
||||
export const SlatJointBarInspectionContent = forwardRef<InspectionContentRef, SlatJointBarInspectionContentProps>(function SlatJointBarInspectionContent({
|
||||
data: order,
|
||||
readOnly = false,
|
||||
workItems,
|
||||
inspectionDataMap,
|
||||
schematicImage,
|
||||
inspectionStandardImage,
|
||||
}, ref) {
|
||||
const fullDate = new Date().toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
@@ -59,24 +87,57 @@ export const SlatJointBarInspectionContent = forwardRef<InspectionContentRef, Sl
|
||||
const documentNo = order.workOrderNo || 'ABC123';
|
||||
const primaryAssignee = order.assignees?.find(a => a.isPrimary)?.name || order.assignee || '-';
|
||||
|
||||
// workItems 기반 행 개수 결정
|
||||
const rowCount = workItems?.length || DEFAULT_ROW_COUNT;
|
||||
|
||||
const [rows, setRows] = useState<InspectionRow[]>(() =>
|
||||
Array.from({ length: DEFAULT_ROW_COUNT }, (_, i) => ({
|
||||
id: i + 1,
|
||||
processStatus: null,
|
||||
assemblyStatus: null,
|
||||
height1Standard: '43.1 ± 0.5',
|
||||
height1Measured: '',
|
||||
height2Standard: '14.5 ± 1',
|
||||
height2Measured: '',
|
||||
lengthDesign: '',
|
||||
lengthMeasured: '',
|
||||
intervalStandard: '150 ± 4',
|
||||
intervalMeasured: '',
|
||||
}))
|
||||
Array.from({ length: rowCount }, (_, i) => {
|
||||
const item = workItems?.[i];
|
||||
const itemData = item && inspectionDataMap?.get(item.id);
|
||||
return {
|
||||
id: i + 1,
|
||||
itemId: item?.id,
|
||||
itemName: item?.itemName,
|
||||
processStatus: itemData ? convertToCheckStatus(itemData.processingStatus) : null,
|
||||
assemblyStatus: itemData ? convertToCheckStatus(itemData.assemblyStatus) : null,
|
||||
height1Standard: '43.1 ± 0.5',
|
||||
height1Measured: '',
|
||||
height2Standard: '14.5 ± 1',
|
||||
height2Measured: '',
|
||||
lengthDesign: '',
|
||||
lengthMeasured: '',
|
||||
intervalStandard: '150 ± 4',
|
||||
intervalMeasured: '',
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const [inadequateContent, setInadequateContent] = useState('');
|
||||
|
||||
// workItems 또는 inspectionDataMap 변경 시 행 업데이트
|
||||
useEffect(() => {
|
||||
const newRowCount = workItems?.length || DEFAULT_ROW_COUNT;
|
||||
setRows(Array.from({ length: newRowCount }, (_, i) => {
|
||||
const item = workItems?.[i];
|
||||
const itemData = item && inspectionDataMap?.get(item.id);
|
||||
return {
|
||||
id: i + 1,
|
||||
itemId: item?.id,
|
||||
itemName: item?.itemName,
|
||||
processStatus: itemData ? convertToCheckStatus(itemData.processingStatus) : null,
|
||||
assemblyStatus: itemData ? convertToCheckStatus(itemData.assemblyStatus) : null,
|
||||
height1Standard: '43.1 ± 0.5',
|
||||
height1Measured: '',
|
||||
height2Standard: '14.5 ± 1',
|
||||
height2Measured: '',
|
||||
lengthDesign: '',
|
||||
lengthMeasured: '',
|
||||
intervalStandard: '150 ± 4',
|
||||
intervalMeasured: '',
|
||||
};
|
||||
}));
|
||||
}, [workItems, inspectionDataMap]);
|
||||
|
||||
const handleStatusChange = useCallback((rowId: number, field: 'processStatus' | 'assemblyStatus', value: CheckStatus) => {
|
||||
if (readOnly) return;
|
||||
setRows(prev => prev.map(row =>
|
||||
@@ -228,8 +289,12 @@ export const SlatJointBarInspectionContent = forwardRef<InspectionContentRef, Sl
|
||||
<tbody>
|
||||
<tr>
|
||||
{/* 도해 영역 */}
|
||||
<td className="border border-gray-400 p-4 text-center text-gray-300 align-middle w-1/5" rowSpan={8}>
|
||||
<div className="h-40 flex items-center justify-center">도해 이미지 영역</div>
|
||||
<td className="border border-gray-400 p-2 text-center align-middle w-1/4" rowSpan={8}>
|
||||
{schematicImage ? (
|
||||
<img src={schematicImage} alt="기준서 도해" className="max-h-40 mx-auto object-contain" />
|
||||
) : (
|
||||
<div className="h-40 flex items-center justify-center text-gray-300">도해 이미지 영역</div>
|
||||
)}
|
||||
</td>
|
||||
{/* 헤더 행 */}
|
||||
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center" colSpan={2}>검사항목</th>
|
||||
|
||||
654
src/components/production/WorkerScreen/InspectionInputModal.tsx
Normal file
654
src/components/production/WorkerScreen/InspectionInputModal.tsx
Normal file
@@ -0,0 +1,654 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 중간검사 입력 모달
|
||||
*
|
||||
* 공정별로 다른 검사 항목 표시:
|
||||
* - screen: 스크린 중간검사
|
||||
* - slat: 슬랫 중간검사
|
||||
* - slat_jointbar: 조인트바 중간검사
|
||||
* - bending: 절곡 중간검사
|
||||
* - bending_wip: 재고생산(재공품) 중간검사
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// 중간검사 공정 타입
|
||||
export type InspectionProcessType =
|
||||
| 'screen'
|
||||
| 'slat'
|
||||
| 'slat_jointbar'
|
||||
| 'bending'
|
||||
| 'bending_wip';
|
||||
|
||||
// 검사 결과 데이터 타입
|
||||
export interface InspectionData {
|
||||
productName: string;
|
||||
specification: string;
|
||||
// 겉모양 상태
|
||||
bendingStatus?: 'good' | 'bad' | null; // 절곡상태
|
||||
processingStatus?: 'good' | 'bad' | null; // 가공상태
|
||||
sewingStatus?: 'good' | 'bad' | null; // 재봉상태
|
||||
assemblyStatus?: 'good' | 'bad' | null; // 조립상태
|
||||
// 치수
|
||||
length?: number | null;
|
||||
width?: number | null;
|
||||
height1?: number | null;
|
||||
height2?: number | null;
|
||||
length3?: number | null;
|
||||
gap4?: number | null;
|
||||
gapStatus?: 'ok' | 'ng' | null;
|
||||
// 간격 포인트들 (절곡용)
|
||||
gapPoints?: { left: number | null; right: number | null }[];
|
||||
// 판정
|
||||
judgment: 'pass' | 'fail' | null;
|
||||
nonConformingContent: string;
|
||||
}
|
||||
|
||||
interface InspectionInputModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
processType: InspectionProcessType;
|
||||
productName?: string;
|
||||
specification?: string;
|
||||
onComplete: (data: InspectionData) => void;
|
||||
}
|
||||
|
||||
const PROCESS_TITLES: Record<InspectionProcessType, string> = {
|
||||
screen: '# 스크린 중간검사',
|
||||
slat: '# 슬랫 중간검사',
|
||||
slat_jointbar: '# 조인트바 중간검사',
|
||||
bending: '# 절곡 중간검사',
|
||||
bending_wip: '# 재고생산 중간검사',
|
||||
};
|
||||
|
||||
// 양호/불량 버튼 컴포넌트
|
||||
function StatusToggle({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: 'good' | 'bad' | null;
|
||||
onChange: (v: 'good' | 'bad') => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('good')}
|
||||
className={cn(
|
||||
'px-4 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
value === 'good'
|
||||
? 'bg-orange-500 text-white'
|
||||
: 'bg-gray-700 text-white hover:bg-gray-600'
|
||||
)}
|
||||
>
|
||||
양호
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('bad')}
|
||||
className={cn(
|
||||
'px-4 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
value === 'bad'
|
||||
? 'bg-gray-900 text-white'
|
||||
: 'bg-gray-700 text-white hover:bg-gray-600'
|
||||
)}
|
||||
>
|
||||
불량
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// OK/NG 버튼 컴포넌트
|
||||
function OkNgToggle({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: 'ok' | 'ng' | null;
|
||||
onChange: (v: 'ok' | 'ng') => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('ok')}
|
||||
className={cn(
|
||||
'px-6 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
value === 'ok'
|
||||
? 'bg-gray-900 text-white'
|
||||
: 'bg-gray-700 text-white hover:bg-gray-600'
|
||||
)}
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('ng')}
|
||||
className={cn(
|
||||
'px-6 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
value === 'ng'
|
||||
? 'bg-gray-900 text-white'
|
||||
: 'bg-gray-700 text-white hover:bg-gray-600'
|
||||
)}
|
||||
>
|
||||
NG
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 적합/부적합 버튼 컴포넌트
|
||||
function JudgmentToggle({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: 'pass' | 'fail' | null;
|
||||
onChange: (v: 'pass' | 'fail') => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('pass')}
|
||||
className={cn(
|
||||
'px-4 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
value === 'pass'
|
||||
? 'bg-orange-500 text-white'
|
||||
: 'bg-gray-700 text-white hover:bg-gray-600'
|
||||
)}
|
||||
>
|
||||
적합
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('fail')}
|
||||
className={cn(
|
||||
'px-4 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
value === 'fail'
|
||||
? 'bg-gray-900 text-white'
|
||||
: 'bg-gray-700 text-white hover:bg-gray-600'
|
||||
)}
|
||||
>
|
||||
부적합
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InspectionInputModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
processType,
|
||||
productName = '',
|
||||
specification = '',
|
||||
onComplete,
|
||||
}: InspectionInputModalProps) {
|
||||
const [formData, setFormData] = useState<InspectionData>({
|
||||
productName,
|
||||
specification,
|
||||
judgment: null,
|
||||
nonConformingContent: '',
|
||||
});
|
||||
|
||||
// 절곡용 간격 포인트 초기화
|
||||
const [gapPoints, setGapPoints] = useState<{ left: number | null; right: number | null }[]>(
|
||||
Array(5).fill(null).map(() => ({ left: null, right: null }))
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// 공정별 기본값 설정 - 모두 양호/OK/적합 상태로 초기화
|
||||
const baseData: InspectionData = {
|
||||
productName,
|
||||
specification,
|
||||
judgment: 'pass', // 기본값: 적합
|
||||
nonConformingContent: '',
|
||||
};
|
||||
|
||||
// 공정별 추가 기본값 설정
|
||||
switch (processType) {
|
||||
case 'screen':
|
||||
setFormData({
|
||||
...baseData,
|
||||
processingStatus: 'good', // 가공상태: 양호
|
||||
sewingStatus: 'good', // 재봉상태: 양호
|
||||
assemblyStatus: 'good', // 조립상태: 양호
|
||||
gapStatus: 'ok', // 간격: OK
|
||||
});
|
||||
break;
|
||||
case 'slat':
|
||||
setFormData({
|
||||
...baseData,
|
||||
processingStatus: 'good', // 가공상태: 양호
|
||||
assemblyStatus: 'good', // 조립상태: 양호
|
||||
});
|
||||
break;
|
||||
case 'slat_jointbar':
|
||||
setFormData({
|
||||
...baseData,
|
||||
processingStatus: 'good', // 가공상태: 양호
|
||||
assemblyStatus: 'good', // 조립상태: 양호
|
||||
});
|
||||
break;
|
||||
case 'bending':
|
||||
setFormData({
|
||||
...baseData,
|
||||
bendingStatus: 'good', // 절곡상태: 양호
|
||||
});
|
||||
break;
|
||||
case 'bending_wip':
|
||||
setFormData({
|
||||
...baseData,
|
||||
bendingStatus: 'good', // 절곡상태: 양호
|
||||
});
|
||||
break;
|
||||
default:
|
||||
setFormData(baseData);
|
||||
}
|
||||
|
||||
setGapPoints(Array(5).fill(null).map(() => ({ left: null, right: null })));
|
||||
}
|
||||
}, [open, productName, specification, processType]);
|
||||
|
||||
const handleComplete = () => {
|
||||
const data: InspectionData = {
|
||||
...formData,
|
||||
gapPoints: processType === 'bending' ? gapPoints : undefined,
|
||||
};
|
||||
onComplete(data);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
// 숫자 입력 핸들러
|
||||
const handleNumberChange = (
|
||||
key: keyof InspectionData,
|
||||
value: string
|
||||
) => {
|
||||
const num = value === '' ? null : parseFloat(value);
|
||||
setFormData((prev) => ({ ...prev, [key]: num }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[95vw] max-w-[500px] sm:max-w-[500px] bg-gray-900 text-white border-gray-700">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white text-lg font-bold">
|
||||
{PROCESS_TITLES[processType]}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 mt-4 max-h-[70vh] overflow-y-auto pr-2">
|
||||
{/* 제품명 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">제품명</Label>
|
||||
<Input
|
||||
value={formData.productName}
|
||||
readOnly
|
||||
className="bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 규격 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">규격</Label>
|
||||
<Input
|
||||
value={formData.specification}
|
||||
readOnly
|
||||
className="bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ===== 재고생산 (bending_wip) 검사 항목 ===== */}
|
||||
{processType === 'bending_wip' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">검모양 절곡상태</Label>
|
||||
<StatusToggle
|
||||
value={formData.bendingStatus || null}
|
||||
onChange={(v) => setFormData((prev) => ({ ...prev, bendingStatus: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">길이 (1,000)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="1,000"
|
||||
value={formData.length ?? ''}
|
||||
onChange={(e) => handleNumberChange('length', e.target.value)}
|
||||
className="bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">너비 (1,000)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="1,000"
|
||||
value={formData.width ?? ''}
|
||||
onChange={(e) => handleNumberChange('width', e.target.value)}
|
||||
className="bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">간격 (1,000)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="①"
|
||||
className="bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="1,000"
|
||||
className="bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ===== 스크린 검사 항목 ===== */}
|
||||
{processType === 'screen' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">검모양 가공상태</Label>
|
||||
<StatusToggle
|
||||
value={formData.processingStatus || null}
|
||||
onChange={(v) => setFormData((prev) => ({ ...prev, processingStatus: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">검모양 재봉상태</Label>
|
||||
<StatusToggle
|
||||
value={formData.sewingStatus || null}
|
||||
onChange={(v) => setFormData((prev) => ({ ...prev, sewingStatus: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">검모양 조립상태</Label>
|
||||
<StatusToggle
|
||||
value={formData.assemblyStatus || null}
|
||||
onChange={(v) => setFormData((prev) => ({ ...prev, assemblyStatus: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">길이 (1,000)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="1,000"
|
||||
value={formData.length ?? ''}
|
||||
onChange={(e) => handleNumberChange('length', e.target.value)}
|
||||
className="bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">너비 (1,000)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="1,000"
|
||||
value={formData.width ?? ''}
|
||||
onChange={(e) => handleNumberChange('width', e.target.value)}
|
||||
className="bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">간격 (400 이하)</Label>
|
||||
<OkNgToggle
|
||||
value={formData.gapStatus || null}
|
||||
onChange={(v) => setFormData((prev) => ({ ...prev, gapStatus: v }))}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ===== 슬랫 검사 항목 ===== */}
|
||||
{processType === 'slat' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">검모양 가공상태</Label>
|
||||
<StatusToggle
|
||||
value={formData.processingStatus || null}
|
||||
onChange={(v) => setFormData((prev) => ({ ...prev, processingStatus: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">검모양 조립상태</Label>
|
||||
<StatusToggle
|
||||
value={formData.assemblyStatus || null}
|
||||
onChange={(v) => setFormData((prev) => ({ ...prev, assemblyStatus: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">① 높이 (16.5 ± 1)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="16.5"
|
||||
value={formData.height1 ?? ''}
|
||||
onChange={(e) => handleNumberChange('height1', e.target.value)}
|
||||
className="bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">② 높이 (14.5 ± 1)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="14.5"
|
||||
value={formData.height2 ?? ''}
|
||||
onChange={(e) => handleNumberChange('height2', e.target.value)}
|
||||
className="bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">길이 (0)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="0"
|
||||
value={formData.length ?? ''}
|
||||
onChange={(e) => handleNumberChange('length', e.target.value)}
|
||||
className="bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ===== 조인트바 검사 항목 ===== */}
|
||||
{processType === 'slat_jointbar' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">검모양 가공상태</Label>
|
||||
<StatusToggle
|
||||
value={formData.processingStatus || null}
|
||||
onChange={(v) => setFormData((prev) => ({ ...prev, processingStatus: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">검모양 조립상태</Label>
|
||||
<StatusToggle
|
||||
value={formData.assemblyStatus || null}
|
||||
onChange={(v) => setFormData((prev) => ({ ...prev, assemblyStatus: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">① 높이 (16.5 ± 1)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="16.5"
|
||||
value={formData.height1 ?? ''}
|
||||
onChange={(e) => handleNumberChange('height1', e.target.value)}
|
||||
className="bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">② 높이 (14.5 ± 1)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="14.5"
|
||||
value={formData.height2 ?? ''}
|
||||
onChange={(e) => handleNumberChange('height2', e.target.value)}
|
||||
className="bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">③ 길이 (300 ± 1)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="300"
|
||||
value={formData.length3 ?? ''}
|
||||
onChange={(e) => handleNumberChange('length3', e.target.value)}
|
||||
className="bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">④ 간격 (150 ± 1)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="150"
|
||||
value={formData.gap4 ?? ''}
|
||||
onChange={(e) => handleNumberChange('gap4', e.target.value)}
|
||||
className="bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ===== 절곡 검사 항목 ===== */}
|
||||
{processType === 'bending' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">검모양 절곡상태</Label>
|
||||
<StatusToggle
|
||||
value={formData.bendingStatus || null}
|
||||
onChange={(v) => setFormData((prev) => ({ ...prev, bendingStatus: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">길이 (1,000)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="1,000"
|
||||
value={formData.length ?? ''}
|
||||
onChange={(e) => handleNumberChange('length', e.target.value)}
|
||||
className="bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">너비 (N/A)</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="N/A"
|
||||
value={formData.width ?? 'N/A'}
|
||||
readOnly
|
||||
className="bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Label className="text-gray-300">간격</Label>
|
||||
{gapPoints.map((point, index) => (
|
||||
<div key={index} className="grid grid-cols-3 gap-2 items-center">
|
||||
<span className="text-gray-400 text-sm">⑤{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="bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
<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="bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 공통: 판정 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">판정</Label>
|
||||
<JudgmentToggle
|
||||
value={formData.judgment}
|
||||
onChange={(v) => setFormData((prev) => ({ ...prev, judgment: v }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 공통: 부적합 내용 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">부적합 내용</Label>
|
||||
<Textarea
|
||||
value={formData.nonConformingContent}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, nonConformingContent: e.target.value }))
|
||||
}
|
||||
placeholder="입력 시 '일련번호: 내용' 형태로 취합되어 표시"
|
||||
className="bg-gray-800 border-gray-700 text-white min-h-[80px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex gap-3 mt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
className="flex-1 bg-gray-800 border-gray-700 text-white hover:bg-gray-700"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleComplete}
|
||||
className="flex-1 bg-orange-500 hover:bg-orange-600 text-white"
|
||||
>
|
||||
검사 완료
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -3,10 +3,14 @@
|
||||
/**
|
||||
* 작업 아이템 카드 컴포넌트 (기획서 기반)
|
||||
*
|
||||
* 공통: 번호 + 품목코드(품목명) + 층/부호, 제작사이즈, 진척률바, pills, 자재투입목록(토글)
|
||||
* 스크린: 절단정보 (폭 X 장)
|
||||
* 슬랫: 길이 / 슬랫매수 / 조인트바
|
||||
* 절곡: 도면(IMG) + 공통사항 + 세부부품
|
||||
* 디자인 스펙 (스크린샷 2026-02-05 기준):
|
||||
* - 카드: 흰색 배경, 미세한 그림자
|
||||
* - 헤더: 번호 뱃지(녹색 원) + 품목코드(품목명) + 층/부호(우측 정렬)
|
||||
* - 제작 사이즈: 한 줄로 표시
|
||||
* - 절단정보: 연한 회색 박스 (라벨 상단, 값 하단)
|
||||
* - 진행률: 파란색 프로그래스 바 + 우측에 "N/M 완료"
|
||||
* - 공정 버튼: 완료는 녹색 배경, 미완료는 다크 배경
|
||||
* - 자재 투입 목록: 토글 (쉐브론 아이콘 + 텍스트)
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
@@ -14,7 +18,6 @@ import { ChevronDown, ChevronUp, Pencil, Trash2, ImageIcon } from 'lucide-react'
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -35,6 +38,9 @@ interface WorkItemCardProps {
|
||||
onStepClick: (itemId: string, step: WorkStepData) => void;
|
||||
onEditMaterial: (itemId: string, material: MaterialListItem) => void;
|
||||
onDeleteMaterial: (itemId: string, materialId: string) => void;
|
||||
onInspectionClick?: (itemId: string) => void;
|
||||
inspectionChecked?: boolean;
|
||||
onInspectionToggle?: (itemId: string, checked: boolean) => void;
|
||||
}
|
||||
|
||||
export function WorkItemCard({
|
||||
@@ -42,6 +48,9 @@ export function WorkItemCard({
|
||||
onStepClick,
|
||||
onEditMaterial,
|
||||
onDeleteMaterial,
|
||||
onInspectionClick,
|
||||
inspectionChecked,
|
||||
onInspectionToggle,
|
||||
}: WorkItemCardProps) {
|
||||
const [isMaterialListOpen, setIsMaterialListOpen] = useState(false);
|
||||
|
||||
@@ -58,20 +67,20 @@ export function WorkItemCard({
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="bg-white shadow-sm border border-gray-200">
|
||||
<CardContent className="p-4 space-y-3">
|
||||
{/* 헤더: 번호 + 품목코드(품목명) + 층/부호 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex items-center justify-center w-7 h-7 rounded-full bg-gray-900 text-white text-sm font-bold">
|
||||
<Card className="bg-white shadow-sm border border-gray-100">
|
||||
<CardContent className="p-5 space-y-4">
|
||||
{/* 헤더: 번호 뱃지 + 품목코드(품목명) + 층/부호 */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex items-center justify-center w-8 h-8 rounded-full bg-emerald-500 text-white text-sm font-bold shrink-0">
|
||||
{item.itemNo}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
<span className="text-base font-bold text-gray-900">
|
||||
{item.itemCode} ({item.itemName})
|
||||
</span>
|
||||
</div>
|
||||
{!item.isWip && (
|
||||
<span className="text-sm text-gray-500">
|
||||
<span className="text-sm text-gray-500 shrink-0">
|
||||
{item.floor} / {item.code}
|
||||
</span>
|
||||
)}
|
||||
@@ -79,12 +88,12 @@ export function WorkItemCard({
|
||||
|
||||
{/* 제작 사이즈 (재공품은 숨김) */}
|
||||
{!item.isWip && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<div className="flex items-center gap-1.5 text-sm text-gray-700">
|
||||
<span className="text-gray-500">제작 사이즈</span>
|
||||
<span className="font-medium">
|
||||
{item.width.toLocaleString()} X {item.height.toLocaleString()} mm
|
||||
</span>
|
||||
<span className="font-medium">{item.quantity}개</span>
|
||||
<span className="font-medium text-gray-900">{item.quantity}개</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -113,37 +122,75 @@ export function WorkItemCard({
|
||||
)}
|
||||
|
||||
{/* 진척률 프로그래스 바 */}
|
||||
<div className="space-y-1">
|
||||
<Progress value={progressPercent} className="h-2" />
|
||||
<p className="text-xs text-gray-500 text-right">
|
||||
<div className="space-y-1.5">
|
||||
<div className="w-full h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-blue-400 to-blue-500 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 text-right">
|
||||
{completedSteps}/{totalSteps} 완료
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 공정 단계 pills */}
|
||||
{/* 공정 단계 버튼 - 중간검사는 포장완료 앞에 위치 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.steps.map((step) => (
|
||||
{/* 포장완료 전 단계들 */}
|
||||
{item.steps.slice(0, -1).map((step) => (
|
||||
<button
|
||||
key={step.id}
|
||||
onClick={() => handleStepClick(step)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-md text-xs font-medium transition-colors',
|
||||
'bg-gray-900 text-white hover:bg-gray-800'
|
||||
'px-4 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
step.isCompleted
|
||||
? 'bg-emerald-500 text-white hover:bg-emerald-600'
|
||||
: 'bg-gray-800 text-white hover:bg-gray-700'
|
||||
)}
|
||||
>
|
||||
{step.name}
|
||||
{step.isCompleted && (
|
||||
<span className="ml-1 text-green-400">완료</span>
|
||||
<span className="ml-1.5 font-semibold">완료</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{/* 중간검사 버튼 - 포장완료 앞 */}
|
||||
{onInspectionClick && (
|
||||
<button
|
||||
onClick={() => onInspectionClick(item.id)}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-gray-800 text-white hover:bg-gray-700"
|
||||
>
|
||||
중간검사
|
||||
</button>
|
||||
)}
|
||||
{/* 포장완료 (마지막 단계) */}
|
||||
{item.steps.length > 0 && (() => {
|
||||
const lastStep = item.steps[item.steps.length - 1];
|
||||
return (
|
||||
<button
|
||||
key={lastStep.id}
|
||||
onClick={() => handleStepClick(lastStep)}
|
||||
className={cn(
|
||||
'px-4 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
lastStep.isCompleted
|
||||
? 'bg-emerald-500 text-white hover:bg-emerald-600'
|
||||
: 'bg-gray-800 text-white hover:bg-gray-700'
|
||||
)}
|
||||
>
|
||||
{lastStep.name}
|
||||
{lastStep.isCompleted && (
|
||||
<span className="ml-1.5 font-semibold">완료</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* 자재 투입 목록 (토글) */}
|
||||
<div>
|
||||
<div className="pt-1">
|
||||
<button
|
||||
onClick={() => setIsMaterialListOpen(!isMaterialListOpen)}
|
||||
className="flex items-center gap-1 text-sm font-medium text-gray-700 hover:text-gray-900"
|
||||
className="flex items-center gap-1.5 text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
{isMaterialListOpen ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
@@ -154,7 +201,7 @@ export function WorkItemCard({
|
||||
</button>
|
||||
|
||||
{isMaterialListOpen && (
|
||||
<div className="mt-2 border rounded-lg overflow-hidden">
|
||||
<div className="mt-3 border rounded-lg overflow-hidden">
|
||||
{(!item.materialInputs || item.materialInputs.length === 0) ? (
|
||||
<div className="py-6 text-center text-sm text-gray-500">
|
||||
투입된 자재가 없습니다.
|
||||
@@ -213,9 +260,9 @@ export function WorkItemCard({
|
||||
// ===== 스크린 전용: 절단정보 =====
|
||||
function ScreenCuttingInfo({ width, sheets }: { width: number; sheets: number }) {
|
||||
return (
|
||||
<div className="p-3 bg-blue-50 rounded-lg border border-blue-100">
|
||||
<p className="text-xs text-gray-500 mb-1">절단정보</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
<div className="p-4 bg-gray-50 rounded-lg border border-gray-100">
|
||||
<p className="text-xs text-gray-400 mb-1.5">절단정보</p>
|
||||
<p className="text-base font-semibold text-gray-900">
|
||||
폭 {width.toLocaleString()}mm X {sheets}장
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,8 @@ import { Button } from '@/components/ui/button';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { toast } from 'sonner';
|
||||
import { getMyWorkOrders, completeWorkOrder } from './actions';
|
||||
import { getProcessList } from '@/components/process-management/actions';
|
||||
import type { InspectionSetting, Process } from '@/types/process';
|
||||
import type { WorkOrder } from '../ProductionDashboard/types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import type {
|
||||
@@ -52,6 +54,7 @@ import { WorkLogModal } from './WorkLogModal';
|
||||
import { IssueReportModal } from './IssueReportModal';
|
||||
import { WorkCompletionResultDialog } from './WorkCompletionResultDialog';
|
||||
import { InspectionReportModal } from '../WorkOrders/documents';
|
||||
import { InspectionInputModal, type InspectionProcessType, type InspectionData } from './InspectionInputModal';
|
||||
|
||||
// ===== 목업 데이터 =====
|
||||
const MOCK_ITEMS: Record<ProcessTab, WorkItemData[]> = {
|
||||
@@ -128,7 +131,7 @@ const MOCK_ITEMS: Record<ProcessTab, WorkItemData[]> = {
|
||||
width: 0, height: 0, quantity: 6, processType: 'bending',
|
||||
bendingInfo: {
|
||||
common: {
|
||||
kind: '벽면형 120X70', type: '벽면형',
|
||||
kind: '혼합형 120X70', type: '혼합형',
|
||||
lengthQuantities: [{ length: 4000, quantity: 6 }, { length: 3000, quantity: 6 }],
|
||||
},
|
||||
detailParts: [
|
||||
@@ -143,26 +146,6 @@ const MOCK_ITEMS: Record<ProcessTab, WorkItemData[]> = {
|
||||
{ id: 'b1-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mock-b2', itemNo: 2, itemCode: 'KWWS10', itemName: '천정레일', floor: '2층', code: 'FSS-04',
|
||||
width: 0, height: 0, quantity: 4, processType: 'bending',
|
||||
bendingInfo: {
|
||||
drawingUrl: '',
|
||||
common: {
|
||||
kind: '천정형 80X60', type: '천정형',
|
||||
lengthQuantities: [{ length: 3500, quantity: 4 }],
|
||||
},
|
||||
detailParts: [
|
||||
{ partName: '상장바', material: 'STS 1.2T', barcyInfo: '12 I 60' },
|
||||
],
|
||||
},
|
||||
steps: [
|
||||
{ id: 'b2-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
|
||||
{ id: 'b2-2', name: '절단', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 'b2-3', name: '절곡', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 'b2-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -300,6 +283,20 @@ export default function WorkerScreen() {
|
||||
const [isWorkLogModalOpen, setIsWorkLogModalOpen] = useState(false);
|
||||
const [isInspectionModalOpen, setIsInspectionModalOpen] = useState(false);
|
||||
const [isIssueReportModalOpen, setIsIssueReportModalOpen] = useState(false);
|
||||
const [isInspectionInputModalOpen, setIsInspectionInputModalOpen] = useState(false);
|
||||
// 공정의 중간검사 설정
|
||||
const [currentInspectionSetting, setCurrentInspectionSetting] = useState<InspectionSetting | undefined>();
|
||||
|
||||
// 중간검사 체크 상태 관리: { [itemId]: boolean }
|
||||
const [inspectionCheckedMap, setInspectionCheckedMap] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 체크된 검사 항목 수 계산
|
||||
const checkedInspectionCount = useMemo(() => {
|
||||
return Object.values(inspectionCheckedMap).filter(Boolean).length;
|
||||
}, [inspectionCheckedMap]);
|
||||
|
||||
// 중간검사 완료 데이터 관리
|
||||
const [inspectionDataMap, setInspectionDataMap] = useState<Map<string, InspectionData>>(new Map());
|
||||
|
||||
// 전량완료 흐름 상태
|
||||
const [isCompletionFlow, setIsCompletionFlow] = useState(false);
|
||||
@@ -314,6 +311,51 @@ export default function WorkerScreen() {
|
||||
// 완료 토스트 상태
|
||||
const [toastInfo, setToastInfo] = useState<CompletionToastInfo | null>(null);
|
||||
|
||||
// 공정 목록 캐시
|
||||
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();
|
||||
}, []);
|
||||
|
||||
// activeTab 변경 시 해당 공정의 중간검사 설정 조회
|
||||
useEffect(() => {
|
||||
if (processListCache.length === 0) return;
|
||||
|
||||
// activeTab에 해당하는 공정 찾기
|
||||
const tabToProcessName: Record<ProcessTab, string[]> = {
|
||||
screen: ['스크린', 'screen'],
|
||||
slat: ['슬랫', 'slat'],
|
||||
bending: ['절곡', 'bending'],
|
||||
};
|
||||
|
||||
const matchNames = tabToProcessName[activeTab] || [];
|
||||
const matchedProcess = processListCache.find((p) =>
|
||||
matchNames.some((name) => p.processName.toLowerCase().includes(name.toLowerCase()))
|
||||
);
|
||||
|
||||
if (matchedProcess?.steps) {
|
||||
// 검사 단계에서 inspectionSetting 찾기
|
||||
const inspectionStep = matchedProcess.steps.find(
|
||||
(step) => step.needsInspection && step.inspectionSetting
|
||||
);
|
||||
setCurrentInspectionSetting(inspectionStep?.inspectionSetting);
|
||||
} else {
|
||||
setCurrentInspectionSetting(undefined);
|
||||
}
|
||||
}, [activeTab, processListCache]);
|
||||
|
||||
// ===== 탭별 필터링된 작업 =====
|
||||
const filteredWorkOrders = useMemo(() => {
|
||||
// process_type 기반 필터링
|
||||
@@ -571,6 +613,45 @@ export default function WorkerScreen() {
|
||||
};
|
||||
}, [filteredWorkOrders, workItems]);
|
||||
|
||||
// 중간검사 버튼 클릭 핸들러 - 바로 모달 열기
|
||||
const handleInspectionClick = useCallback((itemId: string) => {
|
||||
// 해당 아이템 찾기
|
||||
const item = workItems.find((w) => w.id === itemId);
|
||||
if (item) {
|
||||
// 합성 WorkOrder 생성
|
||||
const syntheticOrder: WorkOrder = {
|
||||
id: item.id,
|
||||
orderNo: item.itemCode,
|
||||
productName: item.itemName,
|
||||
processCode: item.processType,
|
||||
processName: PROCESS_TAB_LABELS[item.processType],
|
||||
client: '-',
|
||||
projectName: '-',
|
||||
assignees: [],
|
||||
quantity: item.quantity,
|
||||
dueDate: '',
|
||||
priority: 5,
|
||||
status: 'waiting',
|
||||
isUrgent: false,
|
||||
isDelayed: false,
|
||||
createdAt: '',
|
||||
};
|
||||
setSelectedOrder(syntheticOrder);
|
||||
setIsInspectionInputModalOpen(true);
|
||||
}
|
||||
}, [workItems]);
|
||||
|
||||
// 현재 공정에 맞는 중간검사 타입 결정
|
||||
const getInspectionProcessType = useCallback((): InspectionProcessType => {
|
||||
if (activeTab === 'bending' && bendingSubMode === 'wip') {
|
||||
return 'bending_wip';
|
||||
}
|
||||
if (activeTab === 'slat' && slatSubMode === 'jointbar') {
|
||||
return 'slat_jointbar';
|
||||
}
|
||||
return activeTab as InspectionProcessType;
|
||||
}, [activeTab, bendingSubMode, slatSubMode]);
|
||||
|
||||
// 하단 버튼 핸들러
|
||||
const handleWorkLog = useCallback(() => {
|
||||
const target = getTargetOrder();
|
||||
@@ -592,6 +673,18 @@ export default function WorkerScreen() {
|
||||
}
|
||||
}, [getTargetOrder]);
|
||||
|
||||
// 중간검사 완료 핸들러
|
||||
const handleInspectionComplete = useCallback((data: InspectionData) => {
|
||||
if (selectedOrder) {
|
||||
setInspectionDataMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(selectedOrder.id, data);
|
||||
return next;
|
||||
});
|
||||
toast.success('중간검사가 완료되었습니다.');
|
||||
}
|
||||
}, [selectedOrder]);
|
||||
|
||||
// ===== 재공품 감지 =====
|
||||
const hasWipItems = useMemo(() => {
|
||||
return activeTab === 'bending' && workItems.some(item => item.isWip);
|
||||
@@ -710,32 +803,34 @@ export default function WorkerScreen() {
|
||||
|
||||
{/* 절곡 탭: 절곡/재공품 전환 토글 */}
|
||||
{tab === 'bending' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 p-1 bg-gray-100 rounded-lg w-fit">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBendingSubMode('normal')}
|
||||
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||
bendingSubMode === 'normal'
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
절곡
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBendingSubMode('wip')}
|
||||
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||
bendingSubMode === 'wip'
|
||||
? 'bg-orange-500 text-white shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
재공품
|
||||
</button>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 p-1 bg-gray-100 rounded-lg w-fit">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBendingSubMode('normal')}
|
||||
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||
bendingSubMode === 'normal'
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
절곡
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBendingSubMode('wip')}
|
||||
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||
bendingSubMode === 'wip'
|
||||
? 'bg-orange-500 text-white shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
재공품
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">* 샘플 데이터 전환용</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">* 샘플 데이터 전환용</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -815,6 +910,7 @@ export default function WorkerScreen() {
|
||||
onStepClick={handleStepClick}
|
||||
onEditMaterial={handleEditMaterial}
|
||||
onDeleteMaterial={handleDeleteMaterial}
|
||||
onInspectionClick={handleInspectionClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -830,12 +926,12 @@ export default function WorkerScreen() {
|
||||
<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-[48px] ${sidebarCollapsed ? 'md:left-[156px]' : 'md:left-[316px]'}`}>
|
||||
<div className="flex gap-3">
|
||||
{hasWipItems ? (
|
||||
// 재공품: 통합 버튼 1개
|
||||
// 재공품: 버튼 1개
|
||||
<Button
|
||||
onClick={handleWipInspection}
|
||||
onClick={handleInspection}
|
||||
className="flex-1 py-6 text-base font-medium bg-gray-900 hover:bg-gray-800"
|
||||
>
|
||||
작업일지 및 중간검사하기
|
||||
작업일지 및 검사성적서 보기
|
||||
</Button>
|
||||
) : (
|
||||
// 일반/조인트바: 버튼 2개
|
||||
@@ -851,7 +947,7 @@ export default function WorkerScreen() {
|
||||
onClick={handleInspection}
|
||||
className="flex-1 py-6 text-base font-medium bg-gray-900 hover:bg-gray-800"
|
||||
>
|
||||
중간검사하기
|
||||
검사성적서 보기
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -888,8 +984,12 @@ export default function WorkerScreen() {
|
||||
onOpenChange={setIsInspectionModalOpen}
|
||||
workOrderId={selectedOrder?.id || null}
|
||||
processType={hasWipItems ? 'bending_wip' : activeTab}
|
||||
readOnly={false}
|
||||
readOnly={true}
|
||||
isJointBar={hasJointBarItems}
|
||||
inspectionData={selectedOrder ? inspectionDataMap.get(selectedOrder.id) : undefined}
|
||||
workItems={workItems}
|
||||
inspectionDataMap={inspectionDataMap}
|
||||
inspectionSetting={currentInspectionSetting}
|
||||
/>
|
||||
|
||||
<IssueReportModal
|
||||
@@ -904,6 +1004,15 @@ export default function WorkerScreen() {
|
||||
lotNo={completionLotNo}
|
||||
onConfirm={handleCompletionResultConfirm}
|
||||
/>
|
||||
|
||||
<InspectionInputModal
|
||||
open={isInspectionInputModalOpen}
|
||||
onOpenChange={setIsInspectionInputModalOpen}
|
||||
processType={getInspectionProcessType()}
|
||||
productName={selectedOrder?.productName || workItems[0]?.itemName || ''}
|
||||
specification={workItems[0]?.slatJointBarInfo?.specification || workItems[0]?.wipInfo?.specification || ''}
|
||||
onComplete={handleInspectionComplete}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ export const PROCESS_TYPE_OPTIONS: { value: ProcessType; label: string }[] = [
|
||||
export type StepConnectionType = '팝업' | '없음';
|
||||
|
||||
// 완료 유형
|
||||
export type StepCompletionType = '선택 완료 시 완료' | '클릭 시 완료';
|
||||
export type StepCompletionType = '선택 완료 시 완료' | '클릭 시 완료' | '검사완료 시 완료';
|
||||
|
||||
// 공정 단계 엔티티
|
||||
export interface ProcessStep {
|
||||
@@ -155,6 +155,8 @@ export interface ProcessStep {
|
||||
connectionTarget?: string; // 도달 (입고완료 자재 목록 등)
|
||||
// 완료 정보
|
||||
completionType: StepCompletionType;
|
||||
// 검사 설정 (검사여부가 true일 때)
|
||||
inspectionSetting?: InspectionSetting;
|
||||
}
|
||||
|
||||
// 연결 유형 옵션
|
||||
@@ -167,6 +169,7 @@ export const STEP_CONNECTION_TYPE_OPTIONS: { value: StepConnectionType; label: s
|
||||
export const STEP_COMPLETION_TYPE_OPTIONS: { value: StepCompletionType; label: string }[] = [
|
||||
{ value: '선택 완료 시 완료', label: '선택 완료 시 완료' },
|
||||
{ value: '클릭 시 완료', label: '클릭 시 완료' },
|
||||
{ value: '검사완료 시 완료', label: '검사완료 시 완료' },
|
||||
];
|
||||
|
||||
// 연결 도달 옵션
|
||||
@@ -175,4 +178,99 @@ export const STEP_CONNECTION_TARGET_OPTIONS: { value: string; label: string }[]
|
||||
{ value: '출고 요청 목록', label: '출고 요청 목록' },
|
||||
{ value: '검사 대기 목록', label: '검사 대기 목록' },
|
||||
{ value: '작업 지시 목록', label: '작업 지시 목록' },
|
||||
];
|
||||
{ value: '중간검사', label: '중간검사' },
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// 중간검사 설정 타입 정의
|
||||
// ============================================================================
|
||||
|
||||
// 포인트 타입
|
||||
export type InspectionPointType = '포인트 없음' | '포인트 1' | '포인트 2' | '포인트 3' | '포인트 4' | '포인트 5' | '포인트 6' | '포인트 7' | '포인트 8' | '포인트 9' | '포인트 10';
|
||||
|
||||
// 방법 타입
|
||||
export type InspectionMethodType = '숫자' | '양자택일';
|
||||
|
||||
// 치수 검사 항목
|
||||
export interface DimensionInspectionItem {
|
||||
enabled: boolean;
|
||||
point: InspectionPointType;
|
||||
method: InspectionMethodType;
|
||||
}
|
||||
|
||||
// 겉모양 검사 항목
|
||||
export interface AppearanceInspectionItem {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// 중간검사 설정 데이터
|
||||
export interface InspectionSetting {
|
||||
// 기본 정보
|
||||
standardName: string; // 기준서명
|
||||
schematicImage?: string; // 기준서 도해 이미지 URL
|
||||
inspectionStandardImage?: string; // 검사기준 이미지 URL
|
||||
|
||||
// 겉모양 검사 항목
|
||||
appearance: {
|
||||
bendingStatus: AppearanceInspectionItem; // 절곡상태
|
||||
processingStatus: AppearanceInspectionItem; // 가공상태
|
||||
sewingStatus: AppearanceInspectionItem; // 재봉상태
|
||||
assemblyStatus: AppearanceInspectionItem; // 조립상태
|
||||
};
|
||||
|
||||
// 치수 검사 항목
|
||||
dimension: {
|
||||
length: DimensionInspectionItem; // 길이
|
||||
width: DimensionInspectionItem; // 너비
|
||||
height1: DimensionInspectionItem; // 1 높이
|
||||
height2: DimensionInspectionItem; // 2 높이
|
||||
gap: DimensionInspectionItem; // 간격
|
||||
};
|
||||
|
||||
// 기타 항목
|
||||
judgment: boolean; // 판정
|
||||
nonConformingContent: boolean; // 부적합 내용
|
||||
}
|
||||
|
||||
// 포인트 옵션
|
||||
export const INSPECTION_POINT_OPTIONS: { value: InspectionPointType; label: string }[] = [
|
||||
{ value: '포인트 없음', label: '포인트 없음' },
|
||||
{ value: '포인트 1', label: '포인트 1' },
|
||||
{ value: '포인트 2', label: '포인트 2' },
|
||||
{ value: '포인트 3', label: '포인트 3' },
|
||||
{ value: '포인트 4', label: '포인트 4' },
|
||||
{ value: '포인트 5', label: '포인트 5' },
|
||||
{ value: '포인트 6', label: '포인트 6' },
|
||||
{ value: '포인트 7', label: '포인트 7' },
|
||||
{ value: '포인트 8', label: '포인트 8' },
|
||||
{ value: '포인트 9', label: '포인트 9' },
|
||||
{ value: '포인트 10', label: '포인트 10' },
|
||||
];
|
||||
|
||||
// 방법 옵션
|
||||
export const INSPECTION_METHOD_OPTIONS: { value: InspectionMethodType; label: string }[] = [
|
||||
{ value: '숫자', label: '숫자' },
|
||||
{ value: '양자택일', label: '양자택일' },
|
||||
];
|
||||
|
||||
// 기본 검사 설정값
|
||||
export const DEFAULT_INSPECTION_SETTING: InspectionSetting = {
|
||||
standardName: '',
|
||||
schematicImage: undefined,
|
||||
inspectionStandardImage: undefined,
|
||||
appearance: {
|
||||
bendingStatus: { enabled: false },
|
||||
processingStatus: { enabled: false },
|
||||
sewingStatus: { enabled: false },
|
||||
assemblyStatus: { enabled: false },
|
||||
},
|
||||
dimension: {
|
||||
length: { enabled: false, point: '포인트 없음', method: '숫자' },
|
||||
width: { enabled: false, point: '포인트 없음', method: '숫자' },
|
||||
height1: { enabled: false, point: '포인트 없음', method: '양자택일' },
|
||||
height2: { enabled: false, point: '포인트 없음', method: '양자택일' },
|
||||
gap: { enabled: false, point: '포인트 5', method: '숫자' },
|
||||
},
|
||||
judgment: false,
|
||||
nonConformingContent: false,
|
||||
};
|
||||
Reference in New Issue
Block a user