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:
유병철
2026-02-05 21:43:28 +09:00
parent 32d6e3bbbd
commit efcc645e24
21 changed files with 2559 additions and 328 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -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 || '작업지시 조회에 실패했습니다.');
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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