feat(WEB): 공정관리/작업지시/작업자화면 기능 강화 및 템플릿 개선
- 공정관리: ProcessDetail/ProcessForm/ProcessList 개선, StepDetail/StepForm 신규 추가 - 작업지시: WorkOrderDetail/Edit/List UI 개선, 작업지시서 문서 추가 - 작업자화면: WorkerScreen 대폭 개선, MaterialInputModal/WorkLogModal 수정, WorkItemCard 신규 - 영업주문: 주문 상세 페이지 개선 - 입고관리: 상세/actions 수정 - 템플릿: IntegratedDetailTemplate/IntegratedListTemplateV2/UniversalListPage 기능 확장 - UI: confirm-dialog 개선 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,11 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 자재투입 모달
|
||||
* API 연동 완료 (2025-12-26)
|
||||
* 자재투입 모달 (기획서 기반)
|
||||
*
|
||||
* 기획 화면에 맞춘 레이아웃:
|
||||
* - FIFO 순위 설명 (1 최우선, 2 차선, 3+ 대기)
|
||||
* - ① 자재 선택 (BOM 기준) 테이블
|
||||
* - 취소 / 투입 등록 버튼 (전체 너비)
|
||||
* 기획서 변경: BOM 체크박스 → 투입수량 입력 테이블
|
||||
* 컬럼: 로트번호 | 품목명 | 수량 | 단위 | 투입 수량 (input, 숫자만)
|
||||
* 하단: 취소 / 투입
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
@@ -20,8 +18,7 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -54,20 +51,48 @@ export function MaterialInputModal({
|
||||
isCompletionFlow = false,
|
||||
onSaveMaterials,
|
||||
}: MaterialInputModalProps) {
|
||||
const [selectedMaterials, setSelectedMaterials] = useState<Set<string>>(new Set());
|
||||
const [materials, setMaterials] = useState<MaterialForInput[]>([]);
|
||||
const [inputQuantities, setInputQuantities] = useState<Record<string, string>>({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// 목업 자재 데이터 (기획서 기반 10행)
|
||||
const MOCK_MATERIALS: MaterialForInput[] = Array.from({ length: 10 }, (_, i) => ({
|
||||
id: 100 + i,
|
||||
materialCode: '123123',
|
||||
materialName: '품목명',
|
||||
unit: 'm',
|
||||
currentStock: 500,
|
||||
fifoRank: i + 1,
|
||||
}));
|
||||
|
||||
// API로 자재 목록 로드
|
||||
const loadMaterials = useCallback(async () => {
|
||||
if (!order) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// 목업 아이템인 경우 목업 자재 데이터 사용
|
||||
if (order.id.startsWith('mock-')) {
|
||||
setMaterials(MOCK_MATERIALS);
|
||||
const initialQuantities: Record<string, string> = {};
|
||||
MOCK_MATERIALS.forEach((m) => {
|
||||
initialQuantities[String(m.id)] = '';
|
||||
});
|
||||
setInputQuantities(initialQuantities);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await getMaterialsForWorkOrder(order.id);
|
||||
if (result.success) {
|
||||
setMaterials(result.data);
|
||||
// 초기 투입 수량 비우기
|
||||
const initialQuantities: Record<string, string> = {};
|
||||
result.data.forEach((m) => {
|
||||
initialQuantities[String(m.id)] = '';
|
||||
});
|
||||
setInputQuantities(initialQuantities);
|
||||
} else {
|
||||
toast.error(result.error || '자재 목록 조회에 실패했습니다.');
|
||||
}
|
||||
@@ -87,47 +112,50 @@ export function MaterialInputModal({
|
||||
}
|
||||
}, [open, order, loadMaterials]);
|
||||
|
||||
const handleToggleMaterial = (materialId: string) => {
|
||||
setSelectedMaterials((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(materialId)) {
|
||||
next.delete(materialId);
|
||||
} else {
|
||||
next.add(materialId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
// 투입 수량 변경 핸들러 (숫자만 허용)
|
||||
const handleQuantityChange = (materialId: string, value: string) => {
|
||||
// 숫자만 허용
|
||||
const numericValue = value.replace(/[^0-9]/g, '');
|
||||
setInputQuantities((prev) => ({
|
||||
...prev,
|
||||
[materialId]: numericValue,
|
||||
}));
|
||||
};
|
||||
|
||||
// 투입 등록
|
||||
const handleSubmit = async () => {
|
||||
if (!order) return;
|
||||
|
||||
// 투입 수량이 입력된 항목 필터
|
||||
const materialsWithQuantity = materials.filter((m) => {
|
||||
const qty = inputQuantities[String(m.id)];
|
||||
return qty && parseInt(qty) > 0;
|
||||
});
|
||||
|
||||
if (materialsWithQuantity.length === 0) {
|
||||
toast.error('투입 수량을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// 선택된 자재 ID 배열
|
||||
const materialIds = materials
|
||||
.filter((m) => selectedMaterials.has(String(m.id)))
|
||||
.map((m) => m.id);
|
||||
|
||||
const materialIds = materialsWithQuantity.map((m) => m.id);
|
||||
const result = await registerMaterialInput(order.id, materialIds);
|
||||
|
||||
if (result.success) {
|
||||
toast.success('자재 투입이 등록되었습니다.');
|
||||
|
||||
// onSaveMaterials 콜백 호출 (기존 호환성)
|
||||
// onSaveMaterials 콜백 호출
|
||||
if (onSaveMaterials) {
|
||||
const selectedMaterialList: MaterialInput[] = materials
|
||||
.filter((m) => selectedMaterials.has(String(m.id)))
|
||||
.map((m) => ({
|
||||
id: String(m.id),
|
||||
materialCode: m.materialCode,
|
||||
materialName: m.materialName,
|
||||
unit: m.unit,
|
||||
currentStock: m.currentStock,
|
||||
fifoRank: m.fifoRank,
|
||||
}));
|
||||
onSaveMaterials(order.id, selectedMaterialList);
|
||||
const savedList: MaterialInput[] = materialsWithQuantity.map((m) => ({
|
||||
id: String(m.id),
|
||||
lotNo: '', // API에서 가져올 필드
|
||||
materialName: m.materialName,
|
||||
quantity: m.currentStock,
|
||||
unit: m.unit,
|
||||
inputQuantity: parseInt(inputQuantities[String(m.id)] || '0'),
|
||||
}));
|
||||
onSaveMaterials(order.id, savedList);
|
||||
}
|
||||
|
||||
resetAndClose();
|
||||
@@ -147,13 +175,12 @@ export function MaterialInputModal({
|
||||
}
|
||||
};
|
||||
|
||||
// 취소
|
||||
const handleCancel = () => {
|
||||
resetAndClose();
|
||||
};
|
||||
|
||||
const resetAndClose = () => {
|
||||
setSelectedMaterials(new Set());
|
||||
setInputQuantities({});
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
@@ -164,100 +191,79 @@ export function MaterialInputModal({
|
||||
<DialogContent className="max-w-2xl p-0 gap-0">
|
||||
{/* 헤더 */}
|
||||
<DialogHeader className="p-6 pb-4">
|
||||
<DialogTitle className="text-xl font-semibold">투입자재 등록</DialogTitle>
|
||||
<DialogTitle className="text-xl font-semibold">자재 투입</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6 pb-6 space-y-6">
|
||||
{/* FIFO 순위 설명 */}
|
||||
<div className="flex items-center gap-4 p-4 bg-gray-50 rounded-lg">
|
||||
<span className="text-sm font-medium text-gray-700">FIFO 순위:</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Badge className="bg-gray-900 hover:bg-gray-900 text-white rounded-full w-6 h-6 flex items-center justify-center p-0 text-xs">
|
||||
1
|
||||
</Badge>
|
||||
<span className="text-sm text-gray-600">최우선</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Badge className="bg-gray-900 hover:bg-gray-900 text-white rounded-full w-6 h-6 flex items-center justify-center p-0 text-xs">
|
||||
2
|
||||
</Badge>
|
||||
<span className="text-sm text-gray-600">차선</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Badge className="bg-gray-900 hover:bg-gray-900 text-white rounded-full w-6 h-6 flex items-center justify-center p-0 text-xs">
|
||||
3+
|
||||
</Badge>
|
||||
<span className="text-sm text-gray-600">대기</span>
|
||||
</span>
|
||||
{/* 자재 목록 테이블 */}
|
||||
{isLoading ? (
|
||||
<ContentSkeleton type="table" rows={4} />
|
||||
) : materials.length === 0 ? (
|
||||
<div className="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="text-center">로트번호</TableHead>
|
||||
<TableHead className="text-center">품목명</TableHead>
|
||||
<TableHead className="text-center">수량</TableHead>
|
||||
<TableHead className="text-center">단위</TableHead>
|
||||
<TableHead className="text-center">투입 수량</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-12 text-gray-500">
|
||||
이 공정에 배정된 자재가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 자재 선택 섹션 */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-3">
|
||||
① 자재 선택 (BOM 기준)
|
||||
</h3>
|
||||
|
||||
{isLoading ? (
|
||||
<ContentSkeleton type="table" rows={4} />
|
||||
) : materials.length === 0 ? (
|
||||
<div className="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="text-center">자재코드</TableHead>
|
||||
<TableHead className="text-center">자재명</TableHead>
|
||||
<TableHead className="text-center">단위</TableHead>
|
||||
<TableHead className="text-center">현재고</TableHead>
|
||||
<TableHead className="text-center">선택</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-12 text-gray-500">
|
||||
이 공정에 배정된 자재가 없습니다.
|
||||
) : (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="text-center font-medium">로트번호</TableHead>
|
||||
<TableHead className="text-center font-medium">품목명</TableHead>
|
||||
<TableHead className="text-center font-medium">수량</TableHead>
|
||||
<TableHead className="text-center font-medium">단위</TableHead>
|
||||
<TableHead className="text-center font-medium">투입 수량</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{materials.map((material) => (
|
||||
<TableRow key={material.id}>
|
||||
<TableCell className="text-center text-sm">
|
||||
{material.materialCode}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm">
|
||||
{material.materialName}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm">
|
||||
{material.currentStock.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm">
|
||||
{material.unit}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
placeholder="0"
|
||||
value={inputQuantities[String(material.id)] || ''}
|
||||
onChange={(e) =>
|
||||
handleQuantityChange(String(material.id), e.target.value)
|
||||
}
|
||||
className="w-20 mx-auto text-center h-8 text-sm"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="text-center font-medium">자재코드</TableHead>
|
||||
<TableHead className="text-center font-medium">자재명</TableHead>
|
||||
<TableHead className="text-center font-medium">단위</TableHead>
|
||||
<TableHead className="text-center font-medium">현재고</TableHead>
|
||||
<TableHead className="text-center font-medium">선택</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{materials.map((material) => (
|
||||
<TableRow key={material.id}>
|
||||
<TableCell className="text-center font-medium">
|
||||
{material.materialCode}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{material.materialName}</TableCell>
|
||||
<TableCell className="text-center">{material.unit}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{material.currentStock.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Checkbox
|
||||
checked={selectedMaterials.has(String(material.id))}
|
||||
onCheckedChange={() => handleToggleMaterial(String(material.id))}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex gap-3">
|
||||
@@ -271,8 +277,8 @@ export function MaterialInputModal({
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={selectedMaterials.size === 0 || isSubmitting}
|
||||
className="flex-1 py-6 text-base font-medium bg-gray-400 hover:bg-gray-500 disabled:bg-gray-300"
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 py-6 text-base font-medium bg-gray-900 hover:bg-gray-800"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
@@ -280,7 +286,7 @@ export function MaterialInputModal({
|
||||
등록 중...
|
||||
</>
|
||||
) : (
|
||||
'투입 등록'
|
||||
'투입'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
313
src/components/production/WorkerScreen/WorkItemCard.tsx
Normal file
313
src/components/production/WorkerScreen/WorkItemCard.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 작업 아이템 카드 컴포넌트 (기획서 기반)
|
||||
*
|
||||
* 공통: 번호 + 품목코드(품목명) + 층/부호, 제작사이즈, 진척률바, pills, 자재투입목록(토글)
|
||||
* 스크린: 절단정보 (폭 X 장)
|
||||
* 슬랫: 길이 / 슬랫매수 / 조인트바
|
||||
* 절곡: 도면(IMG) + 공통사항 + 세부부품
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
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,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type {
|
||||
WorkItemData,
|
||||
WorkStepData,
|
||||
MaterialListItem,
|
||||
} from './types';
|
||||
|
||||
interface WorkItemCardProps {
|
||||
item: WorkItemData;
|
||||
onStepClick: (itemId: string, step: WorkStepData) => void;
|
||||
onEditMaterial: (itemId: string, material: MaterialListItem) => void;
|
||||
onDeleteMaterial: (itemId: string, materialId: string) => void;
|
||||
}
|
||||
|
||||
export function WorkItemCard({
|
||||
item,
|
||||
onStepClick,
|
||||
onEditMaterial,
|
||||
onDeleteMaterial,
|
||||
}: WorkItemCardProps) {
|
||||
const [isMaterialListOpen, setIsMaterialListOpen] = useState(false);
|
||||
|
||||
// 진척률 계산
|
||||
const completedSteps = item.steps.filter((s) => s.isCompleted).length;
|
||||
const totalSteps = item.steps.length;
|
||||
const progressPercent = totalSteps > 0 ? (completedSteps / totalSteps) * 100 : 0;
|
||||
|
||||
const handleStepClick = useCallback(
|
||||
(step: WorkStepData) => {
|
||||
onStepClick(item.id, step);
|
||||
},
|
||||
[item.id, onStepClick]
|
||||
);
|
||||
|
||||
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">
|
||||
{item.itemNo}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
{item.itemCode} ({item.itemName})
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">
|
||||
{item.floor} / {item.code}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 제작 사이즈 */}
|
||||
<div className="flex items-center gap-2 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>
|
||||
</div>
|
||||
|
||||
{/* 공정별 추가 정보 */}
|
||||
{item.processType === 'screen' && item.cuttingInfo && (
|
||||
<ScreenCuttingInfo
|
||||
width={item.cuttingInfo.width}
|
||||
sheets={item.cuttingInfo.sheets}
|
||||
/>
|
||||
)}
|
||||
|
||||
{item.processType === 'slat' && item.slatInfo && (
|
||||
<SlatExtraInfo
|
||||
length={item.slatInfo.length}
|
||||
slatCount={item.slatInfo.slatCount}
|
||||
jointBar={item.slatInfo.jointBar}
|
||||
/>
|
||||
)}
|
||||
|
||||
{item.processType === 'bending' && item.bendingInfo && (
|
||||
<BendingExtraInfo info={item.bendingInfo} />
|
||||
)}
|
||||
|
||||
{/* 진척률 프로그래스 바 */}
|
||||
<div className="space-y-1">
|
||||
<Progress value={progressPercent} className="h-2" />
|
||||
<p className="text-xs text-gray-500 text-right">
|
||||
{completedSteps}/{totalSteps} 완료
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 공정 단계 pills */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.steps.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'
|
||||
)}
|
||||
>
|
||||
{step.name}
|
||||
{step.isCompleted && (
|
||||
<span className="ml-1 text-green-400">완료</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 자재 투입 목록 (토글) */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setIsMaterialListOpen(!isMaterialListOpen)}
|
||||
className="flex items-center gap-1 text-sm font-medium text-gray-700 hover:text-gray-900"
|
||||
>
|
||||
{isMaterialListOpen ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
자재 투입 목록
|
||||
</button>
|
||||
|
||||
{isMaterialListOpen && (
|
||||
<div className="mt-2 border rounded-lg overflow-hidden">
|
||||
{(!item.materialInputs || item.materialInputs.length === 0) ? (
|
||||
<div className="py-6 text-center text-sm text-gray-500">
|
||||
투입된 자재가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="text-center text-xs">로트번호</TableHead>
|
||||
<TableHead className="text-center text-xs">품목명</TableHead>
|
||||
<TableHead className="text-center text-xs">수량</TableHead>
|
||||
<TableHead className="text-center text-xs">단위</TableHead>
|
||||
<TableHead className="text-center text-xs">관리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{item.materialInputs.map((mat) => (
|
||||
<TableRow key={mat.id}>
|
||||
<TableCell className="text-center text-xs">{mat.lotNo}</TableCell>
|
||||
<TableCell className="text-center text-xs">{mat.itemName}</TableCell>
|
||||
<TableCell className="text-center text-xs">{mat.quantity.toLocaleString()}</TableCell>
|
||||
<TableCell className="text-center text-xs">{mat.unit}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => onEditMaterial(item.id, mat)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5 text-gray-500" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => onDeleteMaterial(item.id, mat.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 스크린 전용: 절단정보 =====
|
||||
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">
|
||||
폭 {width.toLocaleString()}mm X {sheets}장
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 슬랫 전용: 길이/매수/조인트바 =====
|
||||
function SlatExtraInfo({
|
||||
length,
|
||||
slatCount,
|
||||
jointBar,
|
||||
}: {
|
||||
length: number;
|
||||
slatCount: number;
|
||||
jointBar: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Badge variant="outline" className="text-xs px-2.5 py-1 border-gray-300">
|
||||
길이 {length.toLocaleString()}mm
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs px-2.5 py-1 border-gray-300">
|
||||
슬랫 매수 {slatCount}장
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs px-2.5 py-1 border-gray-300">
|
||||
조인트바 {jointBar}개
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 절곡 전용: 도면 + 공통사항 + 세부부품 =====
|
||||
import type { BendingInfo } from './types';
|
||||
|
||||
function BendingExtraInfo({ info }: { info: BendingInfo }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 도면 + 공통사항 (가로 배치) */}
|
||||
<div className="flex gap-3">
|
||||
{/* 도면 이미지 */}
|
||||
<div className="flex-shrink-0 w-24 h-24 border rounded-lg bg-gray-50 flex items-center justify-center overflow-hidden">
|
||||
{info.drawingUrl ? (
|
||||
<img
|
||||
src={info.drawingUrl}
|
||||
alt="도면"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1 text-gray-400">
|
||||
<ImageIcon className="h-6 w-6" />
|
||||
<span className="text-[10px]">도면</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 공통사항 */}
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<p className="text-xs font-medium text-gray-500">공통사항</p>
|
||||
<div className="space-y-1">
|
||||
<div className="flex gap-2 text-xs">
|
||||
<span className="text-gray-500 w-14">종류</span>
|
||||
<span className="text-gray-900 font-medium">{info.common.kind}</span>
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs">
|
||||
<span className="text-gray-500 w-14">유형</span>
|
||||
<span className="text-gray-900 font-medium">{info.common.type}</span>
|
||||
</div>
|
||||
{info.common.lengthQuantities.map((lq, i) => (
|
||||
<div key={i} className="flex gap-2 text-xs">
|
||||
<span className="text-gray-500 w-14">{i === 0 ? '길이별 수량' : ''}</span>
|
||||
<span className="text-gray-900 font-medium">
|
||||
{lq.length.toLocaleString()}mm X {lq.quantity}개
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 세부부품 */}
|
||||
{info.detailParts.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-1.5">
|
||||
세부부품 ({info.detailParts.length}개)
|
||||
</p>
|
||||
<div className="border rounded-lg divide-y divide-gray-100">
|
||||
{info.detailParts.map((part, i) => (
|
||||
<div key={i} className="p-2.5 space-y-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="font-medium text-gray-900">{part.partName}</span>
|
||||
<span className="text-gray-500">{part.material}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">
|
||||
바아시 정보 {part.barcyInfo}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,31 +4,85 @@
|
||||
* 작업일지 모달
|
||||
*
|
||||
* document-system 통합 버전 (2026-01-22)
|
||||
* 공정별 작업일지 지원 (2026-01-29)
|
||||
* - DocumentViewer 사용
|
||||
* - WorkLogContent로 문서 본문 분리
|
||||
* - 공정 타입에 따라 스크린/슬랫/절곡 작업일지 분기
|
||||
* - processType 미지정 시 기존 WorkLogContent (범용) 사용
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { DocumentViewer } from '@/components/document-system';
|
||||
import { getWorkOrderById } from '../WorkOrders/actions';
|
||||
import type { WorkOrder } from '../WorkOrders/types';
|
||||
import type { WorkOrder, ProcessType } from '../WorkOrders/types';
|
||||
import { WorkLogContent } from './WorkLogContent';
|
||||
import {
|
||||
ScreenWorkLogContent,
|
||||
SlatWorkLogContent,
|
||||
BendingWorkLogContent,
|
||||
} from '../WorkOrders/documents';
|
||||
|
||||
interface WorkLogModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workOrderId: string | null;
|
||||
processType?: ProcessType;
|
||||
}
|
||||
|
||||
export function WorkLogModal({ open, onOpenChange, workOrderId }: WorkLogModalProps) {
|
||||
export function WorkLogModal({ open, onOpenChange, workOrderId, processType }: WorkLogModalProps) {
|
||||
const [order, setOrder] = useState<WorkOrder | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 목업 WorkOrder 생성
|
||||
const createMockOrder = (id: string, pType?: ProcessType): WorkOrder => ({
|
||||
id,
|
||||
workOrderNo: 'KD-WO-260129-01',
|
||||
lotNo: 'KD-SA-260129-01',
|
||||
processId: 1,
|
||||
processName: pType === 'slat' ? '슬랫' : pType === 'bending' ? '절곡' : '스크린',
|
||||
processCode: pType || 'screen',
|
||||
processType: pType || 'screen',
|
||||
status: 'in_progress',
|
||||
client: '(주)경동',
|
||||
projectName: '서울 강남 현장',
|
||||
dueDate: '2026-02-05',
|
||||
assignee: '홍길동',
|
||||
assignees: [{ id: '1', name: '홍길동', isPrimary: true }],
|
||||
orderDate: '2026-01-20',
|
||||
scheduledDate: '2026-01-29',
|
||||
shipmentDate: '2026-02-05',
|
||||
salesOrderDate: '2026-01-15',
|
||||
isAssigned: true,
|
||||
isStarted: true,
|
||||
priority: 3,
|
||||
priorityLabel: '긴급',
|
||||
shutterCount: 12,
|
||||
department: '생산부',
|
||||
items: [
|
||||
{ id: '1', no: 1, status: 'in_progress', productName: '와이어 스크린', floorCode: '1층/FSS-01', specification: '8,260 X 8,350', quantity: 2, unit: 'EA' },
|
||||
{ id: '2', no: 2, status: 'waiting', productName: '메쉬 스크린', floorCode: '2층/FSS-03', specification: '6,400 X 5,200', quantity: 4, unit: 'EA' },
|
||||
{ id: '3', no: 3, status: 'completed', productName: '광폭 와이어', floorCode: '3층/FSS-05', specification: '12,000 X 4,500', quantity: 1, unit: 'EA' },
|
||||
],
|
||||
currentStep: { key: 'cutting', label: '절단', order: 2 },
|
||||
completedSteps: ['material_input'],
|
||||
totalProgress: 25,
|
||||
issues: [],
|
||||
memo: '',
|
||||
createdAt: '2026-01-20T09:00:00',
|
||||
updatedAt: '2026-01-29T14:00:00',
|
||||
});
|
||||
|
||||
// 모달 열릴 때 데이터 fetch
|
||||
useEffect(() => {
|
||||
if (open && workOrderId) {
|
||||
// 목업 ID인 경우 API 호출 생략
|
||||
if (workOrderId.startsWith('mock-')) {
|
||||
setOrder(createMockOrder(workOrderId, processType));
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
@@ -51,13 +105,32 @@ export function WorkLogModal({ open, onOpenChange, workOrderId }: WorkLogModalPr
|
||||
setOrder(null);
|
||||
setError(null);
|
||||
}
|
||||
}, [open, workOrderId]);
|
||||
}, [open, workOrderId, processType]);
|
||||
|
||||
if (!workOrderId) return null;
|
||||
|
||||
// 로딩/에러 상태는 DocumentViewer 내부에서 처리
|
||||
const subtitle = order ? `${order.processName} 생산부서` : undefined;
|
||||
|
||||
// 공정 타입에 따라 콘텐츠 분기
|
||||
const renderContent = () => {
|
||||
if (!order) return null;
|
||||
|
||||
// processType prop 또는 order의 processType 사용
|
||||
const type = processType || order.processType;
|
||||
|
||||
switch (type) {
|
||||
case 'screen':
|
||||
return <ScreenWorkLogContent data={order} />;
|
||||
case 'slat':
|
||||
return <SlatWorkLogContent data={order} />;
|
||||
case 'bending':
|
||||
return <BendingWorkLogContent data={order} />;
|
||||
default:
|
||||
return <WorkLogContent data={order} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DocumentViewer
|
||||
title="작업일지"
|
||||
@@ -75,7 +148,7 @@ export function WorkLogModal({ open, onOpenChange, workOrderId }: WorkLogModalPr
|
||||
<p className="text-muted-foreground">{error || '데이터를 불러올 수 없습니다.'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<WorkLogContent data={order} />
|
||||
renderContent()
|
||||
)}
|
||||
</DocumentViewer>
|
||||
);
|
||||
|
||||
@@ -59,11 +59,20 @@ function transformToWorkerScreenFormat(api: WorkOrderApiItem): WorkOrder {
|
||||
? Math.ceil((today.getTime() - due.getTime()) / (1000 * 60 * 60 * 24))
|
||||
: undefined;
|
||||
|
||||
// process_type → processCode/processName 매핑
|
||||
const processTypeMap: Record<string, { code: string; name: string }> = {
|
||||
screen: { code: 'screen', name: '스크린' },
|
||||
slat: { code: 'slat', name: '슬랫' },
|
||||
bending: { code: 'bending', name: '절곡' },
|
||||
};
|
||||
const processInfo = processTypeMap[api.process_type] || { code: api.process_type, name: api.process_type };
|
||||
|
||||
return {
|
||||
id: String(api.id),
|
||||
orderNo: api.work_order_no,
|
||||
productName,
|
||||
process: api.process_type,
|
||||
processCode: processInfo.code,
|
||||
processName: processInfo.name,
|
||||
client: api.sales_order?.client?.name || '-',
|
||||
projectName: api.project_name || '-',
|
||||
assignees: api.assignee ? [api.assignee.name] : [],
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 작업자 화면 메인 컴포넌트
|
||||
* API 연동 완료 (2025-12-26)
|
||||
* 작업자 화면 메인 컴포넌트 (기획서 기반 전면 개편)
|
||||
*
|
||||
* 기능:
|
||||
* - 상단 통계 카드 4개 (할당/작업중/완료/긴급)
|
||||
* - 내 작업 목록 카드 리스트
|
||||
* - 각 작업 카드별 버튼 (전량완료/공정상세/자재투입/작업일지/이슈보고)
|
||||
* 구조:
|
||||
* - 상단: 페이지 제목
|
||||
* - 탭: 스크린/슬랫/절곡 (디폴트: 스크린)
|
||||
* - 상태 카드 4개 (할일/작업중/완료/긴급)
|
||||
* - 수주 정보 섹션 (읽기 전용)
|
||||
* - 작업 정보 섹션 (생산담당자 셀렉트 + 생산일자)
|
||||
* - 작업 목록 (WorkItemCard 나열)
|
||||
* - 하단 고정 버튼 (작업일지보기 / 중간검사하기)
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { ClipboardList, PlayCircle, CheckCircle2, AlertTriangle } from 'lucide-react';
|
||||
import { ContentSkeleton } from '@/components/ui/skeleton';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -21,24 +26,178 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { toast } from 'sonner';
|
||||
import { getMyWorkOrders, completeWorkOrder } from './actions';
|
||||
import type { WorkOrder } from '../ProductionDashboard/types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import type { WorkerStats, CompletionToastInfo, MaterialInput } from './types';
|
||||
import { WorkCard } from './WorkCard';
|
||||
import type {
|
||||
WorkerStats,
|
||||
CompletionToastInfo,
|
||||
MaterialInput,
|
||||
ProcessTab,
|
||||
WorkItemData,
|
||||
WorkStepData,
|
||||
MaterialListItem,
|
||||
} from './types';
|
||||
import { PROCESS_TAB_LABELS } from './types';
|
||||
import { WorkItemCard } from './WorkItemCard';
|
||||
import { CompletionConfirmDialog } from './CompletionConfirmDialog';
|
||||
import { CompletionToast } from './CompletionToast';
|
||||
import { MaterialInputModal } from './MaterialInputModal';
|
||||
import { WorkLogModal } from './WorkLogModal';
|
||||
import { IssueReportModal } from './IssueReportModal';
|
||||
import { WorkCompletionResultDialog } from './WorkCompletionResultDialog';
|
||||
import { InspectionReportModal } from '../WorkOrders/documents';
|
||||
|
||||
// ===== 목업 데이터 =====
|
||||
const MOCK_ITEMS: Record<ProcessTab, WorkItemData[]> = {
|
||||
screen: [
|
||||
{
|
||||
id: 'mock-s1', itemNo: 1, itemCode: 'KWWS03', itemName: '와이어', floor: '1층', code: 'FSS-01',
|
||||
width: 8260, height: 8350, quantity: 2, processType: 'screen',
|
||||
cuttingInfo: { width: 1210, sheets: 8 },
|
||||
steps: [
|
||||
{ id: 's1-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
|
||||
{ id: 's1-2', name: '절단', isMaterialInput: false, isCompleted: true },
|
||||
{ id: 's1-3', name: '미싱', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 's1-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
],
|
||||
materialInputs: [
|
||||
{ id: 'm1', lotNo: 'LOT-2026-001', itemName: '스크린 원단 A', quantity: 500, unit: 'm' },
|
||||
{ id: 'm2', lotNo: 'LOT-2026-002', itemName: '와이어 B', quantity: 120, unit: 'EA' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mock-s2', itemNo: 2, itemCode: 'KWWS05', itemName: '메쉬', floor: '2층', code: 'FSS-03',
|
||||
width: 6400, height: 5200, quantity: 4, processType: 'screen',
|
||||
cuttingInfo: { width: 1600, sheets: 4 },
|
||||
steps: [
|
||||
{ id: 's2-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
|
||||
{ id: 's2-2', name: '절단', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 's2-3', name: '미싱', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 's2-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mock-s3', itemNo: 3, itemCode: 'KWWS08', itemName: '와이어(광폭)', floor: '3층', code: 'FSS-05',
|
||||
width: 12000, height: 4500, quantity: 1, processType: 'screen',
|
||||
cuttingInfo: { width: 2400, sheets: 5 },
|
||||
steps: [
|
||||
{ id: 's3-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
|
||||
{ id: 's3-2', name: '절단', isMaterialInput: false, isCompleted: true },
|
||||
{ id: 's3-3', name: '미싱', isMaterialInput: false, isCompleted: true },
|
||||
{ id: 's3-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
],
|
||||
materialInputs: [
|
||||
{ id: 'm3', lotNo: 'LOT-2026-005', itemName: '광폭 원단', quantity: 300, unit: 'm' },
|
||||
],
|
||||
},
|
||||
],
|
||||
slat: [
|
||||
{
|
||||
id: 'mock-l1', itemNo: 1, itemCode: 'KQTS01', itemName: '슬랫코일', floor: '1층', code: 'FSS-01',
|
||||
width: 8260, height: 8350, quantity: 2, processType: 'slat',
|
||||
slatInfo: { length: 3910, slatCount: 40, jointBar: 4 },
|
||||
steps: [
|
||||
{ id: 'l1-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
|
||||
{ id: 'l1-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 'l1-3', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
],
|
||||
materialInputs: [
|
||||
{ id: 'm4', lotNo: 'LOT-2026-010', itemName: '슬랫 코일 A', quantity: 200, unit: 'kg' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mock-l2', itemNo: 2, itemCode: 'KQTS03', itemName: '슬랫코일(광폭)', floor: '2층', code: 'FSS-02',
|
||||
width: 10500, height: 6200, quantity: 3, processType: 'slat',
|
||||
slatInfo: { length: 5200, slatCount: 55, jointBar: 6 },
|
||||
steps: [
|
||||
{ id: 'l2-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
|
||||
{ id: 'l2-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 'l2-3', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
bending: [
|
||||
{
|
||||
id: 'mock-b1', itemNo: 1, itemCode: 'KWWS03', itemName: '가이드레일', floor: '1층', code: 'FSS-01',
|
||||
width: 0, height: 0, quantity: 6, processType: 'bending',
|
||||
bendingInfo: {
|
||||
common: {
|
||||
kind: '벽면형 120X70', type: '벽면형',
|
||||
lengthQuantities: [{ length: 4000, quantity: 6 }, { length: 3000, quantity: 6 }],
|
||||
},
|
||||
detailParts: [
|
||||
{ partName: '엘바', material: 'EGI 1.6T', barcyInfo: '16 I 75' },
|
||||
{ partName: '하장바', material: 'EGI 1.6T', barcyInfo: '16|75|16|75|16(A각)' },
|
||||
],
|
||||
},
|
||||
steps: [
|
||||
{ id: 'b1-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
|
||||
{ id: 'b1-2', name: '절단', isMaterialInput: false, isCompleted: true },
|
||||
{ id: 'b1-3', name: '절곡', isMaterialInput: false, isCompleted: false },
|
||||
{ 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 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// 하드코딩된 공정별 단계 폴백
|
||||
const PROCESS_STEPS: Record<ProcessTab, { name: string; isMaterialInput: boolean }[]> = {
|
||||
screen: [
|
||||
{ name: '자재투입', isMaterialInput: true },
|
||||
{ name: '절단', isMaterialInput: false },
|
||||
{ name: '미싱', isMaterialInput: false },
|
||||
{ name: '포장완료', isMaterialInput: false },
|
||||
],
|
||||
slat: [
|
||||
{ name: '자재투입', isMaterialInput: true },
|
||||
{ name: '포밍/절단', isMaterialInput: false },
|
||||
{ name: '포장완료', isMaterialInput: false },
|
||||
],
|
||||
bending: [
|
||||
{ name: '자재투입', isMaterialInput: true },
|
||||
{ name: '절단', isMaterialInput: false },
|
||||
{ name: '절곡', isMaterialInput: false },
|
||||
{ name: '포장완료', isMaterialInput: false },
|
||||
],
|
||||
};
|
||||
|
||||
export default function WorkerScreen() {
|
||||
// ===== 상태 관리 =====
|
||||
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<ProcessTab>('screen');
|
||||
|
||||
// 작업 정보
|
||||
const [productionManagerId, setProductionManagerId] = useState('');
|
||||
const [productionDate, setProductionDate] = useState('');
|
||||
|
||||
// 공정별 step 완료 상태: { [itemId-stepName]: boolean }
|
||||
const [stepCompletionMap, setStepCompletionMap] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
@@ -68,6 +227,7 @@ export default function WorkerScreen() {
|
||||
const [isCompletionDialogOpen, setIsCompletionDialogOpen] = useState(false);
|
||||
const [isMaterialModalOpen, setIsMaterialModalOpen] = useState(false);
|
||||
const [isWorkLogModalOpen, setIsWorkLogModalOpen] = useState(false);
|
||||
const [isInspectionModalOpen, setIsInspectionModalOpen] = useState(false);
|
||||
const [isIssueReportModalOpen, setIsIssueReportModalOpen] = useState(false);
|
||||
|
||||
// 전량완료 흐름 상태
|
||||
@@ -83,72 +243,154 @@ export default function WorkerScreen() {
|
||||
// 완료 토스트 상태
|
||||
const [toastInfo, setToastInfo] = useState<CompletionToastInfo | null>(null);
|
||||
|
||||
// 정렬 상태
|
||||
const [sortBy, setSortBy] = useState<'dueDate' | 'latest'>('dueDate');
|
||||
|
||||
// ===== 통계 계산 =====
|
||||
const stats: WorkerStats = useMemo(() => {
|
||||
return {
|
||||
assigned: workOrders.length,
|
||||
inProgress: workOrders.filter((o) => o.status === 'inProgress').length,
|
||||
completed: 0, // 완료된 것은 목록에서 제외되므로 0
|
||||
urgent: workOrders.filter((o) => o.isUrgent).length,
|
||||
};
|
||||
}, [workOrders]);
|
||||
|
||||
// ===== 정렬된 작업 목록 =====
|
||||
const sortedWorkOrders = useMemo(() => {
|
||||
return [...workOrders].sort((a, b) => {
|
||||
if (sortBy === 'dueDate') {
|
||||
// 납기일순 (가까운 날짜 먼저)
|
||||
return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime();
|
||||
} else {
|
||||
// 최신등록순 (최근 ID가 더 큼 = 최근 등록)
|
||||
return b.id.localeCompare(a.id);
|
||||
// ===== 탭별 필터링된 작업 =====
|
||||
const filteredWorkOrders = useMemo(() => {
|
||||
// process_type 기반 필터링
|
||||
return workOrders.filter((order) => {
|
||||
// WorkOrder의 processCode/processName으로 매칭
|
||||
const processName = (order.processName || '').toLowerCase();
|
||||
switch (activeTab) {
|
||||
case 'screen':
|
||||
return processName.includes('스크린') || processName === 'screen';
|
||||
case 'slat':
|
||||
return processName.includes('슬랫') || processName === 'slat';
|
||||
case 'bending':
|
||||
return processName.includes('절곡') || processName === 'bending';
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}, [workOrders, sortBy]);
|
||||
}, [workOrders, activeTab]);
|
||||
|
||||
// ===== 통계 계산 (탭별) =====
|
||||
const stats: WorkerStats = useMemo(() => {
|
||||
return {
|
||||
assigned: filteredWorkOrders.length,
|
||||
inProgress: filteredWorkOrders.filter((o) => o.status === 'inProgress').length,
|
||||
completed: 0,
|
||||
urgent: filteredWorkOrders.filter((o) => o.isUrgent).length,
|
||||
};
|
||||
}, [filteredWorkOrders]);
|
||||
|
||||
// ===== WorkOrder → WorkItemData 변환 + 목업 =====
|
||||
const workItems: WorkItemData[] = useMemo(() => {
|
||||
const apiItems: WorkItemData[] = filteredWorkOrders.map((order, index) => {
|
||||
const stepsTemplate = PROCESS_STEPS[activeTab];
|
||||
const steps: WorkStepData[] = stepsTemplate.map((st, si) => {
|
||||
const stepKey = `${order.id}-${st.name}`;
|
||||
return {
|
||||
id: `${order.id}-step-${si}`,
|
||||
name: st.name,
|
||||
isMaterialInput: st.isMaterialInput,
|
||||
isCompleted: stepCompletionMap[stepKey] || false,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: order.id,
|
||||
itemNo: index + 1,
|
||||
itemCode: order.orderNo || '-',
|
||||
itemName: order.productName || '-',
|
||||
floor: '-',
|
||||
code: '-',
|
||||
width: 0,
|
||||
height: 0,
|
||||
quantity: order.quantity || 0,
|
||||
processType: activeTab,
|
||||
steps,
|
||||
materialInputs: [],
|
||||
};
|
||||
});
|
||||
|
||||
// 목업 데이터 합치기 (API 데이터 뒤에 번호 이어서)
|
||||
const mockItems = MOCK_ITEMS[activeTab].map((item, i) => ({
|
||||
...item,
|
||||
itemNo: apiItems.length + i + 1,
|
||||
}));
|
||||
|
||||
return [...apiItems, ...mockItems];
|
||||
}, [filteredWorkOrders, activeTab, stepCompletionMap]);
|
||||
|
||||
// ===== 수주 정보 (첫 번째 작업 기반 표시) =====
|
||||
const orderInfo = useMemo(() => {
|
||||
const first = filteredWorkOrders[0];
|
||||
if (!first) return null;
|
||||
return {
|
||||
orderDate: first.createdAt ? new Date(first.createdAt).toLocaleDateString('ko-KR') : '-',
|
||||
lotNo: '-',
|
||||
siteName: first.projectName || '-',
|
||||
client: first.client || '-',
|
||||
salesManager: first.assignees?.[0] || '-',
|
||||
managerPhone: '-',
|
||||
shippingDate: first.dueDate ? new Date(first.dueDate).toLocaleDateString('ko-KR') : '-',
|
||||
};
|
||||
}, [filteredWorkOrders]);
|
||||
|
||||
// ===== 핸들러 =====
|
||||
|
||||
// 전량완료 버튼 클릭
|
||||
const handleComplete = useCallback(
|
||||
(order: WorkOrder) => {
|
||||
setSelectedOrder(order);
|
||||
|
||||
// 이미 투입된 자재가 있으면 바로 완료 결과 팝업
|
||||
const savedMaterials = inputMaterialsMap.get(order.id);
|
||||
if (savedMaterials && savedMaterials.length > 0) {
|
||||
// LOT 번호 생성
|
||||
const lotNo = `KD-SA-${new Date().toISOString().slice(2, 10).replace(/-/g, '')}-01`;
|
||||
setCompletionLotNo(lotNo);
|
||||
setIsCompletionResultOpen(true);
|
||||
// pill 클릭 핸들러
|
||||
const handleStepClick = useCallback(
|
||||
(itemId: string, step: WorkStepData) => {
|
||||
if (step.isMaterialInput) {
|
||||
// 자재투입 → 자재 투입 모달 열기
|
||||
const order = workOrders.find((o) => o.id === itemId);
|
||||
if (order) {
|
||||
setSelectedOrder(order);
|
||||
setIsMaterialModalOpen(true);
|
||||
} else {
|
||||
// 목업 아이템인 경우 합성 WorkOrder 생성
|
||||
const mockItem = workItems.find((item) => item.id === itemId);
|
||||
if (mockItem) {
|
||||
const syntheticOrder: WorkOrder = {
|
||||
id: mockItem.id,
|
||||
orderNo: mockItem.itemCode,
|
||||
productName: mockItem.itemName,
|
||||
processCode: mockItem.processType,
|
||||
processName: PROCESS_TAB_LABELS[mockItem.processType],
|
||||
client: '-',
|
||||
projectName: '-',
|
||||
assignees: [],
|
||||
quantity: mockItem.quantity,
|
||||
dueDate: '',
|
||||
priority: 5,
|
||||
status: 'waiting',
|
||||
isUrgent: false,
|
||||
isDelayed: false,
|
||||
createdAt: '',
|
||||
};
|
||||
setSelectedOrder(syntheticOrder);
|
||||
setIsMaterialModalOpen(true);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 자재 투입이 필요합니다 팝업
|
||||
setIsCompletionDialogOpen(true);
|
||||
// 기타 → 완료/미완료 토글
|
||||
const stepKey = `${itemId}-${step.name}`;
|
||||
setStepCompletionMap((prev) => ({
|
||||
...prev,
|
||||
[stepKey]: !prev[stepKey],
|
||||
}));
|
||||
}
|
||||
},
|
||||
[inputMaterialsMap]
|
||||
[workOrders, workItems]
|
||||
);
|
||||
|
||||
// "자재 투입이 필요합니다" 팝업에서 확인 클릭 → MaterialInputModal 열기
|
||||
const handleCompletionConfirm = useCallback(() => {
|
||||
setIsCompletionFlow(true);
|
||||
setIsMaterialModalOpen(true);
|
||||
}, []);
|
||||
// 자재 수정 핸들러
|
||||
const handleEditMaterial = useCallback(
|
||||
(itemId: string, material: MaterialListItem) => {
|
||||
console.log('[WorkerScreen] editMaterial:', itemId, material);
|
||||
// 추후 구현
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// MaterialInputModal에서 투입 등록/건너뛰기 후 → 작업 완료 결과 팝업 표시
|
||||
const handleWorkCompletion = useCallback(() => {
|
||||
if (!selectedOrder) return;
|
||||
|
||||
// LOT 번호 생성
|
||||
const lotNo = `KD-SA-${new Date().toISOString().slice(2, 10).replace(/-/g, '')}-01`;
|
||||
setCompletionLotNo(lotNo);
|
||||
|
||||
// 완료 결과 팝업 표시
|
||||
setIsCompletionResultOpen(true);
|
||||
setIsCompletionFlow(false);
|
||||
}, [selectedOrder]);
|
||||
// 자재 삭제 핸들러
|
||||
const handleDeleteMaterial = useCallback(
|
||||
(itemId: string, materialId: string) => {
|
||||
console.log('[WorkerScreen] deleteMaterial:', itemId, materialId);
|
||||
// 추후 구현
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 자재 저장 핸들러
|
||||
const handleSaveMaterials = useCallback((orderId: string, materials: MaterialInput[]) => {
|
||||
@@ -157,14 +399,35 @@ export default function WorkerScreen() {
|
||||
next.set(orderId, materials);
|
||||
return next;
|
||||
});
|
||||
|
||||
// 자재투입 step 완료로 마킹
|
||||
const stepKey = `${orderId}-자재투입`;
|
||||
setStepCompletionMap((prev) => ({
|
||||
...prev,
|
||||
[stepKey]: true,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 완료 결과 팝업에서 확인 → API 완료 처리 후 목록에서 제거
|
||||
// 완료 확인 → MaterialInputModal 열기
|
||||
const handleCompletionConfirm = useCallback(() => {
|
||||
setIsCompletionFlow(true);
|
||||
setIsMaterialModalOpen(true);
|
||||
}, []);
|
||||
|
||||
// MaterialInputModal 완료 후 → 작업 완료 결과 팝업
|
||||
const handleWorkCompletion = useCallback(() => {
|
||||
if (!selectedOrder) return;
|
||||
const lotNo = `KD-SA-${new Date().toISOString().slice(2, 10).replace(/-/g, '')}-01`;
|
||||
setCompletionLotNo(lotNo);
|
||||
setIsCompletionResultOpen(true);
|
||||
setIsCompletionFlow(false);
|
||||
}, [selectedOrder]);
|
||||
|
||||
// 완료 결과 팝업 확인 → API 완료 처리
|
||||
const handleCompletionResultConfirm = useCallback(async () => {
|
||||
if (!selectedOrder) return;
|
||||
|
||||
try {
|
||||
// API로 완료 처리
|
||||
const materials = inputMaterialsMap.get(selectedOrder.id);
|
||||
const result = await completeWorkOrder(
|
||||
selectedOrder.id,
|
||||
@@ -177,15 +440,11 @@ export default function WorkerScreen() {
|
||||
|
||||
if (result.success) {
|
||||
toast.success('작업이 완료되었습니다.');
|
||||
|
||||
// 투입된 자재 맵에서도 제거
|
||||
setInputMaterialsMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(selectedOrder.id);
|
||||
return next;
|
||||
});
|
||||
|
||||
// 목록에서 제거
|
||||
setWorkOrders((prev) => prev.filter((o) => o.id !== selectedOrder.id));
|
||||
} else {
|
||||
toast.error(result.error || '작업 완료 처리에 실패했습니다.');
|
||||
@@ -200,26 +459,53 @@ export default function WorkerScreen() {
|
||||
}
|
||||
}, [selectedOrder, inputMaterialsMap]);
|
||||
|
||||
const handleProcessDetail = useCallback((order: WorkOrder) => {
|
||||
setSelectedOrder(order);
|
||||
// 공정상세는 카드 내 토글로 처리 (Phase 4에서 구현)
|
||||
console.log('[공정상세] 토글:', order.orderNo);
|
||||
}, []);
|
||||
// 하단 버튼용 합성 WorkOrder (API 데이터 없을 때 목업 폴백)
|
||||
const getTargetOrder = useCallback((): WorkOrder | null => {
|
||||
const apiTarget = filteredWorkOrders[0];
|
||||
if (apiTarget) return apiTarget;
|
||||
|
||||
const handleMaterialInput = useCallback((order: WorkOrder) => {
|
||||
setSelectedOrder(order);
|
||||
setIsMaterialModalOpen(true);
|
||||
}, []);
|
||||
// 목업 아이템으로 폴백
|
||||
const mockItem = workItems[0];
|
||||
if (!mockItem) return null;
|
||||
return {
|
||||
id: mockItem.id,
|
||||
orderNo: mockItem.itemCode,
|
||||
productName: mockItem.itemName,
|
||||
processCode: mockItem.processType,
|
||||
processName: PROCESS_TAB_LABELS[mockItem.processType],
|
||||
client: '-',
|
||||
projectName: '-',
|
||||
assignees: [],
|
||||
quantity: mockItem.quantity,
|
||||
dueDate: '',
|
||||
priority: 5,
|
||||
status: 'waiting',
|
||||
isUrgent: false,
|
||||
isDelayed: false,
|
||||
createdAt: '',
|
||||
};
|
||||
}, [filteredWorkOrders, workItems]);
|
||||
|
||||
const handleWorkLog = useCallback((order: WorkOrder) => {
|
||||
setSelectedOrder(order);
|
||||
setIsWorkLogModalOpen(true);
|
||||
}, []);
|
||||
// 하단 버튼 핸들러
|
||||
const handleWorkLog = useCallback(() => {
|
||||
const target = getTargetOrder();
|
||||
if (target) {
|
||||
setSelectedOrder(target);
|
||||
setIsWorkLogModalOpen(true);
|
||||
} else {
|
||||
toast.error('표시할 작업이 없습니다.');
|
||||
}
|
||||
}, [getTargetOrder]);
|
||||
|
||||
const handleIssueReport = useCallback((order: WorkOrder) => {
|
||||
setSelectedOrder(order);
|
||||
setIsIssueReportModalOpen(true);
|
||||
}, []);
|
||||
const handleInspection = useCallback(() => {
|
||||
const target = getTargetOrder();
|
||||
if (target) {
|
||||
setSelectedOrder(target);
|
||||
setIsInspectionModalOpen(true);
|
||||
} else {
|
||||
toast.error('표시할 작업이 없습니다.');
|
||||
}
|
||||
}, [getTargetOrder]);
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
@@ -234,75 +520,158 @@ export default function WorkerScreen() {
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">작업자 화면</h1>
|
||||
<p className="text-sm text-muted-foreground">내 작업 목록을 확인하고 관리합니다.</p>
|
||||
<p className="text-sm text-muted-foreground">작업을 관리합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
title="할일"
|
||||
value={stats.assigned}
|
||||
icon={<ClipboardList className="h-4 w-4" />}
|
||||
variant="default"
|
||||
/>
|
||||
<StatCard
|
||||
title="작업중"
|
||||
value={stats.inProgress}
|
||||
icon={<PlayCircle className="h-4 w-4" />}
|
||||
variant="blue"
|
||||
/>
|
||||
<StatCard
|
||||
title="완료"
|
||||
value={stats.completed}
|
||||
icon={<CheckCircle2 className="h-4 w-4" />}
|
||||
variant="green"
|
||||
/>
|
||||
<StatCard
|
||||
title="긴급"
|
||||
value={stats.urgent}
|
||||
icon={<AlertTriangle className="h-4 w-4" />}
|
||||
variant="red"
|
||||
/>
|
||||
</div>
|
||||
{/* 공정별 탭 */}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as ProcessTab)}
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
{(['screen', 'slat', 'bending'] as ProcessTab[]).map((tab) => (
|
||||
<TabsTrigger key={tab} value={tab} className="flex-1">
|
||||
{PROCESS_TAB_LABELS[tab]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{/* 작업 목록 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">내 작업 목록</h2>
|
||||
<Select value={sortBy} onValueChange={(value: 'dueDate' | 'latest') => setSortBy(value)}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="dueDate">납기일순</SelectItem>
|
||||
<SelectItem value="latest">최신등록순</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<ContentSkeleton type="cards" rows={4} />
|
||||
) : sortedWorkOrders.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
배정된 작업이 없습니다.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{sortedWorkOrders.map((order) => (
|
||||
<WorkCard
|
||||
key={order.id}
|
||||
order={order}
|
||||
onComplete={handleComplete}
|
||||
onProcessDetail={handleProcessDetail}
|
||||
onMaterialInput={handleMaterialInput}
|
||||
onWorkLog={handleWorkLog}
|
||||
onIssueReport={handleIssueReport}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 탭 내용은 공통 (탭별 필터링만 다름) */}
|
||||
{(['screen', 'slat', 'bending'] as ProcessTab[]).map((tab) => (
|
||||
<TabsContent key={tab} value={tab}>
|
||||
<div className="space-y-6 mt-4">
|
||||
{/* 상태 카드 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
title="할일"
|
||||
value={stats.assigned}
|
||||
icon={<ClipboardList className="h-4 w-4" />}
|
||||
variant="default"
|
||||
/>
|
||||
<StatCard
|
||||
title="작업중"
|
||||
value={stats.inProgress}
|
||||
icon={<PlayCircle className="h-4 w-4" />}
|
||||
variant="blue"
|
||||
/>
|
||||
<StatCard
|
||||
title="완료"
|
||||
value={stats.completed}
|
||||
icon={<CheckCircle2 className="h-4 w-4" />}
|
||||
variant="green"
|
||||
/>
|
||||
<StatCard
|
||||
title="긴급"
|
||||
value={stats.urgent}
|
||||
icon={<AlertTriangle className="h-4 w-4" />}
|
||||
variant="red"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 수주 정보 섹션 */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">수주 정보</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-3">
|
||||
<InfoField label="수주일" value={orderInfo?.orderDate} />
|
||||
<InfoField label="로트번호" value={orderInfo?.lotNo} />
|
||||
<InfoField label="현장명" value={orderInfo?.siteName} />
|
||||
<InfoField label="수주처" value={orderInfo?.client} />
|
||||
<InfoField label="수주 담당자" value={orderInfo?.salesManager} />
|
||||
<InfoField label="담당자 연락처" value={orderInfo?.managerPhone} />
|
||||
<InfoField label="출고예정일" value={orderInfo?.shippingDate} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 작업 정보 섹션 */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">작업 정보</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm text-gray-600">생산 담당자</Label>
|
||||
<Select
|
||||
value={productionManagerId}
|
||||
onValueChange={setProductionManagerId}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* 담당자 목록 - 현재 작업 담당자들 */}
|
||||
{Array.from(
|
||||
new Set(
|
||||
filteredWorkOrders.flatMap((o) => o.assignees || []).filter(Boolean)
|
||||
)
|
||||
).map((name) => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm text-gray-600">생산일자</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={productionDate}
|
||||
onChange={(e) => setProductionDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 작업 목록 */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">작업 목록</h3>
|
||||
{isLoading ? (
|
||||
<ContentSkeleton type="cards" rows={4} />
|
||||
) : workItems.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
해당 공정에 배정된 작업이 없습니다.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{workItems.map((item) => (
|
||||
<WorkItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onStepClick={handleStepClick}
|
||||
onEditMaterial={handleEditMaterial}
|
||||
onDeleteMaterial={handleDeleteMaterial}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* 하단 고정 버튼 */}
|
||||
<div className="sticky bottom-0 border-t border-gray-200 pt-4 pb-2 z-10">
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleWorkLog}
|
||||
className="flex-1 py-6 text-base font-medium"
|
||||
>
|
||||
작업일지 보기
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleInspection}
|
||||
className="flex-1 py-6 text-base font-medium bg-gray-900 hover:bg-gray-800"
|
||||
>
|
||||
중간검사하기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -328,6 +697,14 @@ export default function WorkerScreen() {
|
||||
open={isWorkLogModalOpen}
|
||||
onOpenChange={setIsWorkLogModalOpen}
|
||||
workOrderId={selectedOrder?.id || null}
|
||||
processType={activeTab}
|
||||
/>
|
||||
|
||||
<InspectionReportModal
|
||||
open={isInspectionModalOpen}
|
||||
onOpenChange={setIsInspectionModalOpen}
|
||||
workOrderId={selectedOrder?.id || null}
|
||||
processType={activeTab}
|
||||
/>
|
||||
|
||||
<IssueReportModal
|
||||
@@ -375,3 +752,17 @@ function StatCard({ title, value, icon, variant }: StatCardProps) {
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface InfoFieldProps {
|
||||
label: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
function InfoField({ label, value }: InfoFieldProps) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">{label}</p>
|
||||
<p className="text-sm font-medium text-gray-900 mt-0.5">{value || '-'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,116 @@
|
||||
// 작업자 화면 타입 정의
|
||||
|
||||
import type { WorkOrder, ProcessType } from '../ProductionDashboard/types';
|
||||
import type { WorkOrder } from '../ProductionDashboard/types';
|
||||
|
||||
// 작업자 작업 아이템 (WorkOrder 확장)
|
||||
// ===== 공정 탭 =====
|
||||
export type ProcessTab = 'screen' | 'slat' | 'bending';
|
||||
|
||||
export const PROCESS_TAB_LABELS: Record<ProcessTab, string> = {
|
||||
screen: '스크린 공정',
|
||||
slat: '슬랫 공정',
|
||||
bending: '절곡 공정',
|
||||
};
|
||||
|
||||
// ===== 수주 정보 =====
|
||||
export interface OrderInfo {
|
||||
orderDate: string; // 수주일
|
||||
lotNo: string; // 로트번호
|
||||
siteName: string; // 현장명
|
||||
client: string; // 수주처
|
||||
salesManager: string; // 수주담당자
|
||||
managerPhone: string; // 담당자연락처
|
||||
shippingDate: string; // 출고예정일
|
||||
}
|
||||
|
||||
// ===== 작업 정보 =====
|
||||
export interface WorkInfo {
|
||||
productionManagerId: string; // 생산 담당자 ID
|
||||
productionDate: string; // 생산일자
|
||||
}
|
||||
|
||||
// ===== 작업 아이템 (카드 1개 단위) =====
|
||||
export interface WorkItemData {
|
||||
id: string;
|
||||
itemNo: number; // 번호 (1, 2, 3...)
|
||||
itemCode: string; // 품목코드 (KWWS03)
|
||||
itemName: string; // 품목명 (와이어)
|
||||
floor: string; // 층 (1층)
|
||||
code: string; // 부호 (FSS-01)
|
||||
width: number; // 폭 (mm)
|
||||
height: number; // 높이 (mm)
|
||||
quantity: number; // 수량
|
||||
processType: ProcessTab; // 공정 타입
|
||||
steps: WorkStepData[]; // 공정 단계들
|
||||
// 스크린 전용
|
||||
cuttingInfo?: CuttingInfo;
|
||||
// 슬랫 전용
|
||||
slatInfo?: SlatInfo;
|
||||
// 절곡 전용
|
||||
bendingInfo?: BendingInfo;
|
||||
// 자재 투입 목록
|
||||
materialInputs?: MaterialListItem[];
|
||||
}
|
||||
|
||||
// ===== 절단 정보 (스크린 전용) =====
|
||||
export interface CuttingInfo {
|
||||
width: number; // 절단 폭 (mm)
|
||||
sheets: number; // 장 수
|
||||
}
|
||||
|
||||
// ===== 슬랫 전용 정보 =====
|
||||
export interface SlatInfo {
|
||||
length: number; // 길이 (mm)
|
||||
slatCount: number; // 슬랫 매수
|
||||
jointBar: number; // 조인트바 개수
|
||||
}
|
||||
|
||||
// ===== 절곡 전용 정보 =====
|
||||
export interface BendingInfo {
|
||||
drawingUrl?: string; // 도면 이미지 URL
|
||||
common: BendingCommonInfo; // 공통사항
|
||||
detailParts: BendingDetailPart[]; // 세부부품
|
||||
}
|
||||
|
||||
export interface BendingCommonInfo {
|
||||
kind: string; // 종류 (벽면형 120X70)
|
||||
type: string; // 유형 (벽면형)
|
||||
lengthQuantities: { length: number; quantity: number }[]; // 길이별 수량
|
||||
}
|
||||
|
||||
export interface BendingDetailPart {
|
||||
partName: string; // 부품명 (엘바, 하장바)
|
||||
material: string; // 재질 (EGI 1.6T)
|
||||
barcyInfo: string; // 바아시 정보
|
||||
}
|
||||
|
||||
// ===== 공정 단계 (pill) =====
|
||||
export interface WorkStepData {
|
||||
id: string;
|
||||
name: string; // 단계명 (자재투입, 절단, 미싱, 포장완료)
|
||||
isMaterialInput: boolean; // 자재투입 단계 여부
|
||||
isCompleted: boolean; // 완료 여부
|
||||
}
|
||||
|
||||
// ===== 자재 투입 목록 항목 =====
|
||||
export interface MaterialListItem {
|
||||
id: string;
|
||||
lotNo: string; // 로트번호
|
||||
itemName: string; // 품목명
|
||||
quantity: number; // 수량
|
||||
unit: string; // 단위
|
||||
}
|
||||
|
||||
// ===== 자재 투입 모달 항목 =====
|
||||
export interface MaterialInput {
|
||||
id: string;
|
||||
lotNo: string; // 로트번호
|
||||
materialName: string; // 품목명
|
||||
quantity: number; // 수량
|
||||
unit: string; // 단위
|
||||
inputQuantity: number; // 투입 수량 (사용자 입력)
|
||||
}
|
||||
|
||||
// ===== 작업자 작업 아이템 (WorkOrder 확장) =====
|
||||
export interface WorkerWorkItem extends WorkOrder {
|
||||
processDetail?: ProcessDetail;
|
||||
}
|
||||
@@ -29,22 +137,12 @@ export interface ProcessStep {
|
||||
// 공정 단계 상세 항목
|
||||
export interface ProcessStepItem {
|
||||
id: string;
|
||||
itemNo: string; // #1, #2
|
||||
location: string; // 1층 1호-A
|
||||
isPriority: boolean; // 선행 생산
|
||||
spec: string; // W2500 × H3000
|
||||
material: string; // 자재: 절곡판
|
||||
lot: string; // LOT-절곡-2025-001
|
||||
}
|
||||
|
||||
// 자재 투입 정보
|
||||
export interface MaterialInput {
|
||||
id: string;
|
||||
materialCode: string;
|
||||
materialName: string;
|
||||
unit: string;
|
||||
currentStock: number;
|
||||
fifoRank: number; // FIFO 순위 (1: 최우선, 2: 차선, 3+: 대기)
|
||||
itemNo: string;
|
||||
location: string;
|
||||
isPriority: boolean;
|
||||
spec: string;
|
||||
material: string;
|
||||
lot: string;
|
||||
}
|
||||
|
||||
// 이슈 유형
|
||||
@@ -71,4 +169,4 @@ export interface CompletionToastInfo {
|
||||
orderNo: string;
|
||||
quantity: number;
|
||||
lotNo: string;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user