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:
유병철
2026-01-29 22:56:01 +09:00
parent 106ce09482
commit 3fc63d0b3e
50 changed files with 5801 additions and 1377 deletions

View File

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

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

View File

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

View File

@@ -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] : [],

View File

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

View File

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