feat: 생산/품질/자재/출고/주문 관리 페이지 구현
- 생산관리: 대시보드, 작업지시, 작업실적, 작업자화면 - 품질관리: 검사관리 (리스트/등록/상세) - 자재관리: 입고관리, 재고현황 - 출고관리: 출하관리 (리스트/등록/상세/수정) - 주문관리: 수주관리, 생산의뢰 - 기존 컴포넌트 개선: CardTransactionInquiry, VendorDetail, QuoteRegistration - IntegratedListTemplateV2 개선 - 공통 컴포넌트 분석 문서 추가 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
348
src/components/production/ProductionDashboard/index.tsx
Normal file
348
src/components/production/ProductionDashboard/index.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 생산 현황판 메인 컴포넌트
|
||||
*
|
||||
* 기능:
|
||||
* - 공장별 탭 필터링 (전체/스크린공장/슬랫공장/절곡공장)
|
||||
* - 통계 카드 6개 (전체작업/작업대기/작업중/작업완료/긴급/지연)
|
||||
* - 3컬럼 레이아웃 (긴급작업/지연작업/작업자별현황)
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Factory, Clock, PlayCircle, CheckCircle2, AlertTriangle, Timer, Users } from 'lucide-react';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { generateMockWorkOrders, generateMockWorkerStatus } from './mockData';
|
||||
import type { WorkOrder, WorkerStatus, ProcessType, DashboardStats } from './types';
|
||||
import { TAB_OPTIONS, PROCESS_LABELS, STATUS_LABELS } from './types';
|
||||
|
||||
export default function ProductionDashboard() {
|
||||
const router = useRouter();
|
||||
|
||||
// ===== 상태 관리 =====
|
||||
const [selectedTab, setSelectedTab] = useState<ProcessType | 'all'>('all');
|
||||
const [workOrders] = useState<WorkOrder[]>(() => generateMockWorkOrders());
|
||||
const [workerStatus] = useState<WorkerStatus[]>(() => generateMockWorkerStatus());
|
||||
|
||||
// ===== 필터링된 데이터 =====
|
||||
const filteredOrders = useMemo(() => {
|
||||
if (selectedTab === 'all') return workOrders;
|
||||
return workOrders.filter((order) => order.process === selectedTab);
|
||||
}, [workOrders, selectedTab]);
|
||||
|
||||
// ===== 통계 계산 =====
|
||||
const stats: DashboardStats = useMemo(() => {
|
||||
const orders = filteredOrders;
|
||||
return {
|
||||
total: orders.length,
|
||||
waiting: orders.filter((o) => o.status === 'waiting').length,
|
||||
inProgress: orders.filter((o) => o.status === 'inProgress').length,
|
||||
completed: orders.filter((o) => o.status === 'completed').length,
|
||||
urgent: orders.filter((o) => o.isUrgent).length,
|
||||
delayed: orders.filter((o) => o.isDelayed).length,
|
||||
};
|
||||
}, [filteredOrders]);
|
||||
|
||||
// ===== 긴급/지연 작업 필터링 =====
|
||||
const urgentOrders = useMemo(
|
||||
() => filteredOrders.filter((o) => o.isUrgent).slice(0, 5),
|
||||
[filteredOrders]
|
||||
);
|
||||
const delayedOrders = useMemo(
|
||||
() => filteredOrders.filter((o) => o.isDelayed).slice(0, 5),
|
||||
[filteredOrders]
|
||||
);
|
||||
|
||||
// ===== 핸들러 =====
|
||||
const handleOrderClick = (orderNo: string) => {
|
||||
// orderNo (예: KD-WO-251217-12)로 상세 페이지 이동
|
||||
router.push(`/ko/production/work-orders/${encodeURIComponent(orderNo)}`);
|
||||
};
|
||||
|
||||
const handleWorkerScreenClick = () => {
|
||||
router.push('/ko/production/worker-screen');
|
||||
};
|
||||
|
||||
const handleWorkOrderListClick = () => {
|
||||
router.push('/ko/production/work-orders');
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Factory className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">생산 현황판</h1>
|
||||
<p className="text-sm text-muted-foreground">공장별 작업 현황을 확인합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleWorkerScreenClick}>
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
작업자 화면
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleWorkOrderListClick}>
|
||||
작업지시 목록
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 탭 */}
|
||||
<Tabs value={selectedTab} onValueChange={(v) => setSelectedTab(v as ProcessType | 'all')}>
|
||||
<TabsList>
|
||||
{TAB_OPTIONS.map((tab) => (
|
||||
<TabsTrigger key={tab.value} value={tab.value} className="px-6">
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<StatCard
|
||||
title="전체 작업"
|
||||
value={stats.total}
|
||||
icon={<Factory className="h-4 w-4" />}
|
||||
variant="default"
|
||||
/>
|
||||
<StatCard
|
||||
title="작업 대기"
|
||||
value={stats.waiting}
|
||||
icon={<Clock 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"
|
||||
/>
|
||||
<StatCard
|
||||
title="지연"
|
||||
value={stats.delayed}
|
||||
icon={<Timer className="h-4 w-4" />}
|
||||
variant="orange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 3컬럼 레이아웃 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* 긴급 작업 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<AlertTriangle className="h-4 w-4 text-red-500" />
|
||||
긴급 작업
|
||||
<Badge variant="destructive" className="ml-auto">
|
||||
{stats.urgent}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{urgentOrders.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
긴급 작업이 없습니다.
|
||||
</p>
|
||||
) : (
|
||||
urgentOrders.map((order) => (
|
||||
<WorkOrderCard
|
||||
key={order.id}
|
||||
order={order}
|
||||
onClick={() => handleOrderClick(order.orderNo)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 지연 작업 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Timer className="h-4 w-4 text-orange-500" />
|
||||
지연 작업
|
||||
<Badge className="ml-auto bg-orange-100 text-orange-800 hover:bg-orange-100">
|
||||
{stats.delayed}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{delayedOrders.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
지연 작업이 없습니다.
|
||||
</p>
|
||||
) : (
|
||||
delayedOrders.map((order) => (
|
||||
<WorkOrderCard
|
||||
key={order.id}
|
||||
order={order}
|
||||
onClick={() => handleOrderClick(order.orderNo)}
|
||||
showDelay
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 작업자별 현황 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Users className="h-4 w-4 text-blue-500" />
|
||||
작업자별 현황
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{workerStatus.map((worker) => (
|
||||
<WorkerStatusRow key={worker.id} worker={worker} />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 하위 컴포넌트 =====
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: number;
|
||||
icon: React.ReactNode;
|
||||
variant: 'default' | 'blue' | 'green' | 'red' | 'orange';
|
||||
}
|
||||
|
||||
function StatCard({ title, value, icon, variant }: StatCardProps) {
|
||||
const variantClasses = {
|
||||
default: 'bg-gray-50 text-gray-700',
|
||||
blue: 'bg-blue-50 text-blue-700',
|
||||
green: 'bg-green-50 text-green-700',
|
||||
red: 'bg-red-50 text-red-700',
|
||||
orange: 'bg-orange-50 text-orange-700',
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={variantClasses[variant]}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
{icon}
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-2">{value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface WorkOrderCardProps {
|
||||
order: WorkOrder;
|
||||
onClick: () => void;
|
||||
showDelay?: boolean;
|
||||
}
|
||||
|
||||
function WorkOrderCard({ order, onClick, showDelay }: WorkOrderCardProps) {
|
||||
const statusColors = {
|
||||
waiting: 'bg-gray-100 text-gray-800',
|
||||
inProgress: 'bg-blue-100 text-blue-800',
|
||||
completed: 'bg-green-100 text-green-800',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="p-3 border rounded-lg hover:bg-accent cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{order.orderNo}
|
||||
</span>
|
||||
<Badge className={`text-xs ${statusColors[order.status]}`}>
|
||||
{STATUS_LABELS[order.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm font-medium mt-1 truncate">{order.productName}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{order.client}</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{PROCESS_LABELS[order.process]}
|
||||
</Badge>
|
||||
{showDelay && order.delayDays && (
|
||||
<p className="text-xs text-orange-600 mt-1">+{order.delayDays}일 지연</p>
|
||||
)}
|
||||
{order.isUrgent && !showDelay && (
|
||||
<p className="text-xs text-muted-foreground mt-1">순위 {order.priority}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{order.instruction && (
|
||||
<p className="text-xs text-muted-foreground mt-2 truncate">
|
||||
{order.instruction}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface WorkerStatusRowProps {
|
||||
worker: WorkerStatus;
|
||||
}
|
||||
|
||||
function WorkerStatusRow({ worker }: WorkerStatusRowProps) {
|
||||
const progressPercent = worker.assigned > 0
|
||||
? Math.round((worker.completed / worker.assigned) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2 border-b last:border-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center text-sm font-medium">
|
||||
{worker.name.charAt(0)}
|
||||
</div>
|
||||
<span className="text-sm font-medium">{worker.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-muted-foreground">작업중</p>
|
||||
<p className="font-medium text-blue-600">{worker.inProgress}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-muted-foreground">완료</p>
|
||||
<p className="font-medium text-green-600">{worker.completed}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-muted-foreground">배정</p>
|
||||
<p className="font-medium">{worker.assigned}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
158
src/components/production/ProductionDashboard/mockData.ts
Normal file
158
src/components/production/ProductionDashboard/mockData.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import type { WorkOrder, WorkerStatus, ProcessType } from './types';
|
||||
|
||||
// Mock 작업 지시 데이터
|
||||
export const generateMockWorkOrders = (): WorkOrder[] => {
|
||||
const processes: ProcessType[] = ['screen', 'slat', 'bending'];
|
||||
const clients = ['삼성물산(주)', '현대건설(주)', '대림건설(주)', '두산건설(주)', '(주)서울인테리어'];
|
||||
const projects = [
|
||||
'강남 타워 신축현장 (B동)',
|
||||
'강남 오피스 A동',
|
||||
'해운대 타워',
|
||||
'[E2E테스트] 강남 오피스 A동',
|
||||
'대치 레이크파크',
|
||||
'위례 청라 센트럴파크',
|
||||
'판교 물류센터',
|
||||
'삼성타운 종합',
|
||||
'분당 더 피스트',
|
||||
'연수 오피스텔',
|
||||
];
|
||||
const productNames = [
|
||||
'스크린 서터 (표준형) - 추가',
|
||||
'방연셔터 절곡 부품',
|
||||
'철재 슬랫 서터',
|
||||
'스크린 서터 (대형)',
|
||||
];
|
||||
const assigneePool = [
|
||||
'김스크린', '박스크린', '이스크린', '최스크린',
|
||||
'김슬랫', '박슬랫', '이절곡', '박절곡',
|
||||
'이정곡', '김술랫', '박술랫', '이슬랫',
|
||||
];
|
||||
|
||||
const orders: WorkOrder[] = [];
|
||||
|
||||
// 긴급 작업 (5개) - WorkOrders mockData와 매칭
|
||||
const urgentOrders = [
|
||||
{ orderNo: 'KD-WO-251217-12', process: 'screen' as ProcessType, client: '두산건설(주)', project: '위브 청라 센트럴파크', dueDate: '2025-12-30', status: 'completed' as const },
|
||||
{ orderNo: 'KD-WO-251217-11', process: 'screen' as ProcessType, client: '대영건설(주)', project: '대시앙 동탄 레이크파크', dueDate: '2026-02-08', status: 'inProgress' as const },
|
||||
{ orderNo: 'KD-WO-FLD-251216-01', process: 'bending' as ProcessType, client: '삼성물산(주)', project: '[E2E테스트] 절곡 전용 현장', dueDate: '2025-12-28', status: 'inProgress' as const },
|
||||
{ orderNo: 'KD-WO-251217-10', process: 'screen' as ProcessType, client: '포레나', project: '포레나 수지 더 퍼스트', dueDate: '2026-02-13', status: 'waiting' as const },
|
||||
{ orderNo: 'KD-WO-251217-09', process: 'slat' as ProcessType, client: '호반건설(주)', project: '써밋 광교 리버파크', dueDate: '2026-01-30', status: 'inProgress' as const },
|
||||
];
|
||||
|
||||
urgentOrders.forEach((item, i) => {
|
||||
orders.push({
|
||||
id: `urgent-${i + 1}`,
|
||||
orderNo: item.orderNo,
|
||||
productName: productNames[i % productNames.length],
|
||||
process: item.process,
|
||||
client: item.client,
|
||||
projectName: item.project,
|
||||
assignees: [assigneePool[i % assigneePool.length]],
|
||||
quantity: (i % 5) + 2, // 고정값 (2~6)
|
||||
dueDate: item.dueDate,
|
||||
priority: i + 1,
|
||||
status: item.status,
|
||||
isUrgent: true,
|
||||
isDelayed: false,
|
||||
instruction: i === 0 ? '추가분 주문, 기존 납품분과 동일 사양 유지' : undefined,
|
||||
createdAt: '2025-12-20T09:00:00.000Z', // 고정값
|
||||
});
|
||||
});
|
||||
|
||||
// 지연 작업 (5개) - WorkOrders mockData와 매칭
|
||||
const delayedOrders = [
|
||||
{ orderNo: 'KD-WO-251217-08', process: 'screen' as ProcessType, client: '삼성물산(주)', delayDays: 5 },
|
||||
{ orderNo: 'KD-WO-251217-07', process: 'screen' as ProcessType, client: '삼성물산(주)', delayDays: 3 },
|
||||
{ orderNo: 'KD-WO-FLD-251215-01', process: 'bending' as ProcessType, client: '삼성물산(주)', delayDays: 7 },
|
||||
{ orderNo: 'KD-WO-FLD-251212-01', process: 'bending' as ProcessType, client: '삼성물산(주)', delayDays: 10 },
|
||||
{ orderNo: 'KD-WO-FLD-251208-01', process: 'screen' as ProcessType, client: '삼성물산(주)', delayDays: 1 },
|
||||
];
|
||||
|
||||
delayedOrders.forEach((item, i) => {
|
||||
orders.push({
|
||||
id: `delayed-${i + 1}`,
|
||||
orderNo: item.orderNo,
|
||||
productName: productNames[i % productNames.length],
|
||||
process: item.process,
|
||||
client: item.client,
|
||||
projectName: projects[i % projects.length],
|
||||
assignees: [assigneePool[(i + 5) % assigneePool.length], assigneePool[(i + 6) % assigneePool.length]],
|
||||
quantity: (i % 5) + 2, // 고정값 (2~6)
|
||||
dueDate: '2025-01-15',
|
||||
priority: i + 1,
|
||||
status: 'inProgress',
|
||||
isUrgent: false,
|
||||
isDelayed: true,
|
||||
delayDays: item.delayDays,
|
||||
createdAt: '2025-12-20T09:00:00.000Z', // 고정값
|
||||
});
|
||||
});
|
||||
|
||||
// 일반 작업 (추가) - 대기/작업중 상태 위주로 추가
|
||||
for (let i = 0; i < 22; i++) {
|
||||
const process = processes[i % 3];
|
||||
// completed 비율을 줄이고 waiting/inProgress 위주로 생성
|
||||
const statusOptions: Array<'waiting' | 'inProgress' | 'completed'> = ['waiting', 'waiting', 'inProgress', 'inProgress', 'inProgress', 'completed'];
|
||||
orders.push({
|
||||
id: `work-${i + 1}`,
|
||||
orderNo: `KD-WO-${process === 'bending' ? 'FLD-' : ''}25${String(12).padStart(2, '0')}${String(i + 1).padStart(2, '0')}-${String((i % 3) + 1).padStart(2, '0')}`,
|
||||
productName: productNames[i % productNames.length],
|
||||
process,
|
||||
client: clients[i % clients.length],
|
||||
projectName: projects[i % projects.length],
|
||||
assignees: [assigneePool[i % assigneePool.length]],
|
||||
quantity: (i % 8) + 3, // 고정값 (3~10)
|
||||
dueDate: `2025-${String((i % 3) + 1).padStart(2, '0')}-${String((i % 28) + 1).padStart(2, '0')}`,
|
||||
priority: (i % 5) + 1,
|
||||
status: statusOptions[i % statusOptions.length],
|
||||
isUrgent: false,
|
||||
isDelayed: false,
|
||||
createdAt: '2025-12-20T09:00:00.000Z', // 고정값
|
||||
});
|
||||
}
|
||||
|
||||
// 작업자 화면용 추가 데이터 (대기/작업중 상태만)
|
||||
const additionalWaitingOrders = [
|
||||
{ orderNo: 'KD-WO-251201-01', process: 'screen' as ProcessType, client: '삼성물산(주)', project: '강남 타워 신축현장 (B동)', product: '스크린 서터 (표준형) - 추가', quantity: 3, priority: 1 },
|
||||
{ orderNo: 'KD-WO-251202-02', process: 'slat' as ProcessType, client: '현대건설(주)', project: '해운대 타워', product: '철재 슬랫 서터', quantity: 5, priority: 2 },
|
||||
{ orderNo: 'KD-WO-251203-03', process: 'screen' as ProcessType, client: '대림건설(주)', project: '대치 레이크파크', product: '스크린 서터 (대형)', quantity: 2, priority: 3 },
|
||||
{ orderNo: 'KD-WO-FLD-251204-01', process: 'bending' as ProcessType, client: '두산건설(주)', project: '위례 청라 센트럴파크', product: '방연셔터 절곡 부품', quantity: 8, priority: 1 },
|
||||
{ orderNo: 'KD-WO-251205-04', process: 'screen' as ProcessType, client: '(주)서울인테리어', project: '판교 물류센터', product: '스크린 서터 (표준형) - 추가', quantity: 4, priority: 2 },
|
||||
];
|
||||
|
||||
additionalWaitingOrders.forEach((item, i) => {
|
||||
orders.push({
|
||||
id: `additional-waiting-${i + 1}`,
|
||||
orderNo: item.orderNo,
|
||||
productName: item.product,
|
||||
process: item.process,
|
||||
client: item.client,
|
||||
projectName: item.project,
|
||||
assignees: [assigneePool[i % assigneePool.length]],
|
||||
quantity: item.quantity,
|
||||
dueDate: '2025-01-01',
|
||||
priority: item.priority,
|
||||
status: 'waiting',
|
||||
isUrgent: i === 0 || i === 3, // 1, 4번째 긴급
|
||||
isDelayed: false,
|
||||
instruction: i === 0 ? '추가분 주문, 기존 납품분과 동일 사양 유지' : undefined,
|
||||
createdAt: '2025-12-20T09:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
return orders;
|
||||
};
|
||||
|
||||
// Mock 작업자 현황 데이터
|
||||
export const generateMockWorkerStatus = (): WorkerStatus[] => {
|
||||
return [
|
||||
{ id: 'w1', name: '김스크린', inProgress: 4, completed: 4, assigned: 9 },
|
||||
{ id: 'w2', name: '박스크린', inProgress: 4, completed: 4, assigned: 5 },
|
||||
{ id: 'w3', name: '김슬랫', inProgress: 0, completed: 3, assigned: 5 },
|
||||
{ id: 'w4', name: '박슬랫', inProgress: 0, completed: 2, assigned: 2 },
|
||||
{ id: 'w5', name: '이스크린', inProgress: 1, completed: 1, assigned: 2 },
|
||||
{ id: 'w6', name: '최절곡', inProgress: 0, completed: 2, assigned: 3 },
|
||||
{ id: 'w7', name: '이절곡', inProgress: 1, completed: 0, assigned: 1 },
|
||||
{ id: 'w8', name: '최스크린', inProgress: 0, completed: 1, assigned: 2 },
|
||||
];
|
||||
};
|
||||
79
src/components/production/ProductionDashboard/types.ts
Normal file
79
src/components/production/ProductionDashboard/types.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
// 작업 지시 상태
|
||||
export type WorkOrderStatus = 'waiting' | 'inProgress' | 'completed';
|
||||
|
||||
// 공정 타입
|
||||
export type ProcessType = 'screen' | 'slat' | 'bending' | 'all';
|
||||
|
||||
// 작업 지시
|
||||
export interface WorkOrder {
|
||||
id: string;
|
||||
orderNo: string; // KD-WO-251216-01
|
||||
productName: string; // 스크린 서터 (표준형) - 추가
|
||||
process: ProcessType; // 스크린, 슬랫, 절곡
|
||||
client: string; // 삼성물산(주)
|
||||
projectName: string; // 강남 타워 신축현장
|
||||
assignees: string[]; // 담당자 배열
|
||||
quantity: number; // EA 수량
|
||||
dueDate: string; // 납기
|
||||
priority: number; // 순위 (1~5)
|
||||
status: WorkOrderStatus;
|
||||
isUrgent: boolean;
|
||||
isDelayed: boolean;
|
||||
delayDays?: number; // 지연 일수
|
||||
instruction?: string; // 지시사항
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// 작업자 현황
|
||||
export interface WorkerStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
inProgress: number; // 작업중 건수
|
||||
completed: number; // 완료 건수
|
||||
assigned: number; // 배정 건수
|
||||
}
|
||||
|
||||
// 통계 데이터
|
||||
export interface DashboardStats {
|
||||
total: number; // 전체 작업
|
||||
waiting: number; // 작업 대기
|
||||
inProgress: number; // 작업중
|
||||
completed: number; // 작업 완료
|
||||
urgent: number; // 긴급
|
||||
delayed: number; // 지연
|
||||
}
|
||||
|
||||
// 탭 옵션
|
||||
export interface TabOption {
|
||||
value: ProcessType | 'all';
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const TAB_OPTIONS: TabOption[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'screen', label: '스크린공장' },
|
||||
{ value: 'slat', label: '슬랫공장' },
|
||||
{ value: 'bending', label: '절곡공장' },
|
||||
];
|
||||
|
||||
// 공정 타입 라벨
|
||||
export const PROCESS_LABELS: Record<ProcessType, string> = {
|
||||
screen: '스크린',
|
||||
slat: '슬랫',
|
||||
bending: '절곡',
|
||||
all: '전체',
|
||||
};
|
||||
|
||||
// 상태 라벨
|
||||
export const STATUS_LABELS: Record<WorkOrderStatus, string> = {
|
||||
waiting: '대기',
|
||||
inProgress: '작업중',
|
||||
completed: '완료',
|
||||
};
|
||||
|
||||
// 상태 배지 색상
|
||||
export const STATUS_BADGE_COLORS: Record<WorkOrderStatus, string> = {
|
||||
waiting: 'bg-gray-100 text-gray-800',
|
||||
inProgress: 'bg-blue-100 text-blue-800',
|
||||
completed: 'bg-green-100 text-green-800',
|
||||
};
|
||||
250
src/components/production/WorkOrders/AssigneeSelectModal.tsx
Normal file
250
src/components/production/WorkOrders/AssigneeSelectModal.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 담당자 선택 모달
|
||||
*
|
||||
* 팀별로 그룹화된 담당자를 다중 선택할 수 있는 모달
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
|
||||
|
||||
// 담당자 타입
|
||||
interface Assignee {
|
||||
id: string;
|
||||
name: string;
|
||||
teamId: string;
|
||||
}
|
||||
|
||||
// 팀 타입
|
||||
interface Team {
|
||||
id: string;
|
||||
name: string;
|
||||
members: Assignee[];
|
||||
}
|
||||
|
||||
// Mock 팀/담당자 데이터
|
||||
const TEAMS: Team[] = [
|
||||
{
|
||||
id: 'screen',
|
||||
name: '스크린팀',
|
||||
members: [
|
||||
{ id: 'a1', name: '김스크린', teamId: 'screen' },
|
||||
{ id: 'a2', name: '이스크린', teamId: 'screen' },
|
||||
{ id: 'a3', name: '박스크린', teamId: 'screen' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'bending',
|
||||
name: '절곡팀',
|
||||
members: [
|
||||
{ id: 'b1', name: '김절곡', teamId: 'bending' },
|
||||
{ id: 'b2', name: '이철곡', teamId: 'bending' },
|
||||
{ id: 'b3', name: '박철곡', teamId: 'bending' },
|
||||
{ id: 'b4', name: '최철곡', teamId: 'bending' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'slat',
|
||||
name: '슬랫팀',
|
||||
members: [
|
||||
{ id: 'c1', name: '김슬랫', teamId: 'slat' },
|
||||
{ id: 'c2', name: '이슬랫', teamId: 'slat' },
|
||||
{ id: 'c3', name: '박슬랫', teamId: 'slat' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'inventory',
|
||||
name: '재고(포장)팀',
|
||||
members: [
|
||||
{ id: 'd1', name: '김포팀', teamId: 'inventory' },
|
||||
{ id: 'd2', name: '이포팀', teamId: 'inventory' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface AssigneeSelectModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
selectedIds: string[];
|
||||
onSelect: (ids: string[], names: string[]) => void;
|
||||
}
|
||||
|
||||
export function AssigneeSelectModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
selectedIds,
|
||||
onSelect,
|
||||
}: AssigneeSelectModalProps) {
|
||||
const [localSelected, setLocalSelected] = useState<Set<string>>(new Set(selectedIds));
|
||||
|
||||
// 모달 열릴 때 선택 상태 초기화
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setLocalSelected(new Set(selectedIds));
|
||||
}
|
||||
}, [open, selectedIds]);
|
||||
|
||||
// 전체 담당자 맵
|
||||
const assigneeMap = useMemo(() => {
|
||||
const map = new Map<string, Assignee>();
|
||||
TEAMS.forEach((team) => {
|
||||
team.members.forEach((member) => {
|
||||
map.set(member.id, member);
|
||||
});
|
||||
});
|
||||
return map;
|
||||
}, []);
|
||||
|
||||
// 팀 전체 선택 여부 확인
|
||||
const isTeamFullySelected = (team: Team) => {
|
||||
return team.members.every((m) => localSelected.has(m.id));
|
||||
};
|
||||
|
||||
// 팀 부분 선택 여부 확인
|
||||
const isTeamPartiallySelected = (team: Team) => {
|
||||
const selectedCount = team.members.filter((m) => localSelected.has(m.id)).length;
|
||||
return selectedCount > 0 && selectedCount < team.members.length;
|
||||
};
|
||||
|
||||
// 개별 담당자 토글
|
||||
const toggleAssignee = (id: string) => {
|
||||
const newSelected = new Set(localSelected);
|
||||
if (newSelected.has(id)) {
|
||||
newSelected.delete(id);
|
||||
} else {
|
||||
newSelected.add(id);
|
||||
}
|
||||
setLocalSelected(newSelected);
|
||||
};
|
||||
|
||||
// 팀 전체 토글
|
||||
const toggleTeam = (team: Team) => {
|
||||
const newSelected = new Set(localSelected);
|
||||
const isFullySelected = isTeamFullySelected(team);
|
||||
|
||||
if (isFullySelected) {
|
||||
// 전체 선택 해제
|
||||
team.members.forEach((m) => newSelected.delete(m.id));
|
||||
} else {
|
||||
// 전체 선택
|
||||
team.members.forEach((m) => newSelected.add(m.id));
|
||||
}
|
||||
setLocalSelected(newSelected);
|
||||
};
|
||||
|
||||
// 선택 완료
|
||||
const handleConfirm = () => {
|
||||
const ids = Array.from(localSelected);
|
||||
const names = ids.map((id) => assigneeMap.get(id)?.name || '').filter(Boolean);
|
||||
onSelect(ids, names);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md p-0 gap-0">
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>담당자 선택</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b">
|
||||
<h2 className="text-lg font-semibold">담당자 선택</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">닫기</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 설명 텍스트 */}
|
||||
<p className="text-sm text-muted-foreground px-6 py-3">
|
||||
팀 전체 또는 개별 작업자를 선택할 수 있습니다
|
||||
</p>
|
||||
|
||||
{/* 팀 목록 */}
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
{TEAMS.map((team) => (
|
||||
<div key={team.id}>
|
||||
{/* 팀 헤더 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b bg-white">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id={`team-${team.id}`}
|
||||
checked={isTeamFullySelected(team)}
|
||||
className="h-5 w-5 rounded border-2"
|
||||
data-state={
|
||||
isTeamPartiallySelected(team)
|
||||
? 'indeterminate'
|
||||
: isTeamFullySelected(team)
|
||||
? 'checked'
|
||||
: 'unchecked'
|
||||
}
|
||||
onCheckedChange={() => toggleTeam(team)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`team-${team.id}`}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<span className="text-muted-foreground">👥</span>
|
||||
<span className="font-semibold">{team.name}</span>
|
||||
<span className="text-muted-foreground">({team.members.length}명)</span>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleTeam(team)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
팀 전체 선택
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 팀 멤버 */}
|
||||
{team.members.map((member) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className="flex items-center gap-3 px-6 py-4 border-b ml-8"
|
||||
>
|
||||
<Checkbox
|
||||
id={`member-${member.id}`}
|
||||
checked={localSelected.has(member.id)}
|
||||
className="h-5 w-5 rounded border-2"
|
||||
onCheckedChange={() => toggleAssignee(member.id)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`member-${member.id}`}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<span className="text-muted-foreground">👤</span>
|
||||
<span>{member.name}</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 하단 버튼 */}
|
||||
<div className="px-6 py-4">
|
||||
<Button onClick={handleConfirm} className="w-full bg-black hover:bg-black/90">
|
||||
<Check className="w-4 h-4 mr-1.5" />
|
||||
선택 완료 ({localSelected.size}명)
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
112
src/components/production/WorkOrders/SalesOrderSelectModal.tsx
Normal file
112
src/components/production/WorkOrders/SalesOrderSelectModal.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 수주 선택 모달
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Search, X, FileText } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { mockSalesOrders } from './mockData';
|
||||
import type { SalesOrder } from './types';
|
||||
|
||||
interface SalesOrderSelectModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelect: (order: SalesOrder) => void;
|
||||
}
|
||||
|
||||
export function SalesOrderSelectModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
}: SalesOrderSelectModalProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// 필터링된 수주 목록
|
||||
const filteredOrders = mockSalesOrders.filter((order) => {
|
||||
if (!searchTerm) return true;
|
||||
const term = searchTerm.toLowerCase();
|
||||
return (
|
||||
order.orderNo.toLowerCase().includes(term) ||
|
||||
order.client.toLowerCase().includes(term) ||
|
||||
order.projectName.toLowerCase().includes(term)
|
||||
);
|
||||
});
|
||||
|
||||
const handleSelect = (order: SalesOrder) => {
|
||||
onSelect(order);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>수주 선택</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 검색 */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="수주번호, 거래처, 현장명 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 안내 문구 */}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
생산지시 가능한 수주 {filteredOrders.length}건 (회계확인 완료 상태)
|
||||
</p>
|
||||
|
||||
{/* 수주 목록 */}
|
||||
<div className="max-h-[400px] overflow-y-auto space-y-2">
|
||||
{filteredOrders.map((order) => (
|
||||
<div
|
||||
key={order.id}
|
||||
onClick={() => handleSelect(order)}
|
||||
className="p-4 border rounded-lg cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">{order.orderNo}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{order.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-right">
|
||||
<span className="text-muted-foreground">납기: </span>
|
||||
<span>{order.dueDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mb-1">
|
||||
{order.client}
|
||||
</div>
|
||||
<div className="text-sm mb-2">{order.projectName}</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span>{order.itemCount}개 품목</span>
|
||||
<span>분할 {order.splitCount}건</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filteredOrders.length === 0 && (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<FileText className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p>검색 결과가 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
469
src/components/production/WorkOrders/WorkOrderCreate.tsx
Normal file
469
src/components/production/WorkOrders/WorkOrderCreate.tsx
Normal file
@@ -0,0 +1,469 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 작업지시 등록 페이지
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ArrowLeft, FileText, X, Edit2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { SalesOrderSelectModal } from './SalesOrderSelectModal';
|
||||
import { AssigneeSelectModal } from './AssigneeSelectModal';
|
||||
import { PROCESS_TYPE_LABELS, type ProcessType, type SalesOrder } from './types';
|
||||
|
||||
// Validation 에러 타입
|
||||
interface ValidationErrors {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
// 필드명 매핑
|
||||
const FIELD_NAME_MAP: Record<string, string> = {
|
||||
selectedOrder: '수주',
|
||||
client: '발주처',
|
||||
projectName: '현장명',
|
||||
shipmentDate: '출고예정일',
|
||||
};
|
||||
|
||||
type RegistrationMode = 'linked' | 'manual';
|
||||
|
||||
interface FormData {
|
||||
// 수주 정보
|
||||
selectedOrder: SalesOrder | null;
|
||||
splitOption: 'all' | 'partial';
|
||||
|
||||
// 기본 정보
|
||||
client: string;
|
||||
projectName: string;
|
||||
orderNo: string;
|
||||
itemCount: number;
|
||||
|
||||
// 작업지시 정보
|
||||
processType: ProcessType;
|
||||
shipmentDate: string;
|
||||
priority: number;
|
||||
assignees: string[];
|
||||
|
||||
// 비고
|
||||
note: string;
|
||||
}
|
||||
|
||||
const initialFormData: FormData = {
|
||||
selectedOrder: null,
|
||||
splitOption: 'all',
|
||||
client: '',
|
||||
projectName: '',
|
||||
orderNo: '',
|
||||
itemCount: 0,
|
||||
processType: 'screen',
|
||||
shipmentDate: '',
|
||||
priority: 5,
|
||||
assignees: [],
|
||||
note: '',
|
||||
};
|
||||
|
||||
export function WorkOrderCreate() {
|
||||
const router = useRouter();
|
||||
const [mode, setMode] = useState<RegistrationMode>('linked');
|
||||
const [formData, setFormData] = useState<FormData>(initialFormData);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isAssigneeModalOpen, setIsAssigneeModalOpen] = useState(false);
|
||||
const [assigneeNames, setAssigneeNames] = useState<string[]>([]);
|
||||
const [validationErrors, setValidationErrors] = useState<ValidationErrors>({});
|
||||
|
||||
// 수주 선택 핸들러
|
||||
const handleSelectOrder = (order: SalesOrder) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
selectedOrder: order,
|
||||
client: order.client,
|
||||
projectName: order.projectName,
|
||||
orderNo: order.orderNo,
|
||||
itemCount: order.itemCount,
|
||||
});
|
||||
};
|
||||
|
||||
// 수주 해제
|
||||
const handleClearOrder = () => {
|
||||
setFormData({
|
||||
...initialFormData,
|
||||
processType: formData.processType,
|
||||
shipmentDate: formData.shipmentDate,
|
||||
priority: formData.priority,
|
||||
});
|
||||
};
|
||||
|
||||
// 폼 제출
|
||||
const handleSubmit = () => {
|
||||
// Validation 체크
|
||||
const errors: ValidationErrors = {};
|
||||
|
||||
if (mode === 'linked') {
|
||||
if (!formData.selectedOrder) {
|
||||
errors.selectedOrder = '수주를 선택해주세요';
|
||||
}
|
||||
} else {
|
||||
if (!formData.client) {
|
||||
errors.client = '발주처를 입력해주세요';
|
||||
}
|
||||
if (!formData.projectName) {
|
||||
errors.projectName = '현장명을 입력해주세요';
|
||||
}
|
||||
}
|
||||
|
||||
if (!formData.shipmentDate) {
|
||||
errors.shipmentDate = '출고예정일을 선택해주세요';
|
||||
}
|
||||
|
||||
// 에러가 있으면 상태 업데이트 후 리턴
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setValidationErrors(errors);
|
||||
// 페이지 상단으로 스크롤
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 에러 초기화
|
||||
setValidationErrors({});
|
||||
|
||||
// TODO: API 호출
|
||||
console.log('Submit:', formData);
|
||||
router.push('/production/work-orders');
|
||||
};
|
||||
|
||||
// 취소
|
||||
const handleCancel = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
// 공정 코드 표시
|
||||
const getProcessCode = (type: ProcessType) => {
|
||||
const codes: Record<ProcessType, string> = {
|
||||
screen: 'P-001 | 작업일지: WL-SCR',
|
||||
slat: 'P-002 | 작업일지: WL-SLT',
|
||||
bending: 'P-003 | 작업일지: WL-FLD',
|
||||
};
|
||||
return codes[type];
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={handleCancel}>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" />
|
||||
작업지시 등록
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>
|
||||
등록
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Validation 에러 표시 */}
|
||||
{Object.keys(validationErrors).length > 0 && (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">⚠️</span>
|
||||
<div className="flex-1">
|
||||
<strong className="block mb-2">
|
||||
입력 내용을 확인해주세요 ({Object.keys(validationErrors).length}개 오류)
|
||||
</strong>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{Object.entries(validationErrors).map(([field, message]) => {
|
||||
const fieldName = FIELD_NAME_MAP[field] || field;
|
||||
return (
|
||||
<li key={field} className="flex items-start gap-1">
|
||||
<span>•</span>
|
||||
<span>
|
||||
<strong>{fieldName}</strong>: {message}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 등록 방식 */}
|
||||
<section className="bg-white border rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-4">등록 방식</h3>
|
||||
<RadioGroup
|
||||
value={mode}
|
||||
onValueChange={(value) => setMode(value as RegistrationMode)}
|
||||
className="flex gap-6"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="linked" id="linked" />
|
||||
<Label htmlFor="linked" className="cursor-pointer">
|
||||
수주 연동 등록{' '}
|
||||
<span className="text-muted-foreground text-sm">
|
||||
(회계확인 완료된 수주에서 불러오기)
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="manual" id="manual" />
|
||||
<Label htmlFor="manual" className="cursor-pointer">
|
||||
수동 등록{' '}
|
||||
<span className="text-muted-foreground text-sm">
|
||||
(수주 없이 직접 입력)
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</section>
|
||||
|
||||
{/* 수주 정보 (연동 모드) */}
|
||||
{mode === 'linked' && (
|
||||
<section className="bg-muted/30 border rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-4">수주 정보</h3>
|
||||
{!formData.selectedOrder ? (
|
||||
<div className="flex items-center justify-between p-4 bg-white border rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="w-5 h-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">수주에서 불러오기</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
회계확인 완료된 수주를 선택하면 정보가 자동으로 채워집니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => setIsModalOpen(true)}>
|
||||
<FileText className="w-4 h-4 mr-1.5" />
|
||||
수주 선택
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 bg-white border rounded-lg">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold">{formData.selectedOrder.orderNo}</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formData.selectedOrder.status}에서 불러옴
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formData.selectedOrder.client} / {formData.selectedOrder.projectName} / {formData.selectedOrder.itemCount}개 품목
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={handleClearOrder}>
|
||||
<X className="w-4 h-4 mr-1" />
|
||||
해제
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setIsModalOpen(true)}>
|
||||
<Edit2 className="w-4 h-4 mr-1" />
|
||||
변경
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 분할 선택 */}
|
||||
<div className="pt-4 border-t">
|
||||
<h4 className="text-sm font-medium mb-2">분할 선택</h4>
|
||||
<RadioGroup
|
||||
value={formData.splitOption}
|
||||
onValueChange={(value) =>
|
||||
setFormData({ ...formData, splitOption: value as 'all' | 'partial' })
|
||||
}
|
||||
className="flex gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="all" id="split-all" />
|
||||
<Label htmlFor="split-all" className="cursor-pointer">
|
||||
전체 품목
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="partial" id="split-partial" />
|
||||
<Label htmlFor="split-partial" className="cursor-pointer">
|
||||
{formData.selectedOrder.orderNo}-01
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<section className="bg-muted/30 border rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-4">기본 정보</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>발주처 *</Label>
|
||||
<Input
|
||||
value={formData.client}
|
||||
onChange={(e) => setFormData({ ...formData, client: e.target.value })}
|
||||
placeholder={mode === 'linked' ? '수주를 선택하면 자동 입력됩니다' : '발주처 입력'}
|
||||
disabled={mode === 'linked'}
|
||||
className="bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>현장명 *</Label>
|
||||
<Input
|
||||
value={formData.projectName}
|
||||
onChange={(e) => setFormData({ ...formData, projectName: e.target.value })}
|
||||
placeholder={mode === 'linked' ? '수주를 선택하면 자동 입력됩니다' : '현장명 입력'}
|
||||
disabled={mode === 'linked'}
|
||||
className="bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>수주번호</Label>
|
||||
<Input
|
||||
value={formData.orderNo}
|
||||
onChange={(e) => setFormData({ ...formData, orderNo: e.target.value })}
|
||||
placeholder={mode === 'linked' ? '수주를 선택하면 자동 입력됩니다' : '수주번호 입력'}
|
||||
disabled={mode === 'linked'}
|
||||
className="bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>품목수</Label>
|
||||
<Input
|
||||
value={formData.itemCount || ''}
|
||||
onChange={(e) => setFormData({ ...formData, itemCount: parseInt(e.target.value) || 0 })}
|
||||
placeholder={mode === 'linked' ? '수주를 선택하면 자동 입력됩니다' : '품목수 입력'}
|
||||
disabled={mode === 'linked'}
|
||||
className="bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 작업지시 정보 */}
|
||||
<section className="bg-muted/30 border rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-4">작업지시 정보</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>공정구분 *</Label>
|
||||
<Select
|
||||
value={formData.processType}
|
||||
onValueChange={(value) => setFormData({ ...formData, processType: value as ProcessType })}
|
||||
>
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(PROCESS_TYPE_LABELS).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
공정코드: {getProcessCode(formData.processType)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>출고예정일 *</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.shipmentDate}
|
||||
onChange={(e) => setFormData({ ...formData, shipmentDate: e.target.value })}
|
||||
className="bg-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>우선순위 (1=긴급, 9=낮음)</Label>
|
||||
<Select
|
||||
value={formData.priority.toString()}
|
||||
onValueChange={(value) => setFormData({ ...formData, priority: parseInt(value) })}
|
||||
>
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => (
|
||||
<SelectItem key={n} value={n.toString()}>
|
||||
{n} {n === 5 ? '(일반)' : n === 1 ? '(긴급)' : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>담당자 (다중선택 가능)</Label>
|
||||
<div
|
||||
onClick={() => setIsAssigneeModalOpen(true)}
|
||||
className="flex min-h-10 w-full cursor-pointer items-center rounded-md border border-input bg-white px-3 py-2 text-sm ring-offset-background hover:bg-accent/50"
|
||||
>
|
||||
{assigneeNames.length > 0 ? (
|
||||
<span>{assigneeNames.join(', ')}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">담당자를 선택하세요 (팀/개인)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 비고 */}
|
||||
<section className="bg-muted/30 border rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-4">비고</h3>
|
||||
<Textarea
|
||||
value={formData.note}
|
||||
onChange={(e) => setFormData({ ...formData, note: e.target.value })}
|
||||
placeholder="특이사항이나 메모를 입력하세요"
|
||||
rows={4}
|
||||
className="bg-white"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* 수주 선택 모달 */}
|
||||
<SalesOrderSelectModal
|
||||
open={isModalOpen}
|
||||
onOpenChange={setIsModalOpen}
|
||||
onSelect={handleSelectOrder}
|
||||
/>
|
||||
|
||||
{/* 담당자 선택 모달 */}
|
||||
<AssigneeSelectModal
|
||||
open={isAssigneeModalOpen}
|
||||
onOpenChange={setIsAssigneeModalOpen}
|
||||
selectedIds={formData.assignees}
|
||||
onSelect={(ids, names) => {
|
||||
setFormData({ ...formData, assignees: ids });
|
||||
setAssigneeNames(names);
|
||||
}}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
355
src/components/production/WorkOrders/WorkOrderDetail.tsx
Normal file
355
src/components/production/WorkOrders/WorkOrderDetail.tsx
Normal file
@@ -0,0 +1,355 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 작업지시 상세 페이지
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FileText, List, AlertTriangle, Play, CheckCircle2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { WorkLogModal } from '../WorkerScreen/WorkLogModal';
|
||||
import { mockWorkOrders } from './mockData';
|
||||
import {
|
||||
PROCESS_TYPE_LABELS,
|
||||
WORK_ORDER_STATUS_LABELS,
|
||||
WORK_ORDER_STATUS_COLORS,
|
||||
ITEM_STATUS_LABELS,
|
||||
ISSUE_STATUS_LABELS,
|
||||
SCREEN_PROCESS_STEPS,
|
||||
SLAT_PROCESS_STEPS,
|
||||
BENDING_PROCESS_STEPS,
|
||||
type WorkOrder,
|
||||
type ProcessType,
|
||||
} from './types';
|
||||
|
||||
// 공정 진행 단계 컴포넌트
|
||||
function ProcessSteps({
|
||||
processType,
|
||||
currentStep,
|
||||
}: {
|
||||
processType: ProcessType;
|
||||
currentStep: number;
|
||||
}) {
|
||||
const steps =
|
||||
processType === 'screen'
|
||||
? SCREEN_PROCESS_STEPS
|
||||
: processType === 'slat'
|
||||
? SLAT_PROCESS_STEPS
|
||||
: BENDING_PROCESS_STEPS;
|
||||
|
||||
return (
|
||||
<div className="bg-white border rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-4">공정 진행 ({steps.length}단계)</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{steps.map((step, index) => {
|
||||
const isCompleted = index < currentStep;
|
||||
const isCurrent = index === currentStep;
|
||||
|
||||
return (
|
||||
<div key={step.key} className="flex items-center">
|
||||
<div
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-full border ${
|
||||
isCompleted
|
||||
? 'bg-gray-900 text-white border-gray-900'
|
||||
: isCurrent
|
||||
? 'bg-white border-gray-900 text-gray-900'
|
||||
: 'bg-white border-gray-300 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium">{step.order}</span>
|
||||
<span>{step.label}</span>
|
||||
{isCompleted && (
|
||||
<span className="text-xs bg-white text-gray-900 px-1.5 py-0.5 rounded">
|
||||
완료
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 전개도 상세정보 컴포넌트 (절곡용)
|
||||
function BendingDetailsSection({ order }: { order: WorkOrder }) {
|
||||
if (!order.bendingDetails || order.bendingDetails.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white border rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-4">전개도 상세정보</h3>
|
||||
<div className="space-y-4">
|
||||
{order.bendingDetails.map((detail) => (
|
||||
<div key={detail.id} className="border rounded-lg overflow-hidden">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between bg-gray-100 px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">{detail.code}</span>
|
||||
<span className="font-medium">{detail.name}</span>
|
||||
<span className="text-sm text-muted-foreground">{detail.material}</span>
|
||||
</div>
|
||||
<span className="text-sm">수량: {detail.quantity}</span>
|
||||
</div>
|
||||
|
||||
{/* 상세 정보 */}
|
||||
<div className="grid grid-cols-5 gap-4 p-4">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">전개폭</p>
|
||||
<p className="font-medium">{detail.developWidth}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">길이</p>
|
||||
<p className="font-medium">{detail.length}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">중량</p>
|
||||
<p className="font-medium">{detail.weight}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">비고</p>
|
||||
<p className="font-medium">{detail.note}</p>
|
||||
</div>
|
||||
<div className="row-span-2 flex items-center justify-center border rounded bg-gray-50">
|
||||
{/* 전개도 이미지 placeholder */}
|
||||
<div className="text-center p-4">
|
||||
<div className="w-24 h-16 border-2 border-dashed border-gray-300 rounded flex items-center justify-center mb-1">
|
||||
<span className="text-xs text-muted-foreground">전개도</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{detail.developDimension}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<p className="text-xs text-muted-foreground mb-1">전개치수</p>
|
||||
<p className="font-medium">{detail.developDimension}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 이슈 섹션 컴포넌트
|
||||
function IssueSection({ order }: { order: WorkOrder }) {
|
||||
if (!order.issues || order.issues.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white border rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-4">이슈 ({order.issues.length}건)</h3>
|
||||
<div className="space-y-3">
|
||||
{order.issues.map((issue) => (
|
||||
<div key={issue.id} className="flex items-start gap-3 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`shrink-0 ${
|
||||
issue.status === 'processing'
|
||||
? 'bg-yellow-100 text-yellow-700 border-yellow-300'
|
||||
: issue.status === 'resolved'
|
||||
? 'bg-green-100 text-green-700 border-green-300'
|
||||
: 'bg-gray-100 text-gray-700 border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{ISSUE_STATUS_LABELS[issue.status]}
|
||||
</Badge>
|
||||
<div>
|
||||
<span className="font-medium">{issue.type}</span>
|
||||
<span className="mx-2 text-muted-foreground">·</span>
|
||||
<span>{issue.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface WorkOrderDetailProps {
|
||||
orderId: string;
|
||||
}
|
||||
|
||||
export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
|
||||
const router = useRouter();
|
||||
const [isWorkLogOpen, setIsWorkLogOpen] = useState(false);
|
||||
|
||||
// orderId는 workOrderNo로 전달됨 (예: KD-WO-251217-12)
|
||||
// URL 디코딩 후 검색 (encodeURIComponent로 인코딩되어 있을 수 있음)
|
||||
const decodedOrderId = decodeURIComponent(orderId);
|
||||
const order = mockWorkOrders.find(
|
||||
(o) => o.id === decodedOrderId || o.workOrderNo === decodedOrderId
|
||||
);
|
||||
|
||||
if (!order) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<h1 className="text-2xl font-bold mb-6">작업지시 상세</h1>
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
작업지시를 찾을 수 없습니다.
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// 작업일지용 WorkOrder 변환 (기존 WorkLogModal 타입에 맞춤)
|
||||
const workLogOrder = {
|
||||
id: order.id,
|
||||
orderNo: order.workOrderNo,
|
||||
productName: order.items[0]?.productName || '-',
|
||||
client: order.client,
|
||||
projectName: order.projectName,
|
||||
dueDate: order.dueDate,
|
||||
quantity: order.items.reduce((sum, item) => sum + item.quantity, 0),
|
||||
progress: order.currentStep * 20, // 대략적인 진행률
|
||||
process: order.processType as 'screen' | 'slat' | 'bending',
|
||||
assignees: [order.assignee],
|
||||
instruction: order.note || '',
|
||||
status: 'in_progress' as const,
|
||||
priority: order.priority <= 3 ? 'high' : order.priority <= 6 ? 'medium' : 'low',
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">작업지시 상세</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => setIsWorkLogOpen(true)}>
|
||||
<FileText className="w-4 h-4 mr-1.5" />
|
||||
작업일지
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => router.push('/production/work-orders')}>
|
||||
<List className="w-4 h-4 mr-1.5" />
|
||||
목록
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-4">기본 정보</h3>
|
||||
<div className="grid grid-cols-4 gap-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">작업지시번호</p>
|
||||
<p className="font-medium">{order.workOrderNo}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">로트번호</p>
|
||||
<p className="font-medium">{order.lotNo}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">공정구분</p>
|
||||
<p className="font-medium">{PROCESS_TYPE_LABELS[order.processType]}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">작업상태</p>
|
||||
<Badge className={`${WORK_ORDER_STATUS_COLORS[order.status]} border-0`}>
|
||||
{WORK_ORDER_STATUS_LABELS[order.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">발주처</p>
|
||||
<p className="font-medium">{order.client}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">현장명</p>
|
||||
<p className="font-medium">{order.projectName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">납기일</p>
|
||||
<p className="font-medium">{order.dueDate}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">작업자</p>
|
||||
<p className="font-medium">{order.assignee}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공정 진행 */}
|
||||
<ProcessSteps processType={order.processType} currentStep={order.currentStep} />
|
||||
|
||||
{/* 작업 품목 */}
|
||||
<div className="bg-white border rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-4">작업 품목 ({order.items.length}건)</h3>
|
||||
{order.items.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead className="w-14">No</TableHead>
|
||||
<TableHead className="w-20">상태</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead className="w-28">층/부호</TableHead>
|
||||
<TableHead className="w-32">규격</TableHead>
|
||||
<TableHead className="w-20 text-right">수량</TableHead>
|
||||
<TableHead className="w-20">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{order.items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{item.no}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{ITEM_STATUS_LABELS[item.status]}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{item.productName}</TableCell>
|
||||
<TableCell>{item.floorCode}</TableCell>
|
||||
<TableCell>{item.specification}</TableCell>
|
||||
<TableCell className="text-right">{item.quantity}</TableCell>
|
||||
<TableCell>
|
||||
{item.status === 'waiting' && (
|
||||
<Button variant="outline" size="sm">
|
||||
<Play className="w-3 h-3 mr-1" />
|
||||
시작
|
||||
</Button>
|
||||
)}
|
||||
{item.status === 'in_progress' && (
|
||||
<Button variant="outline" size="sm">
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||
완료
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center py-8">
|
||||
등록된 품목이 없습니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 절곡 전용: 전개도 상세정보 */}
|
||||
{order.processType === 'bending' && <BendingDetailsSection order={order} />}
|
||||
|
||||
{/* 이슈 섹션 */}
|
||||
<IssueSection order={order} />
|
||||
</div>
|
||||
|
||||
{/* 작업일지 모달 */}
|
||||
<WorkLogModal
|
||||
open={isWorkLogOpen}
|
||||
onOpenChange={setIsWorkLogOpen}
|
||||
order={workLogOrder}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
310
src/components/production/WorkOrders/WorkOrderList.tsx
Normal file
310
src/components/production/WorkOrders/WorkOrderList.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 작업지시 목록 페이지
|
||||
* IntegratedListTemplateV2 패턴 적용
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Plus, FileText, Calendar, Users, CheckCircle2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
IntegratedListTemplateV2,
|
||||
TabOption,
|
||||
TableColumn,
|
||||
StatCard,
|
||||
} from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
|
||||
import { mockWorkOrders, mockStats } from './mockData';
|
||||
import {
|
||||
PROCESS_TYPE_LABELS,
|
||||
WORK_ORDER_STATUS_LABELS,
|
||||
WORK_ORDER_STATUS_COLORS,
|
||||
type WorkOrder,
|
||||
} from './types';
|
||||
|
||||
// 탭 필터 정의
|
||||
type TabFilter = 'all' | 'unassigned' | 'pending' | 'waiting' | 'in_progress' | 'completed';
|
||||
|
||||
// 페이지당 항목 수
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
|
||||
export function WorkOrderList() {
|
||||
const router = useRouter();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<TabFilter>('all');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
// 탭별 카운트 계산
|
||||
const tabCounts = useMemo(() => {
|
||||
return {
|
||||
all: mockWorkOrders.length,
|
||||
unassigned: mockWorkOrders.filter((o) => o.status === 'unassigned').length,
|
||||
pending: mockWorkOrders.filter((o) => o.status === 'pending').length,
|
||||
waiting: mockWorkOrders.filter((o) => o.status === 'waiting').length,
|
||||
in_progress: mockWorkOrders.filter((o) => o.status === 'in_progress').length,
|
||||
completed: mockWorkOrders.filter((o) => o.status === 'completed' || o.status === 'shipped').length,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 탭 옵션
|
||||
const tabs: TabOption[] = [
|
||||
{ value: 'all', label: '전체', count: tabCounts.all },
|
||||
{ value: 'unassigned', label: '미배정', count: tabCounts.unassigned, color: 'gray' },
|
||||
{ value: 'pending', label: '승인대기', count: tabCounts.pending, color: 'orange' },
|
||||
{ value: 'waiting', label: '작업대기', count: tabCounts.waiting, color: 'yellow' },
|
||||
{ value: 'in_progress', label: '작업중', count: tabCounts.in_progress, color: 'blue' },
|
||||
{ value: 'completed', label: '작업완료', count: tabCounts.completed, color: 'green' },
|
||||
];
|
||||
|
||||
// 통계 카드
|
||||
const stats: StatCard[] = [
|
||||
{
|
||||
label: '전체',
|
||||
value: mockStats.total,
|
||||
icon: FileText,
|
||||
iconColor: 'text-gray-600',
|
||||
},
|
||||
{
|
||||
label: '작업대기',
|
||||
value: mockStats.waiting + mockStats.unassigned + mockStats.pending,
|
||||
icon: Calendar,
|
||||
iconColor: 'text-orange-600',
|
||||
},
|
||||
{
|
||||
label: '작업중',
|
||||
value: mockStats.inProgress,
|
||||
icon: Users,
|
||||
iconColor: 'text-blue-600',
|
||||
},
|
||||
{
|
||||
label: '작업완료',
|
||||
value: mockStats.completed,
|
||||
icon: CheckCircle2,
|
||||
iconColor: 'text-green-600',
|
||||
},
|
||||
];
|
||||
|
||||
// 테이블 컬럼
|
||||
const tableColumns: TableColumn[] = [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'workOrderNo', label: '작업지시번호', className: 'min-w-[140px]' },
|
||||
{ key: 'processType', label: '공정', className: 'w-[80px]' },
|
||||
{ key: 'lotNo', label: '로트번호', className: 'min-w-[120px]' },
|
||||
{ key: 'orderDate', label: '지시일', className: 'w-[100px]' },
|
||||
{ key: 'isAssigned', label: '배정', className: 'w-[60px] text-center' },
|
||||
{ key: 'hasWork', label: '작업', className: 'w-[60px] text-center' },
|
||||
{ key: 'isStarted', label: '시작', className: 'w-[60px] text-center' },
|
||||
{ key: 'status', label: '작업상태', className: 'w-[100px]' },
|
||||
{ key: 'priority', label: '현장순위', className: 'w-[80px] text-center' },
|
||||
{ key: 'assignee', label: '작업자', className: 'w-[80px]' },
|
||||
{ key: 'projectName', label: '현장명', className: 'min-w-[150px]' },
|
||||
{ key: 'shipmentDate', label: '출고예정일', className: 'w-[110px]' },
|
||||
];
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredOrders = useMemo(() => {
|
||||
let result = [...mockWorkOrders];
|
||||
|
||||
// 탭 필터
|
||||
if (activeTab !== 'all') {
|
||||
if (activeTab === 'completed') {
|
||||
result = result.filter((o) => o.status === 'completed' || o.status === 'shipped');
|
||||
} else {
|
||||
result = result.filter((o) => o.status === activeTab);
|
||||
}
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
result = result.filter(
|
||||
(o) =>
|
||||
o.workOrderNo.toLowerCase().includes(term) ||
|
||||
o.client.toLowerCase().includes(term) ||
|
||||
o.projectName.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [activeTab, searchTerm]);
|
||||
|
||||
// 페이지네이션 데이터
|
||||
const paginatedData = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||
return filteredOrders.slice(startIndex, startIndex + ITEMS_PER_PAGE);
|
||||
}, [filteredOrders, currentPage]);
|
||||
|
||||
// 페이지네이션 설정
|
||||
const pagination = {
|
||||
currentPage,
|
||||
totalPages: Math.ceil(filteredOrders.length / ITEMS_PER_PAGE),
|
||||
totalItems: filteredOrders.length,
|
||||
itemsPerPage: ITEMS_PER_PAGE,
|
||||
onPageChange: setCurrentPage,
|
||||
};
|
||||
|
||||
// 선택 핸들러
|
||||
const handleToggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === paginatedData.length) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedData.map((o) => o.id)));
|
||||
}
|
||||
}, [paginatedData, selectedItems.size]);
|
||||
|
||||
// 상세 페이지 이동
|
||||
const handleView = useCallback((id: string) => {
|
||||
router.push(`/production/work-orders/${id}`);
|
||||
}, [router]);
|
||||
|
||||
// 등록 페이지 이동
|
||||
const handleCreate = () => {
|
||||
router.push('/production/work-orders/create');
|
||||
};
|
||||
|
||||
// 탭 변경 시 페이지 리셋
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value as TabFilter);
|
||||
setCurrentPage(1);
|
||||
setSelectedItems(new Set());
|
||||
};
|
||||
|
||||
// 검색 변경 시 페이지 리셋
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchTerm(value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = (order: WorkOrder, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(order.id);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={order.id}
|
||||
className={`cursor-pointer hover:bg-muted/50 ${isSelected ? 'bg-blue-50' : ''}`}
|
||||
onClick={() => handleView(order.id)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(order.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell className="font-medium">{order.workOrderNo}</TableCell>
|
||||
<TableCell>{PROCESS_TYPE_LABELS[order.processType]}</TableCell>
|
||||
<TableCell>{order.lotNo}</TableCell>
|
||||
<TableCell>{order.orderDate}</TableCell>
|
||||
<TableCell className="text-center">{order.isAssigned ? 'Y' : '-'}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{order.status !== 'unassigned' && order.status !== 'pending' ? 'Y' : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{order.isStarted ? 'Y' : '-'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={`${WORK_ORDER_STATUS_COLORS[order.status]} border-0`}>
|
||||
{WORK_ORDER_STATUS_LABELS[order.status]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{order.priority}</TableCell>
|
||||
<TableCell>{order.assignee}</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate">{order.projectName}</TableCell>
|
||||
<TableCell>{order.shipmentDate}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = (
|
||||
order: WorkOrder,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
isSelected: boolean,
|
||||
onToggle: () => void
|
||||
) => {
|
||||
return (
|
||||
<ListMobileCard
|
||||
id={order.id}
|
||||
isSelected={isSelected}
|
||||
onToggleSelection={onToggle}
|
||||
onClick={() => handleView(order.id)}
|
||||
headerBadges={
|
||||
<>
|
||||
<Badge variant="outline" className="text-xs">#{globalIndex}</Badge>
|
||||
<Badge variant="outline" className="text-xs">{order.workOrderNo}</Badge>
|
||||
</>
|
||||
}
|
||||
title={order.projectName}
|
||||
statusBadge={
|
||||
<Badge className={`${WORK_ORDER_STATUS_COLORS[order.status]} border-0`}>
|
||||
{WORK_ORDER_STATUS_LABELS[order.status]}
|
||||
</Badge>
|
||||
}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="공정" value={PROCESS_TYPE_LABELS[order.processType]} />
|
||||
<InfoField label="로트번호" value={order.lotNo} />
|
||||
<InfoField label="발주처" value={order.client} />
|
||||
<InfoField label="작업자" value={order.assignee || '-'} />
|
||||
<InfoField label="지시일" value={order.orderDate} />
|
||||
<InfoField label="출고예정일" value={order.shipmentDate} />
|
||||
<InfoField label="현장순위" value={order.priority} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 헤더 액션
|
||||
const headerActions = (
|
||||
<Button onClick={handleCreate}>
|
||||
<Plus className="w-4 h-4 mr-1.5" />
|
||||
등록
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<IntegratedListTemplateV2<WorkOrder>
|
||||
title="작업지시 목록"
|
||||
description="생산 작업지시 관리"
|
||||
icon={FileText}
|
||||
headerActions={headerActions}
|
||||
stats={stats}
|
||||
searchValue={searchTerm}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="작업지시번호, 발주처, 현장명 검색..."
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={handleTabChange}
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
totalCount={filteredOrders.length}
|
||||
allData={filteredOrders}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
getItemId={(order) => order.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
pagination={pagination}
|
||||
/>
|
||||
);
|
||||
}
|
||||
10
src/components/production/WorkOrders/index.ts
Normal file
10
src/components/production/WorkOrders/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 작업지시 관리 컴포넌트 내보내기
|
||||
*/
|
||||
|
||||
export * from './types';
|
||||
export * from './mockData';
|
||||
export { WorkOrderList } from './WorkOrderList';
|
||||
export { WorkOrderCreate } from './WorkOrderCreate';
|
||||
export { WorkOrderDetail } from './WorkOrderDetail';
|
||||
export { SalesOrderSelectModal } from './SalesOrderSelectModal';
|
||||
488
src/components/production/WorkOrders/mockData.ts
Normal file
488
src/components/production/WorkOrders/mockData.ts
Normal file
@@ -0,0 +1,488 @@
|
||||
/**
|
||||
* 작업지시 관리 목업 데이터
|
||||
*/
|
||||
|
||||
import type {
|
||||
WorkOrder,
|
||||
SalesOrder,
|
||||
WorkOrderStats,
|
||||
BendingDetail,
|
||||
} from './types';
|
||||
|
||||
// 전개도 상세 목업 (절곡용)
|
||||
const bendingDetailsSample: BendingDetail[] = [
|
||||
{
|
||||
id: 'bd-1',
|
||||
code: 'SD30',
|
||||
name: '엘바',
|
||||
material: 'E.G.I 1.6T',
|
||||
quantity: 4,
|
||||
developWidth: '500mm',
|
||||
length: '3000mm',
|
||||
weight: '0.9kg',
|
||||
note: '-',
|
||||
developDimension: '75',
|
||||
},
|
||||
{
|
||||
id: 'bd-2',
|
||||
code: 'SD31',
|
||||
name: '하장바',
|
||||
material: 'E.G.I 1.6T',
|
||||
quantity: 2,
|
||||
developWidth: '500mm',
|
||||
length: '3000mm',
|
||||
weight: '1.158kg',
|
||||
note: '-',
|
||||
developDimension: '67→126→165→178→193',
|
||||
},
|
||||
{
|
||||
id: 'bd-3',
|
||||
code: 'SD32',
|
||||
name: '짜부가스켓',
|
||||
material: 'E.G.I 0.8T',
|
||||
quantity: 4,
|
||||
developWidth: '500mm',
|
||||
length: '3000mm',
|
||||
weight: '0.576kg',
|
||||
note: '80*4,50*8',
|
||||
developDimension: '48',
|
||||
},
|
||||
{
|
||||
id: 'bd-4',
|
||||
code: 'SD33',
|
||||
name: '50평철',
|
||||
material: 'E.G.I 1.2T',
|
||||
quantity: 2,
|
||||
developWidth: '500mm',
|
||||
length: '3000mm',
|
||||
weight: '0.3kg',
|
||||
note: '-',
|
||||
developDimension: '50',
|
||||
},
|
||||
{
|
||||
id: 'bd-5',
|
||||
code: 'SD36',
|
||||
name: '밑면 점검구',
|
||||
material: 'E.G.I 1.6T',
|
||||
quantity: 2,
|
||||
developWidth: '500mm',
|
||||
length: '2438mm',
|
||||
weight: '0.98kg',
|
||||
note: '500*380',
|
||||
developDimension: '90→240→310',
|
||||
},
|
||||
{
|
||||
id: 'bd-6',
|
||||
code: 'SD37',
|
||||
name: '후면코너부',
|
||||
material: 'E.G.I 1.2T',
|
||||
quantity: 4,
|
||||
developWidth: '500mm',
|
||||
length: '1219mm',
|
||||
weight: '0.45kg',
|
||||
note: '-',
|
||||
developDimension: '35→85→120',
|
||||
},
|
||||
];
|
||||
|
||||
// 작업지시 목업 데이터
|
||||
export const mockWorkOrders: WorkOrder[] = [
|
||||
{
|
||||
id: 'wo-1',
|
||||
workOrderNo: 'KD-WO-251217-12',
|
||||
lotNo: 'KD-TS-251217-10',
|
||||
processType: 'screen',
|
||||
status: 'shipped',
|
||||
client: '두산건설(주)',
|
||||
projectName: '위브 청라 센트럴파크',
|
||||
dueDate: '2025-12-30',
|
||||
assignee: '최스크린',
|
||||
orderDate: '2025-12-17',
|
||||
shipmentDate: '2025-12-28',
|
||||
isAssigned: true,
|
||||
isStarted: true,
|
||||
priority: 5,
|
||||
currentStep: 5,
|
||||
items: [
|
||||
{ id: 'item-1', no: 1, status: 'completed', productName: '스크린 셔터 (표준형)', floorCode: '1층/I-01', specification: '3500×2500', quantity: 1 },
|
||||
{ id: 'item-2', no: 2, status: 'completed', productName: '스크린 셔터 (표준형)', floorCode: '1층/I-02', specification: '3500×2500', quantity: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'wo-2',
|
||||
workOrderNo: 'KD-WO-251217-11',
|
||||
lotNo: 'KD-TS-251217-09',
|
||||
processType: 'screen',
|
||||
status: 'in_progress',
|
||||
client: '대영건설(주)',
|
||||
projectName: '대시앙 동탄 레이크파크',
|
||||
dueDate: '2026-02-08',
|
||||
assignee: '김스크린',
|
||||
orderDate: '2025-12-17',
|
||||
shipmentDate: '2026-02-01',
|
||||
isAssigned: true,
|
||||
isStarted: true,
|
||||
priority: 5,
|
||||
currentStep: 3,
|
||||
items: [
|
||||
{ id: 'item-3', no: 1, status: 'waiting', productName: '스크린 사타 (표준형)', floorCode: '1층/H-01', specification: '4000×3000', quantity: 1 },
|
||||
{ id: 'item-4', no: 2, status: 'waiting', productName: '스크린 사타 (표준형)', floorCode: '2층/H-02', specification: '4000×3000', quantity: 1 },
|
||||
],
|
||||
issues: [
|
||||
{
|
||||
id: 'issue-1',
|
||||
status: 'processing',
|
||||
type: '불량품발생',
|
||||
description: '앤드락 접착불량 - 전체 재작업 필요',
|
||||
createdAt: '2025-12-20',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'wo-3',
|
||||
workOrderNo: 'KD-WO-251217-10',
|
||||
lotNo: 'KD-TS-251217-08',
|
||||
processType: 'screen',
|
||||
status: 'waiting',
|
||||
client: '포레나',
|
||||
projectName: '포레나 수지 더 퍼스트',
|
||||
dueDate: '2026-02-13',
|
||||
assignee: '-',
|
||||
orderDate: '2025-12-17',
|
||||
shipmentDate: '2026-02-05',
|
||||
isAssigned: false,
|
||||
isStarted: false,
|
||||
priority: 7,
|
||||
currentStep: 0,
|
||||
items: [
|
||||
{ id: 'item-5', no: 1, status: 'waiting', productName: '스크린 셔터 (표준형)', floorCode: '1층/A-01', specification: '3000×2500', quantity: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'wo-4',
|
||||
workOrderNo: 'KD-WO-251217-09',
|
||||
lotNo: 'KD-TS-251217-07',
|
||||
processType: 'slat',
|
||||
status: 'in_progress',
|
||||
client: '호반건설(주)',
|
||||
projectName: '써밋 광교 리버파크',
|
||||
dueDate: '2026-01-30',
|
||||
assignee: '이슬랫',
|
||||
orderDate: '2025-12-17',
|
||||
shipmentDate: '2026-01-20',
|
||||
isAssigned: true,
|
||||
isStarted: true,
|
||||
priority: 5,
|
||||
currentStep: 1,
|
||||
items: [
|
||||
{ id: 'item-6', no: 1, status: 'waiting', productName: '철재 슬랫 셔터', floorCode: '3층/F-05', specification: '3500×2500', quantity: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'wo-5',
|
||||
workOrderNo: 'KD-WO-251217-08',
|
||||
lotNo: 'KD-TS-251217-07',
|
||||
processType: 'screen',
|
||||
status: 'completed',
|
||||
client: '삼성물산(주)',
|
||||
projectName: '써밋 광교 리버파크',
|
||||
dueDate: '2026-01-18',
|
||||
assignee: '박스크린',
|
||||
orderDate: '2025-12-17',
|
||||
shipmentDate: '2026-01-10',
|
||||
isAssigned: true,
|
||||
isStarted: true,
|
||||
priority: 5,
|
||||
currentStep: 5,
|
||||
items: [
|
||||
{ id: 'item-7', no: 1, status: 'completed', productName: '스크린 셔터 (표준형)', floorCode: '1층/A-01', specification: '3500×2500', quantity: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'wo-6',
|
||||
workOrderNo: 'KD-WO-FLD-251216-01',
|
||||
lotNo: 'KD-TS-251216-06',
|
||||
processType: 'bending',
|
||||
status: 'completed',
|
||||
client: '삼성물산(주)',
|
||||
projectName: '[E2E테스트] 절곡 전용 현장',
|
||||
dueDate: '2025-12-28',
|
||||
assignee: '최절곡',
|
||||
orderDate: '2025-12-16',
|
||||
shipmentDate: '2025-12-24',
|
||||
isAssigned: true,
|
||||
isStarted: true,
|
||||
priority: 3,
|
||||
currentStep: 4,
|
||||
items: [
|
||||
{ id: 'item-8', no: 1, status: 'waiting', productName: '방화셔터 절곡 부품 SET (E2E)', floorCode: '테스트층/E2E-G-01', specification: '3000×4000', quantity: 1 },
|
||||
],
|
||||
bendingDetails: bendingDetailsSample,
|
||||
issues: [
|
||||
{
|
||||
id: 'issue-2',
|
||||
status: 'processing',
|
||||
type: '불량품발생',
|
||||
description: '중간검사 불합격 - 절곡 각도 불량 1EA (90° 기준 ±2° 초과)',
|
||||
createdAt: '2025-12-22',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'wo-7',
|
||||
workOrderNo: 'KD-WO-251217-07',
|
||||
lotNo: 'KD-TS-251217-07',
|
||||
processType: 'screen',
|
||||
status: 'completed',
|
||||
client: '삼성물산(주)',
|
||||
projectName: '써밋 광교 리버파크',
|
||||
dueDate: '2026-01-08',
|
||||
assignee: '김스크린',
|
||||
orderDate: '2025-12-17',
|
||||
shipmentDate: '2025-12-30',
|
||||
isAssigned: true,
|
||||
isStarted: true,
|
||||
priority: 5,
|
||||
currentStep: 5,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'wo-8',
|
||||
workOrderNo: 'KD-WO-251217-06',
|
||||
lotNo: 'KD-TS-251217-05',
|
||||
processType: 'screen',
|
||||
status: 'completed',
|
||||
client: '대산',
|
||||
projectName: '대산 송도 마린베이',
|
||||
dueDate: '2026-01-28',
|
||||
assignee: '최스크린',
|
||||
orderDate: '2025-12-17',
|
||||
shipmentDate: '2026-01-20',
|
||||
isAssigned: true,
|
||||
isStarted: true,
|
||||
priority: 5,
|
||||
currentStep: 5,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'wo-9',
|
||||
workOrderNo: 'KD-WO-251217-05',
|
||||
lotNo: 'KD-TS-251217-04',
|
||||
processType: 'slat',
|
||||
status: 'completed',
|
||||
client: '자이',
|
||||
projectName: '자이 위례 더 퍼스트',
|
||||
dueDate: '2026-01-28',
|
||||
assignee: '이슬랫',
|
||||
orderDate: '2025-12-17',
|
||||
shipmentDate: '2026-01-20',
|
||||
isAssigned: true,
|
||||
isStarted: true,
|
||||
priority: 5,
|
||||
currentStep: 5,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'wo-10',
|
||||
workOrderNo: 'KD-WO-251217-04',
|
||||
lotNo: 'KD-TS-251217-03',
|
||||
processType: 'screen',
|
||||
status: 'completed',
|
||||
client: '푸르지오',
|
||||
projectName: '푸르지오 일산 센트럴파크',
|
||||
dueDate: '2026-01-23',
|
||||
assignee: '박스크린',
|
||||
orderDate: '2025-12-17',
|
||||
shipmentDate: '2026-01-15',
|
||||
isAssigned: true,
|
||||
isStarted: true,
|
||||
priority: 5,
|
||||
currentStep: 5,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'wo-11',
|
||||
workOrderNo: 'KD-WO-251217-03',
|
||||
lotNo: 'KD-TS-251217-02',
|
||||
processType: 'bending',
|
||||
status: 'completed',
|
||||
client: '힐스테이트',
|
||||
projectName: '힐스테이트 판교 더 퍼스트',
|
||||
dueDate: '2026-01-18',
|
||||
assignee: '최절곡',
|
||||
orderDate: '2025-12-17',
|
||||
shipmentDate: '2026-01-10',
|
||||
isAssigned: true,
|
||||
isStarted: true,
|
||||
priority: 5,
|
||||
currentStep: 4,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'wo-12',
|
||||
workOrderNo: 'KD-WO-251217-02',
|
||||
lotNo: 'KD-TS-251217-82',
|
||||
processType: 'screen',
|
||||
status: 'completed',
|
||||
client: '힐스테이트',
|
||||
projectName: '힐스테이트 판교 더 머스트',
|
||||
dueDate: '2026-01-08',
|
||||
assignee: '김스크린',
|
||||
orderDate: '2025-12-17',
|
||||
shipmentDate: '2025-12-30',
|
||||
isAssigned: true,
|
||||
isStarted: true,
|
||||
priority: 5,
|
||||
currentStep: 5,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'wo-13',
|
||||
workOrderNo: 'KD-WO-251217-01',
|
||||
lotNo: 'KD-TS-251217-81',
|
||||
processType: 'screen',
|
||||
status: 'completed',
|
||||
client: '레미안',
|
||||
projectName: '레미안 강남 프레스티지',
|
||||
dueDate: '2026-01-13',
|
||||
assignee: '최스크린',
|
||||
orderDate: '2025-12-17',
|
||||
shipmentDate: '2026-01-05',
|
||||
isAssigned: true,
|
||||
isStarted: true,
|
||||
priority: 5,
|
||||
currentStep: 5,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'wo-14',
|
||||
workOrderNo: 'KD-WO-FLD-251215-01',
|
||||
lotNo: 'KD-TS-251215-01',
|
||||
processType: 'bending',
|
||||
status: 'completed',
|
||||
client: '삼성물산(주)',
|
||||
projectName: '송도 아파트 B동',
|
||||
dueDate: '2025-12-30',
|
||||
assignee: '최절곡',
|
||||
orderDate: '2025-12-15',
|
||||
shipmentDate: '2025-12-25',
|
||||
isAssigned: true,
|
||||
isStarted: true,
|
||||
priority: 5,
|
||||
currentStep: 4,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'wo-15',
|
||||
workOrderNo: 'KD-WO-FLD-251212-01',
|
||||
lotNo: 'KD-TS-251212-81',
|
||||
processType: 'bending',
|
||||
status: 'completed',
|
||||
client: '삼성물산(주)',
|
||||
projectName: '판교 롯데센터',
|
||||
dueDate: '2025-12-26',
|
||||
assignee: '최절곡',
|
||||
orderDate: '2025-12-13',
|
||||
shipmentDate: '2025-12-22',
|
||||
isAssigned: true,
|
||||
isStarted: true,
|
||||
priority: 5,
|
||||
currentStep: 4,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'wo-16',
|
||||
workOrderNo: 'KD-WO-FLD-251210-01',
|
||||
lotNo: 'KD-TS-251210-81',
|
||||
processType: 'screen',
|
||||
status: 'completed',
|
||||
client: '삼성물산(주)',
|
||||
projectName: '강남 타워 신축현장',
|
||||
dueDate: '2025-12-25',
|
||||
assignee: '김스크린',
|
||||
orderDate: '2025-12-10',
|
||||
shipmentDate: '2025-12-20',
|
||||
isAssigned: true,
|
||||
isStarted: true,
|
||||
priority: 5,
|
||||
currentStep: 5,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'wo-17',
|
||||
workOrderNo: 'KD-WO-FLD-251208-01',
|
||||
lotNo: 'KD-TS-251288-01',
|
||||
processType: 'screen',
|
||||
status: 'completed',
|
||||
client: '삼성물산(주)',
|
||||
projectName: '배곧더 타워',
|
||||
dueDate: '2025-12-22',
|
||||
assignee: '박스크린',
|
||||
orderDate: '2025-12-08',
|
||||
shipmentDate: '2025-12-18',
|
||||
isAssigned: true,
|
||||
isStarted: true,
|
||||
priority: 5,
|
||||
currentStep: 5,
|
||||
items: [],
|
||||
},
|
||||
];
|
||||
|
||||
// 수주 목록 목업 데이터
|
||||
export const mockSalesOrders: SalesOrder[] = [
|
||||
{
|
||||
id: 'so-1',
|
||||
orderNo: 'KD-TS-251201-01',
|
||||
status: '생산지시완료',
|
||||
client: '삼성물산(주)',
|
||||
projectName: '삼성물산 레미안 강남 1차',
|
||||
dueDate: '2025-12-20',
|
||||
itemCount: 2,
|
||||
splitCount: 1,
|
||||
},
|
||||
{
|
||||
id: 'so-2',
|
||||
orderNo: 'KD-TS-251205-01-A',
|
||||
status: '생산지시완료',
|
||||
client: '삼성물산(주)',
|
||||
projectName: '삼성물산 레미안 강남 1차',
|
||||
dueDate: '2025-12-28',
|
||||
itemCount: 1,
|
||||
splitCount: 1,
|
||||
},
|
||||
{
|
||||
id: 'so-3',
|
||||
orderNo: 'KD-TS-251206-01',
|
||||
status: '생산지시완료',
|
||||
client: '(주)서울인테리어',
|
||||
projectName: '강남 오피스타워 인테리어',
|
||||
dueDate: '2025-12-29',
|
||||
itemCount: 2,
|
||||
splitCount: 2,
|
||||
},
|
||||
{
|
||||
id: 'so-4',
|
||||
orderNo: 'KD-TS-251207-01',
|
||||
status: '생산지시완료',
|
||||
client: '삼성물산(주)',
|
||||
projectName: '삼성물산 레미안 강남 2차',
|
||||
dueDate: '2025-12-28',
|
||||
itemCount: 1,
|
||||
splitCount: 1,
|
||||
},
|
||||
];
|
||||
|
||||
// 통계 계산
|
||||
export function calculateStats(orders: WorkOrder[]): WorkOrderStats {
|
||||
return {
|
||||
total: orders.length,
|
||||
unassigned: orders.filter((o) => o.status === 'unassigned').length,
|
||||
pending: orders.filter((o) => o.status === 'pending').length,
|
||||
waiting: orders.filter((o) => o.status === 'waiting').length,
|
||||
inProgress: orders.filter((o) => o.status === 'in_progress').length,
|
||||
completed: orders.filter((o) => o.status === 'completed' || o.status === 'shipped').length,
|
||||
};
|
||||
}
|
||||
|
||||
// 기본 통계
|
||||
export const mockStats: WorkOrderStats = calculateStats(mockWorkOrders);
|
||||
205
src/components/production/WorkOrders/types.ts
Normal file
205
src/components/production/WorkOrders/types.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* 작업지시 관리 타입 정의
|
||||
*/
|
||||
|
||||
// 공정 구분
|
||||
export type ProcessType = 'screen' | 'slat' | 'bending';
|
||||
|
||||
export const PROCESS_TYPE_LABELS: Record<ProcessType, string> = {
|
||||
screen: '스크린',
|
||||
slat: '슬랫',
|
||||
bending: '절곡',
|
||||
};
|
||||
|
||||
// 작업 상태
|
||||
export type WorkOrderStatus =
|
||||
| 'unassigned' // 미배정
|
||||
| 'pending' // 승인대기
|
||||
| 'waiting' // 작업대기
|
||||
| 'in_progress' // 작업중
|
||||
| 'completed' // 작업완료
|
||||
| 'shipped'; // 출하완료
|
||||
|
||||
export const WORK_ORDER_STATUS_LABELS: Record<WorkOrderStatus, string> = {
|
||||
unassigned: '미배정',
|
||||
pending: '승인대기',
|
||||
waiting: '작업대기',
|
||||
in_progress: '작업중',
|
||||
completed: '작업완료',
|
||||
shipped: '출하완료',
|
||||
};
|
||||
|
||||
export const WORK_ORDER_STATUS_COLORS: Record<WorkOrderStatus, string> = {
|
||||
unassigned: 'bg-gray-100 text-gray-700',
|
||||
pending: 'bg-yellow-100 text-yellow-700',
|
||||
waiting: 'bg-blue-100 text-blue-700',
|
||||
in_progress: 'bg-green-100 text-green-700',
|
||||
completed: 'bg-purple-100 text-purple-700',
|
||||
shipped: 'bg-slate-100 text-slate-700',
|
||||
};
|
||||
|
||||
// 스크린 공정 단계
|
||||
export type ScreenProcessStep =
|
||||
| 'cutting' // 원단절단
|
||||
| 'sewing' // 미싱
|
||||
| 'endlock' // 앤드락작업
|
||||
| 'inspection' // 중간검사
|
||||
| 'packing'; // 포장
|
||||
|
||||
export const SCREEN_PROCESS_STEPS: { key: ScreenProcessStep; label: string; order: number }[] = [
|
||||
{ key: 'cutting', label: '원단절단', order: 1 },
|
||||
{ key: 'sewing', label: '미싱', order: 2 },
|
||||
{ key: 'endlock', label: '앤드락작업', order: 3 },
|
||||
{ key: 'inspection', label: '중간검사', order: 4 },
|
||||
{ key: 'packing', label: '포장', order: 5 },
|
||||
];
|
||||
|
||||
// 슬랫 공정 단계
|
||||
export type SlatProcessStep =
|
||||
| 'coil_cutting' // 코일절단
|
||||
| 'forming' // 성형
|
||||
| 'finishing' // 미미작업
|
||||
| 'inspection' // 검사
|
||||
| 'packing'; // 포장
|
||||
|
||||
export const SLAT_PROCESS_STEPS: { key: SlatProcessStep; label: string; order: number }[] = [
|
||||
{ key: 'coil_cutting', label: '코일절단', order: 1 },
|
||||
{ key: 'forming', label: '성형', order: 2 },
|
||||
{ key: 'finishing', label: '미미작업', order: 3 },
|
||||
{ key: 'inspection', label: '검사', order: 4 },
|
||||
{ key: 'packing', label: '포장', order: 5 },
|
||||
];
|
||||
|
||||
// 절곡 공정 단계
|
||||
export type BendingProcessStep =
|
||||
| 'guide_rail' // 가이드레일 제작
|
||||
| 'case' // 케이스 제작
|
||||
| 'bottom_finish' // 하단마감재 제작
|
||||
| 'inspection'; // 검사
|
||||
|
||||
export const BENDING_PROCESS_STEPS: { key: BendingProcessStep; label: string; order: number }[] = [
|
||||
{ key: 'guide_rail', label: '가이드레일 제작', order: 1 },
|
||||
{ key: 'case', label: '케이스 제작', order: 2 },
|
||||
{ key: 'bottom_finish', label: '하단마감재 제작', order: 3 },
|
||||
{ key: 'inspection', label: '검사', order: 4 },
|
||||
];
|
||||
|
||||
// 품목 상태
|
||||
export type ItemStatus = 'waiting' | 'in_progress' | 'completed';
|
||||
|
||||
export const ITEM_STATUS_LABELS: Record<ItemStatus, string> = {
|
||||
waiting: '대기',
|
||||
in_progress: '작업중',
|
||||
completed: '완료',
|
||||
};
|
||||
|
||||
// 작업 품목
|
||||
export interface WorkOrderItem {
|
||||
id: string;
|
||||
no: number;
|
||||
status: ItemStatus;
|
||||
productName: string;
|
||||
floorCode: string; // 층/부호
|
||||
specification: string; // 규격
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
// 전개도 상세 (절곡용)
|
||||
export interface BendingDetail {
|
||||
id: string;
|
||||
code: string; // SD30, SD31 등
|
||||
name: string; // 엘바, 하장바 등
|
||||
material: string; // E.G.I 1.6T 등
|
||||
quantity: number; // 수량
|
||||
developWidth: string; // 전개폭
|
||||
length: string; // 길이
|
||||
weight: string; // 중량
|
||||
note: string; // 비고
|
||||
developDimension: string; // 전개치수
|
||||
imageUrl?: string; // 전개도 이미지
|
||||
}
|
||||
|
||||
// 이슈
|
||||
export interface WorkOrderIssue {
|
||||
id: string;
|
||||
status: 'pending' | 'processing' | 'resolved';
|
||||
type: string; // 불량품발생 등
|
||||
description: string; // 상세 설명
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export const ISSUE_STATUS_LABELS: Record<WorkOrderIssue['status'], string> = {
|
||||
pending: '대기중',
|
||||
processing: '처리중',
|
||||
resolved: '해결됨',
|
||||
};
|
||||
|
||||
// 작업지시 메인 타입
|
||||
export interface WorkOrder {
|
||||
id: string;
|
||||
workOrderNo: string; // 작업지시번호 (KD-WO-251217-12)
|
||||
lotNo: string; // 로트번호 (KD-TS-251217-10)
|
||||
processType: ProcessType; // 공정구분
|
||||
status: WorkOrderStatus; // 작업상태
|
||||
|
||||
// 기본 정보
|
||||
client: string; // 발주처
|
||||
projectName: string; // 현장명
|
||||
dueDate: string; // 납기일
|
||||
assignee: string; // 작업자
|
||||
|
||||
// 날짜 정보
|
||||
orderDate: string; // 지시일
|
||||
shipmentDate: string; // 출고예정일
|
||||
|
||||
// 플래그
|
||||
isAssigned: boolean; // 배정 여부
|
||||
isStarted: boolean; // 시작 여부
|
||||
|
||||
// 우선순위
|
||||
priority: number; // 1~9 (1=긴급, 9=낮음)
|
||||
|
||||
// 품목
|
||||
items: WorkOrderItem[];
|
||||
|
||||
// 공정 진행 상태 (현재 단계)
|
||||
currentStep: number;
|
||||
|
||||
// 절곡 전용 - 전개도 상세
|
||||
bendingDetails?: BendingDetail[];
|
||||
|
||||
// 이슈
|
||||
issues?: WorkOrderIssue[];
|
||||
|
||||
// 비고
|
||||
note?: string;
|
||||
}
|
||||
|
||||
// 수주 정보 (모달용)
|
||||
export interface SalesOrder {
|
||||
id: string;
|
||||
orderNo: string; // KD-TS-251201-01
|
||||
status: string; // 생산지시완료 등
|
||||
client: string; // 삼성물산(주)
|
||||
projectName: string; // 삼성물산 레미안 강남 1차
|
||||
dueDate: string; // 납기
|
||||
itemCount: number; // 품목수
|
||||
splitCount: number; // 분할건
|
||||
}
|
||||
|
||||
// 리스트 필터
|
||||
export interface WorkOrderFilter {
|
||||
search: string;
|
||||
status: WorkOrderStatus | 'all';
|
||||
processType: ProcessType | 'all';
|
||||
}
|
||||
|
||||
// 통계
|
||||
export interface WorkOrderStats {
|
||||
total: number;
|
||||
unassigned: number;
|
||||
pending: number;
|
||||
waiting: number;
|
||||
inProgress: number;
|
||||
completed: number;
|
||||
}
|
||||
296
src/components/production/WorkResults/WorkResultList.tsx
Normal file
296
src/components/production/WorkResults/WorkResultList.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 작업실적 조회 목록 페이지
|
||||
* IntegratedListTemplateV2 패턴 적용
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
BarChart3,
|
||||
Package,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Percent,
|
||||
Download,
|
||||
CheckCircle,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
IntegratedListTemplateV2,
|
||||
TableColumn,
|
||||
StatCard,
|
||||
} from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
|
||||
import { mockWorkResults, mockStats } from './mockData';
|
||||
import { PROCESS_TYPE_LABELS } from '../WorkOrders/types';
|
||||
import type { WorkResult } from './types';
|
||||
|
||||
// 페이지당 항목 수
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
|
||||
export function WorkResultList() {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
// 통계 카드
|
||||
const stats: StatCard[] = [
|
||||
{
|
||||
label: '총 생산수량',
|
||||
value: `${mockStats.totalProduction}개`,
|
||||
icon: Package,
|
||||
iconColor: 'text-gray-600',
|
||||
},
|
||||
{
|
||||
label: '양품수량',
|
||||
value: `${mockStats.totalGood}개`,
|
||||
icon: CheckCircle2,
|
||||
iconColor: 'text-green-600',
|
||||
},
|
||||
{
|
||||
label: '불량수량',
|
||||
value: `${mockStats.totalDefect}개`,
|
||||
icon: XCircle,
|
||||
iconColor: 'text-red-600',
|
||||
},
|
||||
{
|
||||
label: '불량률',
|
||||
value: `${mockStats.defectRate}%`,
|
||||
icon: Percent,
|
||||
iconColor: 'text-orange-600',
|
||||
},
|
||||
];
|
||||
|
||||
// 테이블 컬럼
|
||||
const tableColumns: TableColumn[] = [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'lotNo', label: '로트번호', className: 'min-w-[150px]' },
|
||||
{ key: 'workDate', label: '작업일', className: 'w-[100px]' },
|
||||
{ key: 'workOrderNo', label: '작업지시번호', className: 'min-w-[140px]' },
|
||||
{ key: 'processType', label: '공정', className: 'w-[80px]' },
|
||||
{ key: 'productName', label: '품목명', className: 'min-w-[180px]' },
|
||||
{ key: 'specification', label: '규격', className: 'w-[100px]' },
|
||||
{ key: 'productionQty', label: '생산수량', className: 'w-[80px] text-center' },
|
||||
{ key: 'goodQty', label: '양품수량', className: 'w-[80px] text-center' },
|
||||
{ key: 'defectQty', label: '불량수량', className: 'w-[80px] text-center' },
|
||||
{ key: 'defectRate', label: '불량률', className: 'w-[80px] text-center' },
|
||||
{ key: 'inspection', label: '검사', className: 'w-[60px] text-center' },
|
||||
{ key: 'packaging', label: '포장', className: 'w-[60px] text-center' },
|
||||
{ key: 'worker', label: '작업자', className: 'w-[80px]' },
|
||||
];
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredResults = useMemo(() => {
|
||||
let result = [...mockWorkResults];
|
||||
|
||||
// 검색 필터
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
result = result.filter(
|
||||
(r) =>
|
||||
r.lotNo.toLowerCase().includes(term) ||
|
||||
r.workOrderNo.toLowerCase().includes(term) ||
|
||||
r.productName.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [searchTerm]);
|
||||
|
||||
// 페이지네이션 데이터
|
||||
const paginatedData = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||
return filteredResults.slice(startIndex, startIndex + ITEMS_PER_PAGE);
|
||||
}, [filteredResults, currentPage]);
|
||||
|
||||
// 페이지네이션 설정
|
||||
const pagination = {
|
||||
currentPage,
|
||||
totalPages: Math.ceil(filteredResults.length / ITEMS_PER_PAGE),
|
||||
totalItems: filteredResults.length,
|
||||
itemsPerPage: ITEMS_PER_PAGE,
|
||||
onPageChange: setCurrentPage,
|
||||
};
|
||||
|
||||
// 선택 핸들러
|
||||
const handleToggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === paginatedData.length) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedData.map((r) => r.id)));
|
||||
}
|
||||
}, [paginatedData, selectedItems.size]);
|
||||
|
||||
// 검색 변경 시 페이지 리셋
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchTerm(value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// 엑셀 다운로드
|
||||
const handleExcelDownload = useCallback(() => {
|
||||
console.log('엑셀 다운로드:', filteredResults);
|
||||
// TODO: 엑셀 다운로드 기능 구현
|
||||
}, [filteredResults]);
|
||||
|
||||
// 상세 보기
|
||||
const handleView = useCallback((id: string) => {
|
||||
console.log('상세 보기:', id);
|
||||
// TODO: 상세 보기 기능 구현
|
||||
}, []);
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = (result: WorkResult, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(result.id);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={result.id}
|
||||
className={`cursor-pointer hover:bg-muted/50 ${isSelected ? 'bg-blue-50' : ''}`}
|
||||
onClick={() => handleView(result.id)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(result.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell className="font-medium">{result.lotNo}</TableCell>
|
||||
<TableCell>{result.workDate}</TableCell>
|
||||
<TableCell>{result.workOrderNo}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{PROCESS_TYPE_LABELS[result.processType]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate">{result.productName}</TableCell>
|
||||
<TableCell>{result.specification}</TableCell>
|
||||
<TableCell className="text-center">{result.productionQty}</TableCell>
|
||||
<TableCell className="text-center">{result.goodQty}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={result.defectQty > 0 ? 'text-red-600 font-medium' : ''}>
|
||||
{result.defectQty}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={result.defectRate > 0 ? 'text-red-600 font-medium' : ''}>
|
||||
{result.defectRate}%
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{result.inspection ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-600 mx-auto" />
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{result.packaging ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-600 mx-auto" />
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{result.worker}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = (
|
||||
result: WorkResult,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
isSelected: boolean,
|
||||
onToggle: () => void
|
||||
) => {
|
||||
return (
|
||||
<ListMobileCard
|
||||
id={result.id}
|
||||
isSelected={isSelected}
|
||||
onToggleSelection={onToggle}
|
||||
onClick={() => handleView(result.id)}
|
||||
headerBadges={
|
||||
<>
|
||||
<Badge variant="outline" className="text-xs">#{globalIndex}</Badge>
|
||||
<Badge variant="outline" className="text-xs">{result.lotNo}</Badge>
|
||||
</>
|
||||
}
|
||||
title={result.productName}
|
||||
statusBadge={
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{PROCESS_TYPE_LABELS[result.processType]}
|
||||
</Badge>
|
||||
}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="작업일" value={result.workDate} />
|
||||
<InfoField label="작업지시번호" value={result.workOrderNo} />
|
||||
<InfoField label="규격" value={result.specification} />
|
||||
<InfoField label="작업자" value={result.worker} />
|
||||
<InfoField label="생산수량" value={`${result.productionQty}개`} />
|
||||
<InfoField label="양품수량" value={`${result.goodQty}개`} />
|
||||
<InfoField
|
||||
label="불량수량"
|
||||
value={`${result.defectQty}개`}
|
||||
className={result.defectQty > 0 ? 'text-red-600' : ''}
|
||||
/>
|
||||
<InfoField
|
||||
label="불량률"
|
||||
value={`${result.defectRate}%`}
|
||||
className={result.defectRate > 0 ? 'text-red-600' : ''}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 헤더 액션
|
||||
const headerActions = (
|
||||
<Button variant="outline" onClick={handleExcelDownload}>
|
||||
<Download className="w-4 h-4 mr-1.5" />
|
||||
엑셀 다운로드
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<IntegratedListTemplateV2<WorkResult>
|
||||
title="작업실적 조회"
|
||||
icon={BarChart3}
|
||||
headerActions={headerActions}
|
||||
stats={stats}
|
||||
searchValue={searchTerm}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="로트번호, 작업지시번호, 품목명 검색..."
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
totalCount={filteredResults.length}
|
||||
allData={filteredResults}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
getItemId={(result) => result.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
pagination={pagination}
|
||||
/>
|
||||
);
|
||||
}
|
||||
6
src/components/production/WorkResults/index.ts
Normal file
6
src/components/production/WorkResults/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* 작업실적 조회 컴포넌트 exports
|
||||
*/
|
||||
|
||||
export { WorkResultList } from './WorkResultList';
|
||||
export type { WorkResult, WorkResultStats } from './types';
|
||||
155
src/components/production/WorkResults/mockData.ts
Normal file
155
src/components/production/WorkResults/mockData.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* 작업실적 조회 Mock 데이터
|
||||
*/
|
||||
|
||||
import type { WorkResult, WorkResultStats } from './types';
|
||||
|
||||
// Mock 작업실적 데이터
|
||||
export const mockWorkResults: WorkResult[] = [
|
||||
{
|
||||
id: 'wr-1',
|
||||
lotNo: 'KD-TS-250212-01-01',
|
||||
workDate: '2025-02-12',
|
||||
workOrderNo: 'KD-PL-250122-01',
|
||||
processType: 'screen',
|
||||
productName: '스크린 셔터 (프리미엄)',
|
||||
specification: '8000x2800',
|
||||
productionQty: 1,
|
||||
goodQty: 0,
|
||||
defectQty: 1,
|
||||
defectRate: 100.0,
|
||||
inspection: true,
|
||||
packaging: false,
|
||||
worker: '이성산',
|
||||
},
|
||||
{
|
||||
id: 'wr-2',
|
||||
lotNo: 'KD-TS-250210-01-02',
|
||||
workDate: '2025-02-10',
|
||||
workOrderNo: 'KD-PL-250120-01',
|
||||
processType: 'screen',
|
||||
productName: '스크린 셔터 (표준형)',
|
||||
specification: '6500x2400',
|
||||
productionQty: 1,
|
||||
goodQty: 1,
|
||||
defectQty: 0,
|
||||
defectRate: 0.0,
|
||||
inspection: true,
|
||||
packaging: true,
|
||||
worker: '김성산',
|
||||
},
|
||||
{
|
||||
id: 'wr-3',
|
||||
lotNo: 'KD-TS-250210-01-01',
|
||||
workDate: '2025-02-10',
|
||||
workOrderNo: 'KD-PL-250120-01',
|
||||
processType: 'screen',
|
||||
productName: '스크린 셔터 (표준형)',
|
||||
specification: '7660x2550',
|
||||
productionQty: 1,
|
||||
goodQty: 1,
|
||||
defectQty: 0,
|
||||
defectRate: 0.0,
|
||||
inspection: true,
|
||||
packaging: true,
|
||||
worker: '김성산',
|
||||
},
|
||||
{
|
||||
id: 'wr-4',
|
||||
lotNo: 'KD-TS-250208-01-01',
|
||||
workDate: '2025-02-08',
|
||||
workOrderNo: 'KD-PL-250118-01',
|
||||
processType: 'slat',
|
||||
productName: '철재 슬랫 셔터',
|
||||
specification: '5000x3000',
|
||||
productionQty: 2,
|
||||
goodQty: 2,
|
||||
defectQty: 0,
|
||||
defectRate: 0.0,
|
||||
inspection: true,
|
||||
packaging: true,
|
||||
worker: '박철호',
|
||||
},
|
||||
{
|
||||
id: 'wr-5',
|
||||
lotNo: 'KD-TS-250205-01-01',
|
||||
workDate: '2025-02-05',
|
||||
workOrderNo: 'KD-PL-250115-01',
|
||||
processType: 'bending',
|
||||
productName: '방화셔터 절곡 부품 SET',
|
||||
specification: '3000x4000',
|
||||
productionQty: 3,
|
||||
goodQty: 2,
|
||||
defectQty: 1,
|
||||
defectRate: 33.3,
|
||||
inspection: true,
|
||||
packaging: true,
|
||||
worker: '최절곡',
|
||||
},
|
||||
{
|
||||
id: 'wr-6',
|
||||
lotNo: 'KD-TS-250203-01-01',
|
||||
workDate: '2025-02-03',
|
||||
workOrderNo: 'KD-PL-250113-01',
|
||||
processType: 'screen',
|
||||
productName: '스크린 셔터 (프리미엄)',
|
||||
specification: '9000x3200',
|
||||
productionQty: 1,
|
||||
goodQty: 1,
|
||||
defectQty: 0,
|
||||
defectRate: 0.0,
|
||||
inspection: true,
|
||||
packaging: true,
|
||||
worker: '이성산',
|
||||
},
|
||||
{
|
||||
id: 'wr-7',
|
||||
lotNo: 'KD-TS-250201-01-01',
|
||||
workDate: '2025-02-01',
|
||||
workOrderNo: 'KD-PL-250111-01',
|
||||
processType: 'slat',
|
||||
productName: '알루미늄 슬랫 셔터',
|
||||
specification: '4500x2800',
|
||||
productionQty: 2,
|
||||
goodQty: 1,
|
||||
defectQty: 1,
|
||||
defectRate: 50.0,
|
||||
inspection: true,
|
||||
packaging: true,
|
||||
worker: '박철호',
|
||||
},
|
||||
{
|
||||
id: 'wr-8',
|
||||
lotNo: 'KD-TS-250130-01-01',
|
||||
workDate: '2025-01-30',
|
||||
workOrderNo: 'KD-PL-250109-01',
|
||||
processType: 'screen',
|
||||
productName: '스크린 셔터 (표준형)',
|
||||
specification: '7000x2600',
|
||||
productionQty: 1,
|
||||
goodQty: 1,
|
||||
defectQty: 0,
|
||||
defectRate: 0.0,
|
||||
inspection: true,
|
||||
packaging: true,
|
||||
worker: '김성산',
|
||||
},
|
||||
];
|
||||
|
||||
// 통계 계산
|
||||
export function calculateWorkResultStats(results: WorkResult[]): WorkResultStats {
|
||||
const totalProduction = results.reduce((sum, r) => sum + r.productionQty, 0);
|
||||
const totalGood = results.reduce((sum, r) => sum + r.goodQty, 0);
|
||||
const totalDefect = results.reduce((sum, r) => sum + r.defectQty, 0);
|
||||
const defectRate = totalProduction > 0 ? (totalDefect / totalProduction) * 100 : 0;
|
||||
|
||||
return {
|
||||
totalProduction,
|
||||
totalGood,
|
||||
totalDefect,
|
||||
defectRate: Math.round(defectRate * 10) / 10,
|
||||
};
|
||||
}
|
||||
|
||||
// 기본 통계
|
||||
export const mockStats: WorkResultStats = calculateWorkResultStats(mockWorkResults);
|
||||
31
src/components/production/WorkResults/types.ts
Normal file
31
src/components/production/WorkResults/types.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 작업실적 조회 타입 정의
|
||||
*/
|
||||
|
||||
import { ProcessType } from '../WorkOrders/types';
|
||||
|
||||
// 작업실적 데이터
|
||||
export interface WorkResult {
|
||||
id: string;
|
||||
lotNo: string; // 로트번호
|
||||
workDate: string; // 작업일
|
||||
workOrderNo: string; // 작업지시번호
|
||||
processType: ProcessType; // 공정
|
||||
productName: string; // 품목명
|
||||
specification: string; // 규격
|
||||
productionQty: number; // 생산수량
|
||||
goodQty: number; // 양품수량
|
||||
defectQty: number; // 불량수량
|
||||
defectRate: number; // 불량률 (%)
|
||||
inspection: boolean; // 검사
|
||||
packaging: boolean; // 포장
|
||||
worker: string; // 작업자
|
||||
}
|
||||
|
||||
// 통계 데이터
|
||||
export interface WorkResultStats {
|
||||
totalProduction: number; // 총 생산수량
|
||||
totalGood: number; // 양품수량
|
||||
totalDefect: number; // 불량수량
|
||||
defectRate: number; // 불량률 (%)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 전량완료 확인 다이얼로그
|
||||
*
|
||||
* "자재 투입이 필요합니다" 안내 후 확인 클릭 시 MaterialInputModal로 이동
|
||||
*/
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { PROCESS_LABELS } from '../ProductionDashboard/types';
|
||||
import type { WorkOrder } from '../ProductionDashboard/types';
|
||||
|
||||
interface CompletionConfirmDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
order: WorkOrder | null;
|
||||
onConfirm: () => void; // 확인 클릭 시 → MaterialInputModal 열기
|
||||
}
|
||||
|
||||
export function CompletionConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
order,
|
||||
onConfirm,
|
||||
}: CompletionConfirmDialogProps) {
|
||||
const handleConfirm = () => {
|
||||
onOpenChange(false);
|
||||
onConfirm(); // 부모에서 MaterialInputModal 열기
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
if (!order) return null;
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-orange-600">
|
||||
자재 투입이 필요합니다!
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-3">
|
||||
<div className="bg-gray-50 p-3 rounded-lg space-y-1 text-sm">
|
||||
<p>
|
||||
<span className="text-muted-foreground">작업지시:</span>{' '}
|
||||
<span className="font-medium text-foreground">{order.orderNo}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">공정:</span>{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{PROCESS_LABELS[order.process]}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-orange-600 font-medium">
|
||||
자재 투입 없이 완료 처리하시겠습니까?
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
(LOT 추적이 불가능해집니다)
|
||||
</p>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={handleCancel}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirm}>
|
||||
확인
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
28
src/components/production/WorkerScreen/CompletionToast.tsx
Normal file
28
src/components/production/WorkerScreen/CompletionToast.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 완료 토스트/뱃지 컴포넌트
|
||||
*
|
||||
* 검은색 라운드 배지, 상단 중앙 표시
|
||||
* 3초 후 자동 fade out
|
||||
*/
|
||||
|
||||
import { CheckCircle2 } from 'lucide-react';
|
||||
import type { CompletionToastInfo } from './types';
|
||||
|
||||
interface CompletionToastProps {
|
||||
info: CompletionToastInfo;
|
||||
}
|
||||
|
||||
export function CompletionToast({ info }: CompletionToastProps) {
|
||||
return (
|
||||
<div className="fixed top-4 left-1/2 -translate-x-1/2 z-50 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||
<div className="bg-gray-900 text-white px-6 py-3 rounded-full shadow-lg flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-400" />
|
||||
<span className="font-medium">
|
||||
{info.orderNo} 완료! ({info.quantity}EA)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
184
src/components/production/WorkerScreen/IssueReportModal.tsx
Normal file
184
src/components/production/WorkerScreen/IssueReportModal.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 이슈 보고 모달
|
||||
*
|
||||
* - 이슈 유형 선택 (5개 버튼)
|
||||
* - 상세 내용 textarea
|
||||
* - 벨리데이션 & 성공 다이얼로그
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import type { WorkOrder } from '../ProductionDashboard/types';
|
||||
import type { IssueType } from './types';
|
||||
import { ISSUE_TYPE_LABELS } from './types';
|
||||
|
||||
interface IssueReportModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
order: WorkOrder | null;
|
||||
}
|
||||
|
||||
export function IssueReportModal({ open, onOpenChange, order }: IssueReportModalProps) {
|
||||
const [selectedType, setSelectedType] = useState<IssueType | null>(null);
|
||||
const [description, setDescription] = useState('');
|
||||
const [showValidationAlert, setShowValidationAlert] = useState(false);
|
||||
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
|
||||
|
||||
const issueTypes: IssueType[] = ['defect', 'noStock', 'delay', 'equipment', 'other'];
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!selectedType) {
|
||||
setShowValidationAlert(true);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[이슈보고]', {
|
||||
orderId: order?.id,
|
||||
orderNo: order?.orderNo,
|
||||
issueType: selectedType,
|
||||
description,
|
||||
});
|
||||
|
||||
setShowSuccessAlert(true);
|
||||
};
|
||||
|
||||
const handleSuccessClose = () => {
|
||||
setShowSuccessAlert(false);
|
||||
setSelectedType(null);
|
||||
setDescription('');
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setSelectedType(null);
|
||||
setDescription('');
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
if (!order) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-orange-500" />
|
||||
이슈 보고
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className="block">작업: {order.orderNo}</span>
|
||||
<span className="block">{order.client}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 이슈 유형 선택 */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">이슈 유형</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{issueTypes.map((type) => (
|
||||
<Button
|
||||
key={type}
|
||||
variant={selectedType === type ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSelectedType(type)}
|
||||
className={
|
||||
selectedType === type
|
||||
? 'bg-orange-600 hover:bg-orange-700'
|
||||
: 'hover:bg-orange-50 hover:border-orange-300'
|
||||
}
|
||||
>
|
||||
{ISSUE_TYPE_LABELS[type]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상세 내용 */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">상세 내용</label>
|
||||
<Textarea
|
||||
placeholder="이슈 상세 내용을 입력하세요..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} className="bg-orange-600 hover:bg-orange-700">
|
||||
보고
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 벨리데이션 알림 */}
|
||||
<AlertDialog open={showValidationAlert} onOpenChange={setShowValidationAlert}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>알림</AlertDialogTitle>
|
||||
<AlertDialogDescription>이슈 유형을 선택해주세요.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction>확인</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 성공 알림 */}
|
||||
<AlertDialog open={showSuccessAlert} onOpenChange={setShowSuccessAlert}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-green-600">
|
||||
이슈가 보고되었습니다.
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-2">
|
||||
<p>
|
||||
<span className="text-muted-foreground">작업:</span>{' '}
|
||||
<span className="font-medium text-foreground">{order.orderNo}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">유형:</span>{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{selectedType && ISSUE_TYPE_LABELS[selectedType]}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction onClick={handleSuccessClose}>확인</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
244
src/components/production/WorkerScreen/MaterialInputModal.tsx
Normal file
244
src/components/production/WorkerScreen/MaterialInputModal.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 자재투입 모달
|
||||
*
|
||||
* - FIFO 순위 표시
|
||||
* - 자재 테이블 (BOM 기준)
|
||||
* - 투입 등록 기능
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Package } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import type { WorkOrder } from '../ProductionDashboard/types';
|
||||
import type { MaterialInput } from './types';
|
||||
|
||||
// Mock 자재 데이터
|
||||
const MOCK_MATERIALS: MaterialInput[] = [
|
||||
{
|
||||
id: '1',
|
||||
materialCode: 'KD-RM-001',
|
||||
materialName: 'SPHC-SD 1.6T',
|
||||
unit: 'KG',
|
||||
currentStock: 500,
|
||||
fifoRank: 1,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
materialCode: 'KD-RM-002',
|
||||
materialName: 'EGI 1.55T',
|
||||
unit: 'KG',
|
||||
currentStock: 350,
|
||||
fifoRank: 2,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
materialCode: 'KD-SM-001',
|
||||
materialName: '볼트 M6x20',
|
||||
unit: 'EA',
|
||||
currentStock: 1200,
|
||||
fifoRank: 3,
|
||||
},
|
||||
];
|
||||
|
||||
interface MaterialInputModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
order: WorkOrder | null;
|
||||
/** 전량완료 흐름에서 사용 - 투입 등록/취소 후 완료 처리 */
|
||||
onComplete?: () => void;
|
||||
/** 전량완료 흐름 여부 (취소 시에도 완료 처리) */
|
||||
isCompletionFlow?: boolean;
|
||||
/** 자재 투입 저장 콜백 */
|
||||
onSaveMaterials?: (orderId: string, materials: MaterialInput[]) => void;
|
||||
/** 이미 투입된 자재 목록 */
|
||||
savedMaterials?: MaterialInput[];
|
||||
}
|
||||
|
||||
export function MaterialInputModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
order,
|
||||
onComplete,
|
||||
isCompletionFlow = false,
|
||||
onSaveMaterials,
|
||||
savedMaterials = [],
|
||||
}: MaterialInputModalProps) {
|
||||
const [selectedMaterials, setSelectedMaterials] = useState<Set<string>>(new Set());
|
||||
const [materials] = useState<MaterialInput[]>(MOCK_MATERIALS);
|
||||
|
||||
// 이미 투입된 자재가 있으면 선택 상태로 초기화
|
||||
const hasSavedMaterials = savedMaterials.length > 0;
|
||||
|
||||
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 handleSubmit = () => {
|
||||
if (!order) return;
|
||||
|
||||
// 선택된 자재 정보 추출
|
||||
const selectedMaterialList = materials.filter((m) => selectedMaterials.has(m.id));
|
||||
console.log('[자재투입] 저장:', order.id, selectedMaterialList);
|
||||
|
||||
// 자재 저장 콜백
|
||||
if (onSaveMaterials) {
|
||||
onSaveMaterials(order.id, selectedMaterialList);
|
||||
}
|
||||
|
||||
setSelectedMaterials(new Set());
|
||||
onOpenChange(false);
|
||||
|
||||
// 전량완료 흐름이면 완료 처리
|
||||
if (isCompletionFlow && onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
};
|
||||
|
||||
// 건너뛰기 (자재 없이 완료) - 전량완료 흐름에서만 사용
|
||||
const handleSkip = () => {
|
||||
setSelectedMaterials(new Set());
|
||||
onOpenChange(false);
|
||||
// 전량완료 흐름이면 완료 처리
|
||||
if (onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
};
|
||||
|
||||
// 취소 (모달만 닫기)
|
||||
const handleCancel = () => {
|
||||
setSelectedMaterials(new Set());
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const getFifoRankBadge = (rank: number) => {
|
||||
const colors = {
|
||||
1: 'bg-red-100 text-red-800',
|
||||
2: 'bg-orange-100 text-orange-800',
|
||||
3: 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
const labels = {
|
||||
1: '최우선',
|
||||
2: '차선',
|
||||
3: '대기',
|
||||
};
|
||||
return (
|
||||
<Badge className={colors[rank as 1 | 2 | 3] || colors[3]}>
|
||||
{rank}위 ({labels[rank as 1 | 2 | 3] || labels[3]})
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
if (!order) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Package className="h-5 w-5" />
|
||||
투입자재 등록
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
작업지시 {order.orderNo}에 투입할 자재를 선택하세요.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* FIFO 순위 안내 */}
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span>FIFO 순위:</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Badge className="bg-red-100 text-red-800">1</Badge> 최우선
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Badge className="bg-orange-100 text-orange-800">2</Badge> 차선
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Badge className="bg-gray-100 text-gray-800">3+</Badge> 대기
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 자재 테이블 */}
|
||||
{materials.length === 0 ? (
|
||||
<div className="py-8 text-center text-muted-foreground border rounded-lg">
|
||||
이 공정에 배정된 자재가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">선택</TableHead>
|
||||
<TableHead>자재코드</TableHead>
|
||||
<TableHead>자재명</TableHead>
|
||||
<TableHead>단위</TableHead>
|
||||
<TableHead className="text-right">현재고</TableHead>
|
||||
<TableHead>FIFO</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{materials.map((material) => (
|
||||
<TableRow key={material.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedMaterials.has(material.id)}
|
||||
onCheckedChange={() => handleToggleMaterial(material.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{material.materialCode}</TableCell>
|
||||
<TableCell>{material.materialName}</TableCell>
|
||||
<TableCell>{material.unit}</TableCell>
|
||||
<TableCell className="text-right">{material.currentStock.toLocaleString()}</TableCell>
|
||||
<TableCell>{getFifoRankBadge(material.fifoRank)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-col sm:flex-row gap-2">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
{isCompletionFlow && (
|
||||
<Button variant="secondary" onClick={handleSkip}>
|
||||
건너뛰기
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleSubmit} disabled={selectedMaterials.size === 0}>
|
||||
투입 등록
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
285
src/components/production/WorkerScreen/ProcessDetailSection.tsx
Normal file
285
src/components/production/WorkerScreen/ProcessDetailSection.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 공정상세 섹션 컴포넌트
|
||||
*
|
||||
* WorkCard 내부에서 토글 확장되는 공정 상세 정보
|
||||
* - 자재 투입 필요 섹션
|
||||
* - 공정 단계 (5단계)
|
||||
* - 각 단계별 세부 항목
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Package, CheckCircle2, Circle, AlertTriangle, MapPin, Ruler } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ProcessStep, ProcessStepItem } from './types';
|
||||
|
||||
// Mock 공정 단계 데이터
|
||||
const MOCK_PROCESS_STEPS: ProcessStep[] = [
|
||||
{
|
||||
id: 'step-1',
|
||||
stepNo: 1,
|
||||
name: '절곡판/코일 절단',
|
||||
completed: 0,
|
||||
total: 2,
|
||||
items: [
|
||||
{
|
||||
id: 'item-1-1',
|
||||
itemNo: '#1',
|
||||
location: '1층 1호-A',
|
||||
isPriority: true,
|
||||
spec: 'W2500 × H3000',
|
||||
material: '절곡판',
|
||||
lot: 'LOT-절곡-2025-001',
|
||||
},
|
||||
{
|
||||
id: 'item-1-2',
|
||||
itemNo: '#2',
|
||||
location: '1층 1호-B',
|
||||
isPriority: false,
|
||||
spec: 'W2000 × H2500',
|
||||
material: '절곡판',
|
||||
lot: 'LOT-절곡-2025-002',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'step-2',
|
||||
stepNo: 2,
|
||||
name: 'V컷팅',
|
||||
completed: 0,
|
||||
total: 2,
|
||||
items: [
|
||||
{
|
||||
id: 'item-2-1',
|
||||
itemNo: '#1',
|
||||
location: '1층 2호-A',
|
||||
isPriority: false,
|
||||
spec: 'V10 × L2500',
|
||||
material: 'V컷팅재',
|
||||
lot: 'LOT-V컷-2025-001',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'step-3',
|
||||
stepNo: 3,
|
||||
name: '절곡',
|
||||
completed: 0,
|
||||
total: 3,
|
||||
items: [
|
||||
{
|
||||
id: 'item-3-1',
|
||||
itemNo: '#1',
|
||||
location: '2층 1호',
|
||||
isPriority: true,
|
||||
spec: '90° × 2회',
|
||||
material: '절곡판',
|
||||
lot: 'LOT-절곡-2025-001',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'step-4',
|
||||
stepNo: 4,
|
||||
name: '중간검사',
|
||||
isInspection: true,
|
||||
completed: 0,
|
||||
total: 1,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'step-5',
|
||||
stepNo: 5,
|
||||
name: '포장',
|
||||
completed: 0,
|
||||
total: 1,
|
||||
items: [],
|
||||
},
|
||||
];
|
||||
|
||||
interface ProcessDetailSectionProps {
|
||||
isExpanded: boolean;
|
||||
materialRequired: boolean;
|
||||
onMaterialInput: () => void;
|
||||
}
|
||||
|
||||
export function ProcessDetailSection({
|
||||
isExpanded,
|
||||
materialRequired,
|
||||
onMaterialInput,
|
||||
}: ProcessDetailSectionProps) {
|
||||
const [steps] = useState<ProcessStep[]>(MOCK_PROCESS_STEPS);
|
||||
const [expandedSteps, setExpandedSteps] = useState<Set<string>>(new Set());
|
||||
|
||||
const totalSteps = steps.length;
|
||||
const completedSteps = steps.filter((s) => s.completed === s.total).length;
|
||||
|
||||
const toggleStep = (stepId: string) => {
|
||||
setExpandedSteps((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(stepId)) {
|
||||
next.delete(stepId);
|
||||
} else {
|
||||
next.add(stepId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (!isExpanded) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-4 pt-4 border-t space-y-4">
|
||||
{/* 자재 투입 필요 섹션 */}
|
||||
{materialRequired && (
|
||||
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-orange-500" />
|
||||
<span className="text-sm font-medium text-orange-800">
|
||||
자재 투입이 필요합니다
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onMaterialInput}
|
||||
className="border-orange-300 text-orange-700 hover:bg-orange-100"
|
||||
>
|
||||
<Package className="mr-1 h-4 w-4" />
|
||||
자재 투입하기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 공정 단계 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium">공정 단계</h4>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{completedSteps}/{totalSteps} 완료
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 공정 단계 목록 */}
|
||||
<div className="space-y-2">
|
||||
{steps.map((step) => (
|
||||
<ProcessStepCard
|
||||
key={step.id}
|
||||
step={step}
|
||||
isExpanded={expandedSteps.has(step.id)}
|
||||
onToggle={() => toggleStep(step.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 하위 컴포넌트 =====
|
||||
|
||||
interface ProcessStepCardProps {
|
||||
step: ProcessStep;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
function ProcessStepCard({ step, isExpanded, onToggle }: ProcessStepCardProps) {
|
||||
const isCompleted = step.completed === step.total;
|
||||
const hasItems = step.items.length > 0;
|
||||
|
||||
return (
|
||||
<Collapsible open={isExpanded} onOpenChange={onToggle}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between p-3 rounded-lg border cursor-pointer transition-colors',
|
||||
isCompleted
|
||||
? 'bg-green-50 border-green-200'
|
||||
: 'bg-gray-50 border-gray-200 hover:bg-gray-100'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{isCompleted ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||
) : (
|
||||
<Circle className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{step.stepNo}. {step.name}
|
||||
</span>
|
||||
{step.isInspection && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
검사
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{step.completed}/{step.total} 완료
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{hasItems && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{isExpanded ? '접기' : '펼치기'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
{hasItems && (
|
||||
<CollapsibleContent>
|
||||
<div className="ml-8 mt-2 space-y-2">
|
||||
{step.items.map((item) => (
|
||||
<ProcessStepItemCard key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
)}
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProcessStepItemCardProps {
|
||||
item: ProcessStepItem;
|
||||
}
|
||||
|
||||
function ProcessStepItemCard({ item }: ProcessStepItemCardProps) {
|
||||
return (
|
||||
<div className="p-3 bg-white border rounded-lg text-sm">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{item.itemNo}</span>
|
||||
{item.isPriority && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
선행 생산
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs font-mono">
|
||||
{item.lot}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
<span>{item.location}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Ruler className="h-3 w-3" />
|
||||
<span>{item.spec}</span>
|
||||
</div>
|
||||
<div className="col-span-2 flex items-center gap-1">
|
||||
<Package className="h-3 w-3" />
|
||||
<span>자재: {item.material}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
181
src/components/production/WorkerScreen/WorkCard.tsx
Normal file
181
src/components/production/WorkerScreen/WorkCard.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 작업 카드 컴포넌트
|
||||
*
|
||||
* 각 작업 항목을 카드 형태로 표시
|
||||
* 버튼: 전량완료, 공정상세, 자재투입, 작업일지, 이슈보고
|
||||
* 공정상세 토글 시 ProcessDetailSection 표시
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { CheckCircle, Layers, Package, FileText, AlertTriangle, ChevronDown } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { WorkOrder } from '../ProductionDashboard/types';
|
||||
import { PROCESS_LABELS, STATUS_LABELS } from '../ProductionDashboard/types';
|
||||
import { ProcessDetailSection } from './ProcessDetailSection';
|
||||
|
||||
interface WorkCardProps {
|
||||
order: WorkOrder;
|
||||
onComplete: (order: WorkOrder) => void;
|
||||
onProcessDetail: (order: WorkOrder) => void;
|
||||
onMaterialInput: (order: WorkOrder) => void;
|
||||
onWorkLog: (order: WorkOrder) => void;
|
||||
onIssueReport: (order: WorkOrder) => void;
|
||||
}
|
||||
|
||||
export function WorkCard({
|
||||
order,
|
||||
onComplete,
|
||||
onProcessDetail,
|
||||
onMaterialInput,
|
||||
onWorkLog,
|
||||
onIssueReport,
|
||||
}: WorkCardProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// 상태별 배지 스타일
|
||||
const statusBadgeStyle = {
|
||||
waiting: 'bg-gray-100 text-gray-700 border-gray-200',
|
||||
inProgress: 'bg-blue-100 text-blue-700 border-blue-200',
|
||||
completed: 'bg-green-100 text-green-700 border-green-200',
|
||||
};
|
||||
|
||||
// 납기일 포맷 (YYYY. M. D.)
|
||||
const formatDueDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return `${date.getFullYear()}. ${date.getMonth() + 1}. ${date.getDate()}.`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={`${order.isUrgent ? 'border-red-200 bg-red-50/30' : 'bg-gray-50/50'} shadow-sm`}>
|
||||
<CardContent className="p-5">
|
||||
{/* 상단: 작업번호 + 상태 + 순위 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base font-semibold text-gray-900">
|
||||
{order.orderNo}
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs font-medium rounded-full px-3 py-0.5 ${statusBadgeStyle[order.status]}`}
|
||||
>
|
||||
{STATUS_LABELS[order.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
{order.priority <= 3 && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-sm font-semibold rounded-full px-3 py-1 border-red-400 text-red-500 bg-white"
|
||||
>
|
||||
순위 {order.priority}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 제품명 + 수량 */}
|
||||
<div className="flex items-start justify-between mb-1">
|
||||
<div className="flex-1 space-y-0.5">
|
||||
<h3 className="font-semibold text-lg text-gray-900">{order.productName}</h3>
|
||||
<p className="text-sm text-gray-500">{order.client}</p>
|
||||
<p className="text-sm text-gray-500">{order.projectName}</p>
|
||||
</div>
|
||||
<div className="text-right pl-4">
|
||||
<p className="text-3xl font-bold text-gray-900">{order.quantity}</p>
|
||||
<p className="text-sm text-gray-500">EA</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공정 + 납기 */}
|
||||
<div className="flex items-center gap-3 mt-4 mb-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-sm font-medium rounded-full px-3 py-1 bg-white border-gray-300 text-gray-700"
|
||||
>
|
||||
{PROCESS_LABELS[order.process]}
|
||||
</Badge>
|
||||
<span className="text-sm text-gray-500">
|
||||
납기: {formatDueDate(order.dueDate)}
|
||||
</span>
|
||||
{order.isDelayed && order.delayDays && (
|
||||
<span className="text-sm text-orange-600 font-medium">
|
||||
+{order.delayDays}일 지연
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 지시사항 */}
|
||||
{order.instruction && (
|
||||
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg text-sm text-yellow-800">
|
||||
{order.instruction}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 버튼 영역 - 첫 번째 줄 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onComplete(order)}
|
||||
className="bg-green-500 hover:bg-green-600 text-white rounded-full px-4 h-9"
|
||||
>
|
||||
<CheckCircle className="mr-1.5 h-4 w-4" />
|
||||
전량완료
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsExpanded(!isExpanded);
|
||||
onProcessDetail(order);
|
||||
}}
|
||||
className="rounded-full px-4 h-9 border-gray-300"
|
||||
>
|
||||
<Layers className="mr-1.5 h-4 w-4" />
|
||||
공정상세
|
||||
<ChevronDown className={`ml-1 h-4 w-4 transition-transform ${isExpanded ? 'rotate-180' : ''}`} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onMaterialInput(order)}
|
||||
className="rounded-full px-4 h-9 border-gray-300"
|
||||
>
|
||||
<Package className="mr-1.5 h-4 w-4" />
|
||||
자재투입
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onWorkLog(order)}
|
||||
className="rounded-full px-4 h-9 border-gray-300"
|
||||
>
|
||||
<FileText className="mr-1.5 h-4 w-4" />
|
||||
작업일지
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 버튼 영역 - 두 번째 줄 (이슈보고) */}
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onIssueReport(order)}
|
||||
className="rounded-full px-4 h-9 border-orange-300 text-orange-500 hover:bg-orange-50 hover:text-orange-600"
|
||||
>
|
||||
<AlertTriangle className="mr-1.5 h-4 w-4" />
|
||||
이슈보고
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 공정상세 섹션 (토글) */}
|
||||
<ProcessDetailSection
|
||||
isExpanded={isExpanded}
|
||||
materialRequired={true}
|
||||
onMaterialInput={() => onMaterialInput(order)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 작업 완료 결과 다이얼로그
|
||||
*
|
||||
* 스크린샷 기준:
|
||||
* - ✅ 작업이 완료되었습니다
|
||||
* - 🔲 제품검사LOT: KD-SA-251223-01
|
||||
* - ✅ 제품검사(FQC)가 자동 생성되었습니다.
|
||||
* - [품질관리 > 제품검사]에서 검사를 진행하세요.
|
||||
*/
|
||||
|
||||
import { CheckSquare, Square } from 'lucide-react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
interface WorkCompletionResultDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
lotNo: string;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function WorkCompletionResultDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
lotNo,
|
||||
onConfirm,
|
||||
}: WorkCompletionResultDialogProps) {
|
||||
const handleConfirm = () => {
|
||||
onConfirm();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent className="bg-zinc-900 text-white border-zinc-700">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="sr-only">작업 완료</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-3 text-white">
|
||||
{/* ✅ 작업이 완료되었습니다 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckSquare className="h-5 w-5 text-green-500 fill-green-500" />
|
||||
<span className="text-base">작업이 완료되었습니다.</span>
|
||||
</div>
|
||||
|
||||
{/* 🔲 제품검사LOT */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Square className="h-5 w-5 text-zinc-500" />
|
||||
<span className="text-base text-zinc-400">제품검사LOT: {lotNo}</span>
|
||||
</div>
|
||||
|
||||
{/* ✅ 제품검사(FQC)가 자동 생성되었습니다 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckSquare className="h-5 w-5 text-green-500 fill-green-500" />
|
||||
<span className="text-base">제품검사(FQC)가 자동 생성되었습니다.</span>
|
||||
</div>
|
||||
|
||||
{/* 안내 메시지 */}
|
||||
<p className="text-sm text-zinc-400 pl-7">
|
||||
[품질관리 > 제품검사]에서 검사를 진행하세요.
|
||||
</p>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="flex justify-center sm:justify-center">
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirm}
|
||||
className="bg-pink-200 hover:bg-pink-300 text-zinc-900 font-medium px-8"
|
||||
>
|
||||
확인
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
272
src/components/production/WorkerScreen/WorkLogModal.tsx
Normal file
272
src/components/production/WorkerScreen/WorkLogModal.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 작업일지 모달
|
||||
*
|
||||
* - 헤더: sam-design 작업일지 스타일
|
||||
* - 내부 문서: 스크린샷 기준 작업일지 양식
|
||||
*/
|
||||
|
||||
import { Printer, X } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { WorkOrder } from '../ProductionDashboard/types';
|
||||
import { PROCESS_LABELS } from '../ProductionDashboard/types';
|
||||
|
||||
interface WorkLogModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
order: WorkOrder | null;
|
||||
}
|
||||
|
||||
export function WorkLogModal({ open, onOpenChange, order }: WorkLogModalProps) {
|
||||
const handlePrint = () => {
|
||||
window.print();
|
||||
};
|
||||
|
||||
if (!order) return null;
|
||||
|
||||
const today = new Date().toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).replace(/\. /g, '-').replace('.', '');
|
||||
|
||||
const documentNo = `WL-${order.process.toUpperCase().slice(0, 3)}`;
|
||||
const lotNo = `KD-TS-${new Date().toISOString().slice(2, 10).replace(/-/g, '')}-01`;
|
||||
|
||||
// 샘플 품목 데이터 (스크린샷 기준)
|
||||
const items = [
|
||||
{ no: 1, name: '스크린 사타 (표준형)', location: '1층/A-01', spec: '3000×2500', qty: 1, status: '대기' },
|
||||
{ no: 2, name: '스크린 사타 (표준형)', location: '2층/A-02', spec: '3000×2500', qty: 1, status: '대기' },
|
||||
{ no: 3, name: '스크린 사타 (표준형)', location: '3층/A-03', spec: '-', qty: '-', status: '대기' },
|
||||
];
|
||||
|
||||
// 작업내역 데이터 (스크린샷 기준)
|
||||
const workStats = {
|
||||
workType: '필름 스크린',
|
||||
workWidth: '1016mm',
|
||||
general: 3,
|
||||
ironing: 3,
|
||||
sandblast: 3,
|
||||
packing: 1,
|
||||
orderQty: 3,
|
||||
completedQty: 1,
|
||||
progress: 33,
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-5xl max-h-[90vh] overflow-y-auto p-0 gap-0 bg-gray-100">
|
||||
{/* 접근성을 위한 숨겨진 타이틀 */}
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>작업일지 - {order.orderNo}</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
{/* 모달 헤더 - sam-design 스타일 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b bg-white sticky top-0 z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-semibold text-lg">작업일지</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{PROCESS_LABELS[order.process]} 생산부서
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({documentNo})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handlePrint}>
|
||||
<Printer className="w-4 h-4 mr-1.5" />
|
||||
인쇄
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 문서 본문 */}
|
||||
<div className="m-6 p-6 bg-white rounded-lg shadow-sm">
|
||||
{/* 문서 헤더: 로고 + 제목 + 결재라인 */}
|
||||
<div className="flex justify-between items-start mb-6 border border-gray-300">
|
||||
{/* 좌측: 로고 영역 */}
|
||||
<div className="w-24 border-r border-gray-300 flex flex-col items-center justify-center p-3">
|
||||
<span className="text-2xl font-bold">KD</span>
|
||||
<span className="text-xs text-gray-500">정동기업</span>
|
||||
</div>
|
||||
|
||||
{/* 중앙: 문서 제목 */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-3 border-r border-gray-300">
|
||||
<h1 className="text-xl font-bold tracking-widest mb-1">작 업 일 지</h1>
|
||||
<p className="text-xs text-gray-500">{documentNo}</p>
|
||||
<p className="text-sm font-medium mt-1">{PROCESS_LABELS[order.process]} 생산부서</p>
|
||||
</div>
|
||||
|
||||
{/* 우측: 결재라인 */}
|
||||
<div className="shrink-0 text-xs">
|
||||
{/* 첫 번째 행: 작성/검토/승인 */}
|
||||
<div className="grid grid-cols-3 border-b border-gray-300">
|
||||
<div className="w-16 p-2 text-center font-medium bg-gray-100 border-r border-gray-300">작성</div>
|
||||
<div className="w-16 p-2 text-center font-medium bg-gray-100 border-r border-gray-300">검토</div>
|
||||
<div className="w-16 p-2 text-center font-medium bg-gray-100">승인</div>
|
||||
</div>
|
||||
{/* 두 번째 행: 이름 */}
|
||||
<div className="grid grid-cols-3 border-b border-gray-300">
|
||||
<div className="w-16 p-2 text-center border-r border-gray-300">{order.assignees[0] || '-'}</div>
|
||||
<div className="w-16 p-2 text-center border-r border-gray-300"></div>
|
||||
<div className="w-16 p-2 text-center"></div>
|
||||
</div>
|
||||
{/* 세 번째 행: 부서 */}
|
||||
<div className="grid grid-cols-3">
|
||||
<div className="w-16 p-2 text-center bg-gray-50 border-r border-gray-300">판매</div>
|
||||
<div className="w-16 p-2 text-center bg-gray-50 border-r border-gray-300">생산</div>
|
||||
<div className="w-16 p-2 text-center bg-gray-50">품질</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 테이블 */}
|
||||
<div className="border border-gray-300 mb-6">
|
||||
{/* Row 1 */}
|
||||
<div className="grid grid-cols-2 border-b border-gray-300">
|
||||
<div className="flex border-r border-gray-300">
|
||||
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300 flex items-center">
|
||||
발주처
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm flex items-center">{order.client}</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300 flex items-center">
|
||||
현장명
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm flex items-center">{order.projectName}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2 */}
|
||||
<div className="grid grid-cols-2 border-b border-gray-300">
|
||||
<div className="flex border-r border-gray-300">
|
||||
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300 flex items-center">
|
||||
작업일자
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm flex items-center">{today}</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300 flex items-center">
|
||||
LOT NO.
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm flex items-center">{lotNo}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3 */}
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="flex border-r border-gray-300">
|
||||
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300 flex items-center">
|
||||
납기일
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm flex items-center">
|
||||
{new Date(order.dueDate).toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).replace(/\. /g, '-').replace('.', '')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300 flex items-center">
|
||||
규격
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm flex items-center">W- x H-</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품목 테이블 */}
|
||||
<div className="border border-gray-300 mb-6">
|
||||
{/* 테이블 헤더 */}
|
||||
<div className="grid grid-cols-12 border-b border-gray-300 bg-gray-100">
|
||||
<div className="col-span-1 p-2 text-sm font-medium text-center border-r border-gray-300">No</div>
|
||||
<div className="col-span-4 p-2 text-sm font-medium text-center border-r border-gray-300">품목명</div>
|
||||
<div className="col-span-2 p-2 text-sm font-medium text-center border-r border-gray-300">출/부호</div>
|
||||
<div className="col-span-2 p-2 text-sm font-medium text-center border-r border-gray-300">규격</div>
|
||||
<div className="col-span-1 p-2 text-sm font-medium text-center border-r border-gray-300">수량</div>
|
||||
<div className="col-span-2 p-2 text-sm font-medium text-center">상태</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 데이터 */}
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={item.no}
|
||||
className={`grid grid-cols-12 ${index < items.length - 1 ? 'border-b border-gray-300' : ''}`}
|
||||
>
|
||||
<div className="col-span-1 p-2 text-sm text-center border-r border-gray-300">{item.no}</div>
|
||||
<div className="col-span-4 p-2 text-sm border-r border-gray-300">{item.name}</div>
|
||||
<div className="col-span-2 p-2 text-sm text-center border-r border-gray-300">{item.location}</div>
|
||||
<div className="col-span-2 p-2 text-sm text-center border-r border-gray-300">{item.spec}</div>
|
||||
<div className="col-span-1 p-2 text-sm text-center border-r border-gray-300">{item.qty}</div>
|
||||
<div className="col-span-2 p-2 text-sm text-center">{item.status}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 작업내역 */}
|
||||
<div className="border border-gray-300 mb-6">
|
||||
{/* 검정 헤더 */}
|
||||
<div className="bg-gray-800 text-white p-2.5 text-sm font-medium text-center">
|
||||
{PROCESS_LABELS[order.process]} 작업내역
|
||||
</div>
|
||||
|
||||
{/* 작업내역 그리드 */}
|
||||
<div className="grid grid-cols-4 border-b border-gray-300">
|
||||
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium">화단 유형</div>
|
||||
<div className="p-2 text-sm border-r border-gray-300 text-center">{workStats.workType}</div>
|
||||
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium">화단 폭</div>
|
||||
<div className="p-2 text-sm text-center">{workStats.workWidth}</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 border-b border-gray-300">
|
||||
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium">화단일반</div>
|
||||
<div className="p-2 text-sm border-r border-gray-300 text-center">{workStats.general} EA</div>
|
||||
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium">이싱</div>
|
||||
<div className="p-2 text-sm text-center">{workStats.ironing} EA</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 border-b border-gray-300">
|
||||
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium">센드락 작업</div>
|
||||
<div className="p-2 text-sm border-r border-gray-300 text-center">{workStats.sandblast} EA</div>
|
||||
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium">포장</div>
|
||||
<div className="p-2 text-sm text-center">{workStats.packing} EA</div>
|
||||
</div>
|
||||
{/* 수량 및 진행률 */}
|
||||
<div className="grid grid-cols-6">
|
||||
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium">지시수량</div>
|
||||
<div className="p-2 text-sm border-r border-gray-300 text-center">{workStats.orderQty} EA</div>
|
||||
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium">완료수량</div>
|
||||
<div className="p-2 text-sm border-r border-gray-300 text-center">{workStats.completedQty} EA</div>
|
||||
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium">진행률</div>
|
||||
<div className="p-2 text-sm text-center font-medium text-blue-600">{workStats.progress}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 특이 사항 */}
|
||||
<div className="border border-gray-300">
|
||||
<div className="bg-gray-800 text-white p-2.5 text-sm font-medium text-center">
|
||||
특이사항
|
||||
</div>
|
||||
<div className="p-4 min-h-[60px] text-sm">
|
||||
{order.instruction || '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
325
src/components/production/WorkerScreen/index.tsx
Normal file
325
src/components/production/WorkerScreen/index.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 작업자 화면 메인 컴포넌트
|
||||
*
|
||||
* 기능:
|
||||
* - 상단 통계 카드 4개 (할당/작업중/완료/긴급)
|
||||
* - 내 작업 목록 카드 리스트
|
||||
* - 각 작업 카드별 버튼 (전량완료/공정상세/자재투입/작업일지/이슈보고)
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { ClipboardList, PlayCircle, CheckCircle2, AlertTriangle } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { generateMockWorkOrders } from '../ProductionDashboard/mockData';
|
||||
import type { WorkOrder } from '../ProductionDashboard/types';
|
||||
import type { WorkerStats, CompletionToastInfo, MaterialInput } from './types';
|
||||
import { WorkCard } from './WorkCard';
|
||||
import { CompletionConfirmDialog } from './CompletionConfirmDialog';
|
||||
import { CompletionToast } from './CompletionToast';
|
||||
import { MaterialInputModal } from './MaterialInputModal';
|
||||
import { WorkLogModal } from './WorkLogModal';
|
||||
import { IssueReportModal } from './IssueReportModal';
|
||||
import { WorkCompletionResultDialog } from './WorkCompletionResultDialog';
|
||||
|
||||
export default function WorkerScreen() {
|
||||
// ===== 상태 관리 =====
|
||||
const [workOrders, setWorkOrders] = useState<WorkOrder[]>(() =>
|
||||
generateMockWorkOrders().filter((o) => o.status !== 'completed')
|
||||
);
|
||||
|
||||
// 모달/다이얼로그 상태
|
||||
const [selectedOrder, setSelectedOrder] = useState<WorkOrder | null>(null);
|
||||
const [isCompletionDialogOpen, setIsCompletionDialogOpen] = useState(false);
|
||||
const [isMaterialModalOpen, setIsMaterialModalOpen] = useState(false);
|
||||
const [isWorkLogModalOpen, setIsWorkLogModalOpen] = useState(false);
|
||||
const [isIssueReportModalOpen, setIsIssueReportModalOpen] = useState(false);
|
||||
|
||||
// 전량완료 흐름 상태
|
||||
const [isCompletionFlow, setIsCompletionFlow] = useState(false);
|
||||
const [isCompletionResultOpen, setIsCompletionResultOpen] = useState(false);
|
||||
const [completionLotNo, setCompletionLotNo] = useState('');
|
||||
|
||||
// 투입된 자재 관리 (orderId -> MaterialInput[])
|
||||
const [inputMaterialsMap, setInputMaterialsMap] = useState<Map<string, MaterialInput[]>>(
|
||||
new Map()
|
||||
);
|
||||
|
||||
// 완료 토스트 상태
|
||||
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);
|
||||
}
|
||||
});
|
||||
}, [workOrders, sortBy]);
|
||||
|
||||
// ===== 핸들러 =====
|
||||
|
||||
// 전량완료 버튼 클릭
|
||||
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);
|
||||
} else {
|
||||
// 자재 투입이 필요합니다 팝업
|
||||
setIsCompletionDialogOpen(true);
|
||||
}
|
||||
},
|
||||
[inputMaterialsMap]
|
||||
);
|
||||
|
||||
// "자재 투입이 필요합니다" 팝업에서 확인 클릭 → MaterialInputModal 열기
|
||||
const handleCompletionConfirm = useCallback(() => {
|
||||
setIsCompletionFlow(true);
|
||||
setIsMaterialModalOpen(true);
|
||||
}, []);
|
||||
|
||||
// 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 handleSaveMaterials = useCallback((orderId: string, materials: MaterialInput[]) => {
|
||||
setInputMaterialsMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(orderId, materials);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 완료 결과 팝업에서 확인 → 목록에서 제거
|
||||
const handleCompletionResultConfirm = useCallback(() => {
|
||||
if (!selectedOrder) return;
|
||||
|
||||
// 투입된 자재 맵에서도 제거
|
||||
setInputMaterialsMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(selectedOrder.id);
|
||||
return next;
|
||||
});
|
||||
|
||||
// 목록에서 제거
|
||||
setWorkOrders((prev) => prev.filter((o) => o.id !== selectedOrder.id));
|
||||
setSelectedOrder(null);
|
||||
setCompletionLotNo('');
|
||||
}, [selectedOrder]);
|
||||
|
||||
const handleProcessDetail = useCallback((order: WorkOrder) => {
|
||||
setSelectedOrder(order);
|
||||
// 공정상세는 카드 내 토글로 처리 (Phase 4에서 구현)
|
||||
console.log('[공정상세] 토글:', order.orderNo);
|
||||
}, []);
|
||||
|
||||
const handleMaterialInput = useCallback((order: WorkOrder) => {
|
||||
setSelectedOrder(order);
|
||||
setIsMaterialModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleWorkLog = useCallback((order: WorkOrder) => {
|
||||
setSelectedOrder(order);
|
||||
setIsWorkLogModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleIssueReport = useCallback((order: WorkOrder) => {
|
||||
setSelectedOrder(order);
|
||||
setIsIssueReportModalOpen(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="space-y-6">
|
||||
{/* 완료 토스트 */}
|
||||
{toastInfo && <CompletionToast info={toastInfo} />}
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<ClipboardList className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">작업자 화면</h1>
|
||||
<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>
|
||||
|
||||
{/* 작업 목록 */}
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모달/다이얼로그 */}
|
||||
<CompletionConfirmDialog
|
||||
open={isCompletionDialogOpen}
|
||||
onOpenChange={setIsCompletionDialogOpen}
|
||||
order={selectedOrder}
|
||||
onConfirm={handleCompletionConfirm}
|
||||
/>
|
||||
|
||||
<MaterialInputModal
|
||||
open={isMaterialModalOpen}
|
||||
onOpenChange={setIsMaterialModalOpen}
|
||||
order={selectedOrder}
|
||||
isCompletionFlow={isCompletionFlow}
|
||||
onComplete={handleWorkCompletion}
|
||||
onSaveMaterials={handleSaveMaterials}
|
||||
savedMaterials={selectedOrder ? inputMaterialsMap.get(selectedOrder.id) : undefined}
|
||||
/>
|
||||
|
||||
<WorkLogModal
|
||||
open={isWorkLogModalOpen}
|
||||
onOpenChange={setIsWorkLogModalOpen}
|
||||
order={selectedOrder}
|
||||
/>
|
||||
|
||||
<IssueReportModal
|
||||
open={isIssueReportModalOpen}
|
||||
onOpenChange={setIsIssueReportModalOpen}
|
||||
order={selectedOrder}
|
||||
/>
|
||||
|
||||
<WorkCompletionResultDialog
|
||||
open={isCompletionResultOpen}
|
||||
onOpenChange={setIsCompletionResultOpen}
|
||||
lotNo={completionLotNo}
|
||||
onConfirm={handleCompletionResultConfirm}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 하위 컴포넌트 =====
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: number;
|
||||
icon: React.ReactNode;
|
||||
variant: 'default' | 'blue' | 'green' | 'red';
|
||||
}
|
||||
|
||||
function StatCard({ title, value, icon, variant }: StatCardProps) {
|
||||
const variantClasses = {
|
||||
default: 'bg-gray-50 text-gray-700',
|
||||
blue: 'bg-blue-50 text-blue-700',
|
||||
green: 'bg-green-50 text-green-700',
|
||||
red: 'bg-red-50 text-red-700',
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={variantClasses[variant]}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
{icon}
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-2">{value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
74
src/components/production/WorkerScreen/types.ts
Normal file
74
src/components/production/WorkerScreen/types.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
// 작업자 화면 타입 정의
|
||||
|
||||
import type { WorkOrder, ProcessType } from '../ProductionDashboard/types';
|
||||
|
||||
// 작업자 작업 아이템 (WorkOrder 확장)
|
||||
export interface WorkerWorkItem extends WorkOrder {
|
||||
processDetail?: ProcessDetail;
|
||||
}
|
||||
|
||||
// 공정상세 정보
|
||||
export interface ProcessDetail {
|
||||
materialRequired: boolean;
|
||||
steps: ProcessStep[];
|
||||
totalSteps: number;
|
||||
completedSteps: number;
|
||||
}
|
||||
|
||||
// 공정 단계
|
||||
export interface ProcessStep {
|
||||
id: string;
|
||||
stepNo: number;
|
||||
name: string;
|
||||
isInspection?: boolean;
|
||||
completed: number;
|
||||
total: number;
|
||||
items: ProcessStepItem[];
|
||||
}
|
||||
|
||||
// 공정 단계 상세 항목
|
||||
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+: 대기)
|
||||
}
|
||||
|
||||
// 이슈 유형
|
||||
export type IssueType = 'defect' | 'noStock' | 'delay' | 'equipment' | 'other';
|
||||
|
||||
export const ISSUE_TYPE_LABELS: Record<IssueType, string> = {
|
||||
defect: '불량품 발생',
|
||||
noStock: '재고 없음',
|
||||
delay: '일정 지연',
|
||||
equipment: '설비 문제',
|
||||
other: '기타',
|
||||
};
|
||||
|
||||
// 작업자 화면 통계
|
||||
export interface WorkerStats {
|
||||
assigned: number;
|
||||
inProgress: number;
|
||||
completed: number;
|
||||
urgent: number;
|
||||
}
|
||||
|
||||
// 완료 토스트 정보
|
||||
export interface CompletionToastInfo {
|
||||
orderNo: string;
|
||||
quantity: number;
|
||||
lotNo: string;
|
||||
}
|
||||
Reference in New Issue
Block a user