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:
byeongcheolryu
2025-12-23 21:13:07 +09:00
parent 2ebcea0255
commit f0e8e51d06
108 changed files with 21156 additions and 84 deletions

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

View 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 },
];
};

View 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',
};

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

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

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

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

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

View 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';

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

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

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

View File

@@ -0,0 +1,6 @@
/**
* 작업실적 조회 컴포넌트 exports
*/
export { WorkResultList } from './WorkResultList';
export type { WorkResult, WorkResultStats } from './types';

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

View 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; // 불량률 (%)
}

View File

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

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

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

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

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

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

View File

@@ -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">
[ &gt; ] .
</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>
);
}

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

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

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