feat: 견적확정 밸리데이션, 수주등록 개소그룹, 작업지시 개선

- 견적확정 시 업체명/현장명/담당자/연락처 프론트 밸리데이션 추가
- 견적확정 후 수주등록 버튼 동적 전환
- 수주등록 품목 개소별(floor+code) 그룹핑 수정
- 작업지시 상세 quantity 문자열→숫자 변환 (formatQuantity)
- 작업지시 탭 카운트 초기 로딩 시 전체 표시 (by_process 활용)
- 작업지시 상세 개소별/품목별 합산 테이블 추가
- 작업자 화면 API 연동 및 목업 데이터 분리
- 입고관리 완료건 수정, 재고현황 개선
This commit is contained in:
2026-02-07 03:27:23 +09:00
parent b2085a84ca
commit a8591c438e
29 changed files with 3238 additions and 700 deletions

View File

@@ -232,8 +232,8 @@ export function MaterialInputModal({
</TableRow>
</TableHeader>
<TableBody>
{materials.map((material) => (
<TableRow key={material.id}>
{materials.map((material, index) => (
<TableRow key={`mat-${material.id}-${index}`}>
<TableCell className="text-center text-sm">
{material.materialCode}
</TableCell>

View File

@@ -76,7 +76,7 @@ export function WorkItemCard({
{item.itemNo}
</span>
<span className="text-base font-bold text-gray-900">
{item.itemCode} ({item.itemName})
{item.itemCode} - {item.itemName}
</span>
</div>
{!item.isWip && (

View File

@@ -0,0 +1,131 @@
'use client';
/**
* 작업지시서 리스트 패널 (좌측)
*
* 마스터-디테일 레이아웃의 좌측 패널.
* 공정별 필터링된 작업지시서 목록을 표시하고 선택 기능 제공.
*/
import { cn } from '@/lib/utils';
import { AlertTriangle, Package } from 'lucide-react';
import type { WorkOrder } from '../ProductionDashboard/types';
interface WorkOrderListPanelProps {
workOrders: WorkOrder[];
selectedId: string | null;
onSelect: (id: string) => void;
isLoading: boolean;
}
export function WorkOrderListPanel({
workOrders,
selectedId,
onSelect,
isLoading,
}: WorkOrderListPanelProps) {
if (isLoading) {
return (
<div className="p-2 space-y-1.5">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-[84px] rounded-lg bg-gray-100 animate-pulse" />
))}
</div>
);
}
if (workOrders.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground p-6">
<Package className="h-10 w-10 mb-2 opacity-40" />
<p className="text-sm"> .</p>
</div>
);
}
return (
<div className="overflow-y-auto h-full p-2 space-y-1.5">
{workOrders.map((order, index) => (
<button
key={order.id}
onClick={() => onSelect(order.id)}
className={cn(
'w-full text-left p-3 rounded-lg border transition-all duration-150',
selectedId === order.id
? 'bg-primary/5 border-primary/30 shadow-sm'
: 'bg-white border-gray-100 hover:bg-gray-50 hover:border-gray-200'
)}
>
{/* 헤더: 번호 + 작업지시번호 + 상태 */}
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2 min-w-0">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-emerald-500 text-white text-xs font-bold shrink-0">
{index + 1}
</span>
<span className="text-sm font-semibold text-gray-900 truncate">
{order.orderNo}
</span>
</div>
<div className="flex items-center gap-1.5 shrink-0 ml-2">
{order.isUrgent && (
<AlertTriangle className="h-3.5 w-3.5 text-red-500" />
)}
<StatusBadge status={order.status} />
</div>
</div>
{/* 품목명 */}
<p className="text-xs text-gray-600 truncate ml-8">{order.productName}</p>
{/* 현장명 + 수량 */}
<div className="flex items-center justify-between mt-1.5 ml-8">
<span className="text-xs text-gray-400 truncate">
{order.projectName || order.client}
</span>
<span className="text-xs font-medium text-gray-500 shrink-0">
{order.quantity}EA
</span>
</div>
{/* 납기일 */}
{order.dueDate && (
<div className="mt-1 ml-8">
<span
className={cn(
'text-[11px]',
order.isDelayed
? 'text-red-500 font-medium'
: 'text-gray-400'
)}
>
: {new Date(order.dueDate).toLocaleDateString('ko-KR')}
{order.isDelayed &&
order.delayDays &&
` (${order.delayDays}일 지연)`}
</span>
</div>
)}
</button>
))}
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const config: Record<string, { label: string; className: string }> = {
waiting: { label: '대기', className: 'bg-gray-100 text-gray-600' },
inProgress: { label: '작업중', className: 'bg-blue-100 text-blue-700' },
completed: { label: '완료', className: 'bg-green-100 text-green-700' },
};
const { label, className } = config[status] || config.waiting;
return (
<span
className={cn(
'text-[10px] font-medium px-1.5 py-0.5 rounded',
className
)}
>
{label}
</span>
);
}

View File

@@ -1,6 +1,6 @@
/**
* 작업자 화면 서버 액션
* API 연동 완료 (2025-12-26)
* API 연동 (2025-12-26 초기, 2026-02-06 options + step-progress 확장)
*
* WorkOrders API를 호출하고 WorkerScreen에 맞는 형식으로 변환
*/
@@ -11,13 +11,22 @@
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { serverFetch } from '@/lib/api/fetch-wrapper';
import type { WorkOrder, WorkOrderStatus } from '../ProductionDashboard/types';
import type { WorkItemData, WorkStepData, ProcessTab } from './types';
// ===== API 타입 =====
interface WorkOrderApiItem {
id: number;
work_order_no: string;
project_name: string | null;
process_type: 'screen' | 'slat' | 'bending';
process_id: number | null;
process?: {
id: number;
process_name: string;
process_code: string;
department?: string | null;
};
/** @deprecated process_id + process relation 사용 */
process_type?: 'screen' | 'slat' | 'bending';
status: 'unassigned' | 'pending' | 'waiting' | 'in_progress' | 'completed' | 'shipped';
scheduled_date: string | null;
memo: string | null;
@@ -26,9 +35,21 @@ interface WorkOrderApiItem {
id: number;
order_no: string;
client?: { id: number; name: string };
root_nodes_count?: number;
};
assignee?: { id: number; name: string };
items?: { id: number; item_name: string; quantity: number }[];
items?: {
id: number;
item_name: string;
quantity: number;
specification?: string | null;
options?: Record<string, unknown> | null;
source_order_item?: {
id: number;
order_node_id: number | null;
node?: { id: number; name: string; code: string } | null;
} | null;
}[];
}
// ===== 상태 변환 =====
@@ -46,7 +67,7 @@ function mapApiStatus(status: WorkOrderApiItem['status']): WorkOrderStatus {
// ===== API → WorkOrder 변환 =====
function transformToWorkerScreenFormat(api: WorkOrderApiItem): WorkOrder {
const totalQuantity = (api.items || []).reduce((sum, item) => sum + item.quantity, 0);
const totalQuantity = (api.items || []).reduce((sum, item) => sum + Number(item.quantity), 0);
const productName = api.items?.[0]?.item_name || '-';
// 납기일 계산 (지연 여부)
@@ -59,13 +80,62 @@ function transformToWorkerScreenFormat(api: WorkOrderApiItem): WorkOrder {
? Math.ceil((today.getTime() - due.getTime()) / (1000 * 60 * 60 * 24))
: undefined;
// process_type → processCode/processName 매핑
const processTypeMap: Record<string, { code: string; name: string }> = {
screen: { code: 'screen', name: '스크린' },
slat: { code: 'slat', name: '슬랫' },
bending: { code: 'bending', name: '절곡' },
// process relation → processCode/processName 매핑
// 신규: process relation 사용, 폴백: deprecated process_type 필드
const rawProcessName = api.process?.process_name || '';
const rawProcessCode = api.process?.process_code || '';
// 한글 공정명 → 탭 키 매핑 (필터링에 사용)
const nameToTab: Record<string, { code: string; name: string }> = {
'스크린': { code: 'screen', name: '스크린' },
'슬랫': { code: 'slat', name: '슬랫' },
'절곡': { code: 'bending', name: '절곡' },
};
const processInfo = processTypeMap[api.process_type] || { code: api.process_type, name: api.process_type };
// 1차: process relation의 process_name으로 매핑
let processInfo = Object.entries(nameToTab).find(
([key]) => rawProcessName.includes(key)
)?.[1];
// 2차: deprecated process_type 폴백
if (!processInfo && api.process_type) {
const legacyMap: Record<string, { code: string; name: string }> = {
screen: { code: 'screen', name: '스크린' },
slat: { code: 'slat', name: '슬랫' },
bending: { code: 'bending', name: '절곡' },
};
processInfo = legacyMap[api.process_type];
}
// 3차: 그래도 없으면 원시값 사용
if (!processInfo) {
processInfo = { code: rawProcessCode || 'unknown', name: rawProcessName || '알수없음' };
}
// 아이템을 개소(node)별로 그룹핑
const nodeMap = new Map<string, { nodeId: number | null; nodeName: string; items: typeof api.items }>();
for (const item of (api.items || [])) {
const nodeId = item.source_order_item?.order_node_id ?? null;
const nodeName = item.source_order_item?.node?.name || '미지정';
const key = nodeId != null ? String(nodeId) : 'unassigned';
if (!nodeMap.has(key)) {
nodeMap.set(key, { nodeId, nodeName, items: [] });
}
nodeMap.get(key)!.items!.push(item);
}
const nodeGroups = Array.from(nodeMap.values()).map((g) => ({
nodeId: g.nodeId,
nodeName: g.nodeName,
items: (g.items || []).map((it) => ({
id: it.id,
itemName: it.item_name,
quantity: Number(it.quantity),
specification: it.specification,
options: it.options,
})),
totalQuantity: (g.items || []).reduce((sum, it) => sum + Number(it.quantity), 0),
}));
return {
id: String(api.id),
@@ -77,6 +147,7 @@ function transformToWorkerScreenFormat(api: WorkOrderApiItem): WorkOrder {
projectName: api.project_name || '-',
assignees: api.assignee ? [api.assignee.name] : [],
quantity: totalQuantity,
shutterCount: api.sales_order?.root_nodes_count || 0,
dueDate,
priority: 5, // 기본 우선순위
status: mapApiStatus(api.status),
@@ -84,7 +155,9 @@ function transformToWorkerScreenFormat(api: WorkOrderApiItem): WorkOrder {
isDelayed,
delayDays,
instruction: api.memo || undefined,
salesOrderNo: api.sales_order?.order_no || undefined,
createdAt: api.created_at,
nodeGroups,
};
}
@@ -95,8 +168,8 @@ export async function getMyWorkOrders(): Promise<{
error?: string;
}> {
try {
// 작업 대기 + 작업중 상태만 조회 (완료 제외)
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders?per_page=100&assigned_to_me=1`;
// 작업자 화면용 캐스케이드 필터: 개인 배정 → 부서 → 전체
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders?per_page=100&worker_screen=1`;
console.log('[WorkerScreenActions] GET my work orders:', url);
@@ -530,4 +603,216 @@ export async function requestInspection(
error: '서버 오류가 발생했습니다.',
};
}
}
// ===== 공정 단계 진행 현황 조회 =====
export interface StepProgressItem {
id: number;
process_step_id: number;
step_code: string;
step_name: string;
sort_order: number;
needs_inspection: boolean;
connection_type: string | null;
completion_type: string | null;
status: string;
is_completed: boolean;
completed_at: string | null;
completed_by: number | null;
}
export async function getStepProgress(
workOrderId: string
): Promise<{
success: boolean;
data: StepProgressItem[];
error?: string;
}> {
try {
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${workOrderId}/step-progress`;
const { response, error } = await serverFetch(url, { method: 'GET' });
if (error || !response) {
return { success: false, data: [], error: error?.message || '네트워크 오류' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, data: [], error: result.message || '단계 진행 조회 실패' };
}
return { success: true, data: result.data || [] };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[WorkerScreenActions] getStepProgress error:', error);
return { success: false, data: [], error: '서버 오류' };
}
}
// ===== 공정 단계 완료 토글 =====
export async function toggleStepProgress(
workOrderId: string,
progressId: number
): Promise<{ success: boolean; data?: StepProgressItem; error?: string }> {
try {
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${workOrderId}/step-progress/${progressId}/toggle`;
const { response, error } = await serverFetch(url, { method: 'PATCH' });
if (error || !response) {
return { success: false, error: error?.message || '네트워크 오류' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '단계 토글 실패' };
}
return { success: true, data: result.data };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[WorkerScreenActions] toggleStepProgress error:', error);
return { success: false, error: '서버 오류' };
}
}
// ===== 작업지시 상세 조회 (items + options 포함) =====
export async function getWorkOrderDetail(
workOrderId: string
): Promise<{
success: boolean;
data: WorkItemData[];
error?: string;
}> {
try {
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${workOrderId}`;
const { response, error } = await serverFetch(url, { method: 'GET' });
if (error || !response) {
return { success: false, data: [], error: error?.message || '네트워크 오류' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, data: [], error: result.message || '상세 조회 실패' };
}
const wo = result.data;
const processName = (wo.process?.process_name || '').toLowerCase();
const processType: ProcessTab =
processName.includes('스크린') ? 'screen' :
processName.includes('슬랫') ? 'slat' :
processName.includes('절곡') ? 'bending' : 'screen';
// items → WorkItemData 변환 (options 파싱)
const items: WorkItemData[] = (wo.items || []).map((item: {
id: number;
item_name: string;
specification: string | null;
quantity: string;
unit: string | null;
status: string;
sort_order: number;
options: Record<string, unknown> | null;
}, index: number) => {
const opts = item.options || {};
// steps: stepProgress에서 가져오거나 process.steps에서 생성
const stepProgressList = wo.step_progress || [];
const processSteps = wo.process?.steps || [];
let steps: WorkStepData[];
if (stepProgressList.length > 0) {
steps = stepProgressList
.filter((sp: { work_order_item_id: number | null }) => !sp.work_order_item_id || sp.work_order_item_id === item.id)
.map((sp: { id: number; process_step: { step_name: string; step_code: string } | null; status: string }) => ({
id: String(sp.id),
name: sp.process_step?.step_name || '',
isMaterialInput: (sp.process_step?.step_code || '').includes('MAT') || (sp.process_step?.step_name || '').includes('자재투입'),
isCompleted: sp.status === 'completed',
}));
} else {
steps = processSteps.map((ps: { id: number; step_name: string; step_code: string }, si: number) => ({
id: `${item.id}-step-${si}`,
name: ps.step_name,
isMaterialInput: ps.step_code.includes('MAT') || ps.step_name.includes('자재투입'),
isCompleted: false,
}));
}
const workItem: WorkItemData = {
id: String(item.id),
itemNo: index + 1,
itemCode: wo.work_order_no || '-',
itemName: item.item_name || '-',
floor: (opts.floor as string) || '-',
code: (opts.code as string) || '-',
width: (opts.width as number) || 0,
height: (opts.height as number) || 0,
quantity: Number(item.quantity) || 0,
processType,
steps,
materialInputs: [],
};
// 공정별 상세 정보 파싱 (options에서)
if (opts.cutting_info) {
const ci = opts.cutting_info as { width: number; sheets: number };
workItem.cuttingInfo = { width: ci.width, sheets: ci.sheets };
}
if (opts.slat_info) {
const si = opts.slat_info as { length: number; slat_count: number; joint_bar: number };
workItem.slatInfo = { length: si.length, slatCount: si.slat_count, jointBar: si.joint_bar };
}
if (opts.bending_info) {
const bi = opts.bending_info as {
common: { kind: string; type: string; length_quantities: { length: number; quantity: number }[] };
detail_parts: { part_name: string; material: string; barcy_info: string }[];
};
workItem.bendingInfo = {
common: {
kind: bi.common.kind,
type: bi.common.type,
lengthQuantities: bi.common.length_quantities || [],
},
detailParts: (bi.detail_parts || []).map(dp => ({
partName: dp.part_name,
material: dp.material,
barcyInfo: dp.barcy_info,
})),
};
}
if (opts.is_wip) {
workItem.isWip = true;
const wi = opts.wip_info as { specification: string; length_quantity: string; drawing_url?: string } | undefined;
if (wi) {
workItem.wipInfo = {
specification: wi.specification,
lengthQuantity: wi.length_quantity,
drawingUrl: wi.drawing_url,
};
}
}
if (opts.is_joint_bar) {
workItem.isJointBar = true;
const jb = opts.slat_joint_bar_info as { specification: string; length: number; quantity: number } | undefined;
if (jb) {
workItem.slatJointBarInfo = jb;
}
}
return workItem;
});
return { success: true, data: items };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[WorkerScreenActions] getWorkOrderDetail error:', error);
return { success: false, data: [], error: '서버 오류' };
}
}

View File

@@ -303,7 +303,7 @@ export default function WorkerScreen() {
const { sidebarCollapsed } = useMenuStore();
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [activeTab, setActiveTab] = useState<ProcessTab>('screen');
const [activeTab, setActiveTab] = useState<string>('');
const [bendingSubMode, setBendingSubMode] = useState<'normal' | 'wip'>('normal');
const [slatSubMode, setSlatSubMode] = useState<'normal' | 'jointbar'>('normal');
@@ -313,7 +313,7 @@ export default function WorkerScreen() {
const [productionDate, setProductionDate] = useState('');
// 좌측 사이드바
const [selectedSidebarOrderId, setSelectedSidebarOrderId] = useState<string>('order-1');
const [selectedSidebarOrderId, setSelectedSidebarOrderId] = useState<string>('');
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
// 공정별 step 완료 상태: { [itemId-stepName]: boolean }
@@ -406,24 +406,36 @@ export default function WorkerScreen() {
fetchProcessList();
}, []);
// 활성 공정 목록 (탭용) - 공정관리에서 등록된 활성 공정만
const processTabs = useMemo(() => {
return processListCache.filter((p) => p.status === '사용중');
}, [processListCache]);
// 공정 목록 로드 후 첫 번째 공정을 기본 선택
useEffect(() => {
if (processTabs.length > 0 && !activeTab) {
setActiveTab(processTabs[0].id);
}
}, [processTabs, activeTab]);
// 선택된 공정의 ProcessTab 키 (mock 데이터 및 기존 로직 호환용)
const activeProcessTabKey: ProcessTab = useMemo(() => {
const process = processListCache.find((p) => p.id === activeTab);
if (!process) return 'screen';
const name = process.processName.toLowerCase();
if (name.includes('스크린')) return 'screen';
if (name.includes('슬랫')) return 'slat';
if (name.includes('절곡')) return 'bending';
return 'screen';
}, [activeTab, processListCache]);
// activeTab 변경 시 해당 공정의 중간검사 설정 조회
useEffect(() => {
if (processListCache.length === 0) return;
if (processListCache.length === 0 || !activeTab) return;
// activeTab에 해당하는 공정 찾기
const tabToProcessName: Record<ProcessTab, string[]> = {
screen: ['스크린', 'screen'],
slat: ['슬랫', 'slat'],
bending: ['절곡', 'bending'],
};
const matchNames = tabToProcessName[activeTab] || [];
const matchedProcess = processListCache.find((p) =>
matchNames.some((name) => p.processName.toLowerCase().includes(name.toLowerCase()))
);
const matchedProcess = processListCache.find((p) => p.id === activeTab);
if (matchedProcess?.steps) {
// 검사 단계에서 inspectionSetting 찾기
const inspectionStep = matchedProcess.steps.find(
(step) => step.needsInspection && step.inspectionSetting
);
@@ -435,22 +447,49 @@ export default function WorkerScreen() {
// ===== 탭별 필터링된 작업 =====
const filteredWorkOrders = useMemo(() => {
// process_type 기반 필터링
const selectedProcess = processListCache.find((p) => p.id === activeTab);
if (!selectedProcess) return workOrders;
const selectedName = selectedProcess.processName.toLowerCase();
return workOrders.filter((order) => {
// WorkOrder의 processCode/processName으로 매칭
const processName = (order.processName || '').toLowerCase();
switch (activeTab) {
case 'screen':
return processName.includes('스크린') || processName === 'screen';
case 'slat':
return processName.includes('슬랫') || processName === 'slat';
case 'bending':
return processName.includes('절곡') || processName === 'bending';
default:
return true;
}
const orderProcessName = (order.processName || '').toLowerCase();
return orderProcessName.includes(selectedName) || selectedName.includes(orderProcessName);
});
}, [workOrders, activeTab]);
}, [workOrders, activeTab, processListCache]);
// ===== API WorkOrders → SidebarOrder 변환 =====
const apiSidebarOrders: SidebarOrder[] = useMemo(() => {
return filteredWorkOrders.map((wo) => ({
id: wo.id,
siteName: wo.projectName || wo.client || '-',
date: wo.dueDate || (wo.createdAt ? wo.createdAt.slice(0, 10) : '-'),
quantity: wo.quantity,
shutterCount: wo.shutterCount || 0,
priority: (wo.isUrgent ? 'urgent' : (wo.priority <= 3 ? 'priority' : 'normal')) as SidebarOrder['priority'],
}));
}, [filteredWorkOrders]);
// ===== 탭 변경/데이터 로드 시 최상위 우선순위 작업 자동 선택 =====
useEffect(() => {
if (isLoading) return;
const allOrders: SidebarOrder[] = [...apiSidebarOrders, ...MOCK_SIDEBAR_ORDERS[activeProcessTabKey]];
// 우선순위 순서: urgent → priority → normal
for (const group of PRIORITY_GROUPS) {
const first = allOrders.find((o) => o.priority === group.key);
if (first) {
setSelectedSidebarOrderId(first.id);
// subType에 따라 서브모드도 설정
if (activeProcessTabKey === 'slat') {
setSlatSubMode(first.subType === 'jointbar' ? 'jointbar' : 'normal');
}
if (activeProcessTabKey === 'bending') {
setBendingSubMode(first.subType === 'wip' ? 'wip' : 'normal');
}
return;
}
}
}, [isLoading, apiSidebarOrders, activeProcessTabKey]);
// ===== 통계 계산 (탭별) =====
const stats: WorkerStats = useMemo(() => {
@@ -462,46 +501,84 @@ export default function WorkerScreen() {
};
}, [filteredWorkOrders]);
// ===== WorkOrder → WorkItemData 변환 + 목업 =====
// ===== 선택된 작업지시의 개소별 WorkItemData 변환 + 목업 =====
const workItems: WorkItemData[] = useMemo(() => {
const apiItems: WorkItemData[] = filteredWorkOrders.map((order, index) => {
const stepsKey = (activeTab === 'bending' && bendingSubMode === 'wip') ? 'bending_wip' : activeTab;
const stepsTemplate = PROCESS_STEPS[stepsKey];
const selectedOrder = filteredWorkOrders.find((wo) => wo.id === selectedSidebarOrderId);
const stepsKey = (activeProcessTabKey === 'bending' && bendingSubMode === 'wip') ? 'bending_wip' : activeProcessTabKey;
const stepsTemplate = PROCESS_STEPS[stepsKey];
const apiItems: WorkItemData[] = [];
if (selectedOrder && selectedOrder.nodeGroups && selectedOrder.nodeGroups.length > 0) {
// 개소별로 WorkItemData 생성
selectedOrder.nodeGroups.forEach((group, index) => {
const nodeKey = group.nodeId != null ? String(group.nodeId) : `unassigned-${index}`;
const steps: WorkStepData[] = stepsTemplate.map((st, si) => {
const stepKey = `${selectedOrder.id}-${nodeKey}-${st.name}`;
return {
id: `${selectedOrder.id}-${nodeKey}-step-${si}`,
name: st.name,
isMaterialInput: st.isMaterialInput,
isInspection: st.isInspection,
isCompleted: stepCompletionMap[stepKey] || false,
};
});
// 개소 내 아이템 이름 요약
const itemNames = group.items.map((it) => it.itemName).filter(Boolean);
const itemSummary = itemNames.length > 0 ? itemNames.join(', ') : '-';
apiItems.push({
id: `${selectedOrder.id}-node-${nodeKey}`,
itemNo: index + 1,
itemCode: selectedOrder.orderNo || '-',
itemName: `${group.nodeName} : ${itemSummary}`,
floor: '-',
code: '-',
width: 0,
height: 0,
quantity: group.totalQuantity,
processType: activeProcessTabKey,
steps,
materialInputs: [],
});
});
} else if (selectedOrder) {
// nodeGroups가 없는 경우 폴백 (단일 항목)
const steps: WorkStepData[] = stepsTemplate.map((st, si) => {
const stepKey = `${order.id}-${st.name}`;
const stepKey = `${selectedOrder.id}-${st.name}`;
return {
id: `${order.id}-step-${si}`,
id: `${selectedOrder.id}-step-${si}`,
name: st.name,
isMaterialInput: st.isMaterialInput,
isInspection: st.isInspection,
isCompleted: stepCompletionMap[stepKey] || false,
};
});
return {
id: order.id,
itemNo: index + 1,
itemCode: order.orderNo || '-',
itemName: order.productName || '-',
apiItems.push({
id: selectedOrder.id,
itemNo: 1,
itemCode: selectedOrder.orderNo || '-',
itemName: selectedOrder.productName || '-',
floor: '-',
code: '-',
width: 0,
height: 0,
quantity: order.quantity || 0,
processType: activeTab,
quantity: selectedOrder.quantity || 0,
processType: activeProcessTabKey,
steps,
materialInputs: [],
};
});
});
}
// 목업 데이터 합치기 (API 데이터 뒤에 번호 이어서)
// 절곡 탭에서 재공품 서브모드면 WIP 전용 목업 사용
// 슬랫 탭에서 조인트바 서브모드면 조인트바 전용 목업 사용
const baseMockItems = (activeTab === 'bending' && bendingSubMode === 'wip')
const baseMockItems = (activeProcessTabKey === 'bending' && bendingSubMode === 'wip')
? MOCK_ITEMS_BENDING_WIP
: (activeTab === 'slat' && slatSubMode === 'jointbar')
: (activeProcessTabKey === 'slat' && slatSubMode === 'jointbar')
? MOCK_ITEMS_SLAT_JOINTBAR
: MOCK_ITEMS[activeTab];
: MOCK_ITEMS[activeProcessTabKey];
const mockItems = baseMockItems.map((item, i) => ({
...item,
itemNo: apiItems.length + i + 1,
@@ -515,22 +592,51 @@ export default function WorkerScreen() {
}));
return [...apiItems, ...mockItems];
}, [filteredWorkOrders, activeTab, stepCompletionMap, bendingSubMode, slatSubMode]);
}, [filteredWorkOrders, selectedSidebarOrderId, activeProcessTabKey, stepCompletionMap, bendingSubMode, slatSubMode]);
// ===== 수주 정보 (첫 번째 작업 기반 표시) =====
// ===== 수주 정보 (사이드바 선택 항목 기반) =====
const orderInfo = useMemo(() => {
// 1. 선택된 API 작업지시에서 찾기
const apiOrder = filteredWorkOrders.find((wo) => wo.id === selectedSidebarOrderId);
if (apiOrder) {
return {
orderDate: apiOrder.createdAt ? new Date(apiOrder.createdAt).toLocaleDateString('ko-KR') : '-',
salesOrderNo: apiOrder.salesOrderNo || '-',
siteName: apiOrder.projectName || '-',
client: apiOrder.client || '-',
salesManager: apiOrder.assignees?.[0] || '-',
managerPhone: '-',
shippingDate: apiOrder.dueDate ? new Date(apiOrder.dueDate).toLocaleDateString('ko-KR') : '-',
};
}
// 2. 목업 사이드바에서 찾기
const mockOrder = MOCK_SIDEBAR_ORDERS[activeProcessTabKey].find((o) => o.id === selectedSidebarOrderId);
if (mockOrder) {
return {
orderDate: mockOrder.date,
salesOrderNo: 'SO-2024-0001',
siteName: mockOrder.siteName,
client: '-',
salesManager: '-',
managerPhone: '-',
shippingDate: '-',
};
}
// 3. 폴백: 첫 번째 작업
const first = filteredWorkOrders[0];
if (!first) return null;
return {
orderDate: first.createdAt ? new Date(first.createdAt).toLocaleDateString('ko-KR') : '-',
lotNo: '-',
salesOrderNo: first.salesOrderNo || '-',
siteName: first.projectName || '-',
client: first.client || '-',
salesManager: first.assignees?.[0] || '-',
managerPhone: '-',
shippingDate: first.dueDate ? new Date(first.dueDate).toLocaleDateString('ko-KR') : '-',
};
}, [filteredWorkOrders]);
}, [filteredWorkOrders, selectedSidebarOrderId, activeProcessTabKey]);
// ===== 핸들러 =====
@@ -722,14 +828,14 @@ export default function WorkerScreen() {
// 현재 공정에 맞는 중간검사 타입 결정
const getInspectionProcessType = useCallback((): InspectionProcessType => {
if (activeTab === 'bending' && bendingSubMode === 'wip') {
if (activeProcessTabKey === 'bending' && bendingSubMode === 'wip') {
return 'bending_wip';
}
if (activeTab === 'slat' && slatSubMode === 'jointbar') {
if (activeProcessTabKey === 'slat' && slatSubMode === 'jointbar') {
return 'slat_jointbar';
}
return activeTab as InspectionProcessType;
}, [activeTab, bendingSubMode, slatSubMode]);
return activeProcessTabKey as InspectionProcessType;
}, [activeProcessTabKey, bendingSubMode, slatSubMode]);
// 하단 버튼 핸들러
const handleWorkLog = useCallback(() => {
@@ -766,13 +872,13 @@ export default function WorkerScreen() {
// ===== 재공품 감지 =====
const hasWipItems = useMemo(() => {
return activeTab === 'bending' && workItems.some(item => item.isWip);
}, [activeTab, workItems]);
return activeProcessTabKey === 'bending' && workItems.some(item => item.isWip);
}, [activeProcessTabKey, workItems]);
// ===== 조인트바 감지 =====
const hasJointBarItems = useMemo(() => {
return activeTab === 'slat' && slatSubMode === 'jointbar';
}, [activeTab, slatSubMode]);
return activeProcessTabKey === 'slat' && slatSubMode === 'jointbar';
}, [activeProcessTabKey, slatSubMode]);
// 재공품 통합 문서 (작업일지 + 중간검사) 핸들러
const handleWipInspection = useCallback(() => {
@@ -802,21 +908,32 @@ export default function WorkerScreen() {
</div>
</div>
{/* 공정별 탭 */}
{/* 공정별 탭 (공정관리 API 기반 동적 생성) */}
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as ProcessTab)}
onValueChange={(v) => setActiveTab(v)}
>
<TabsList className="w-full">
{(['screen', 'slat', 'bending'] as ProcessTab[]).map((tab) => (
<TabsTrigger key={tab} value={tab} className="flex-1">
{PROCESS_TAB_LABELS[tab]}
</TabsTrigger>
))}
{processTabs.length > 0 ? (
processTabs.map((proc) => (
<TabsTrigger key={proc.id} value={proc.id} className="flex-1">
{proc.processName}
</TabsTrigger>
))
) : (
(['screen', 'slat', 'bending'] as ProcessTab[]).map((tab) => (
<TabsTrigger key={tab} value={tab} className="flex-1">
{PROCESS_TAB_LABELS[tab]}
</TabsTrigger>
))
)}
</TabsList>
{(['screen', 'slat', 'bending'] as ProcessTab[]).map((tab) => (
<TabsContent key={tab} value={tab} className="mt-4">
{(processTabs.length > 0
? processTabs.map((p) => p.id)
: ['screen', 'slat', 'bending']
).map((tabValue) => (
<TabsContent key={tabValue} value={tabValue} className="mt-4">
{/* 모바일: 사이드바 토글 */}
<div className="md:hidden mb-4">
<button
@@ -826,15 +943,16 @@ export default function WorkerScreen() {
>
<div className="flex items-center gap-2">
<List className="h-4 w-4" />
<span className="text-sm font-medium"> </span>
<span className="text-sm font-medium"> </span>
</div>
{isSidebarOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</button>
{isSidebarOpen && (
<div className="mt-2 p-3 border rounded-lg bg-white max-h-[300px] overflow-y-auto">
<SidebarContent
tab={tab}
tab={activeProcessTabKey}
selectedOrderId={selectedSidebarOrderId}
apiOrders={apiSidebarOrders}
onSelectOrder={(id, subType) => {
setSelectedSidebarOrderId(id);
if (subType === 'slat' || subType === 'jointbar') setSlatSubMode(subType === 'jointbar' ? 'jointbar' : 'normal');
@@ -853,8 +971,9 @@ export default function WorkerScreen() {
<Card className="max-h-[calc(100vh-7.5rem)] overflow-y-auto">
<CardContent className="p-4">
<SidebarContent
tab={tab}
tab={activeProcessTabKey}
selectedOrderId={selectedSidebarOrderId}
apiOrders={apiSidebarOrders}
onSelectOrder={(id, subType) => {
setSelectedSidebarOrderId(id);
if (subType === 'slat' || subType === 'jointbar') setSlatSubMode(subType === 'jointbar' ? 'jointbar' : 'normal');
@@ -901,7 +1020,7 @@ export default function WorkerScreen() {
<h3 className="text-sm font-semibold text-gray-900 mb-3"> </h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-3">
<InfoField label="수주일" value={orderInfo?.orderDate} />
<InfoField label="로트번호" value={orderInfo?.lotNo} />
<InfoField label="수주로트" value={orderInfo?.salesOrderNo} />
<InfoField label="현장명" value={orderInfo?.siteName} />
<InfoField label="수주처" value={orderInfo?.client} />
<InfoField label="수주 담당자" value={orderInfo?.salesManager} />
@@ -977,16 +1096,37 @@ export default function WorkerScreen() {
</Card>
) : (
<div className="space-y-4">
{workItems.map((item) => (
<WorkItemCard
key={item.id}
item={item}
onStepClick={handleStepClick}
onEditMaterial={handleEditMaterial}
onDeleteMaterial={handleDeleteMaterial}
onInspectionClick={handleInspectionClick}
/>
))}
{(() => {
const apiCount = workItems.filter((i) => !i.id.startsWith('mock-')).length;
return apiCount > 0 ? (
<span className="text-[10px] font-semibold text-blue-600 bg-blue-50 px-2 py-0.5 rounded inline-block">
({apiCount})
</span>
) : null;
})()}
{workItems.map((item, index) => {
const isFirstMock = item.id.startsWith('mock-') &&
(index === 0 || !workItems[index - 1]?.id.startsWith('mock-'));
return (
<div key={item.id}>
{isFirstMock && (
<div className="mb-3 pt-1 space-y-2">
{workItems.some((i) => !i.id.startsWith('mock-')) && <div className="border-t border-dashed border-gray-300" />}
<span className="text-[10px] font-semibold text-orange-600 bg-orange-50 px-2 py-0.5 rounded inline-block">
</span>
</div>
)}
<WorkItemCard
item={item}
onStepClick={handleStepClick}
onEditMaterial={handleEditMaterial}
onDeleteMaterial={handleDeleteMaterial}
onInspectionClick={handleInspectionClick}
/>
</div>
);
})}
</div>
)}
</div>
@@ -1049,14 +1189,14 @@ export default function WorkerScreen() {
open={isWorkLogModalOpen}
onOpenChange={setIsWorkLogModalOpen}
workOrderId={selectedOrder?.id || null}
processType={activeTab}
processType={activeProcessTabKey}
/>
<InspectionReportModal
open={isInspectionModalOpen}
onOpenChange={setIsInspectionModalOpen}
workOrderId={selectedOrder?.id || null}
processType={hasWipItems ? 'bending_wip' : activeTab}
processType={hasWipItems ? 'bending_wip' : activeProcessTabKey}
readOnly={true}
isJointBar={hasJointBarItems}
inspectionData={selectedOrder ? inspectionDataMap.get(selectedOrder.id) : undefined}
@@ -1094,6 +1234,7 @@ export default function WorkerScreen() {
interface SidebarContentProps {
tab: ProcessTab;
selectedOrderId: string;
apiOrders: SidebarOrder[];
onSelectOrder: (id: string, subType?: SidebarOrder['subType']) => void;
}
@@ -1101,14 +1242,12 @@ function SidebarContent({
tab,
selectedOrderId,
onSelectOrder,
apiOrders,
}: SidebarContentProps) {
const orders = MOCK_SIDEBAR_ORDERS[tab];
const mockOrders = MOCK_SIDEBAR_ORDERS[tab];
return (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-gray-900"> </h3>
{/* 우선순위별 작업지시 카드 + 태그 */}
const renderOrders = (orders: SidebarOrder[]) => (
<>
{PRIORITY_GROUPS.map((group) => {
const groupOrders = orders.filter((o) => o.priority === group.key);
if (groupOrders.length === 0) return null;
@@ -1140,7 +1279,7 @@ function SidebarContent({
</div>
<div className="flex items-center justify-between mt-1 text-gray-500">
<span>{order.date}</span>
<span>{order.quantity}/{order.shutterCount}</span>
<span>{order.shutterCount}</span>
</div>
</button>
);
@@ -1149,6 +1288,37 @@ function SidebarContent({
</div>
);
})}
</>
);
return (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-gray-900"> </h3>
{/* API 실제 데이터 */}
{apiOrders.length > 0 && (
<div className="space-y-2">
<span className="text-[10px] font-semibold text-blue-600 bg-blue-50 px-2 py-0.5 rounded">
({apiOrders.length})
</span>
{renderOrders(apiOrders)}
</div>
)}
{/* 구분선 */}
{apiOrders.length > 0 && mockOrders.length > 0 && (
<div className="border-t border-dashed border-gray-300 my-1" />
)}
{/* 목업 데이터 */}
{mockOrders.length > 0 && (
<div className="space-y-2">
<span className="text-[10px] font-semibold text-orange-600 bg-orange-50 px-2 py-0.5 rounded">
</span>
{renderOrders(mockOrders)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,194 @@
/**
* 작업자 화면 목업 데이터
*
* 실제 API 연동 전환 시 참조용으로 보존
* USE_MOCK = true 일 때 API 데이터 뒤에 병합됨
*/
import type { ProcessTab, WorkItemData } from './types';
// ===== 목업 ON/OFF 플래그 =====
// true: API 데이터 + 목업 데이터 병합 (개발 중)
// false: API 데이터만 사용 (운영)
export const USE_MOCK = false;
// ===== 공정별 목업 데이터 =====
export const MOCK_ITEMS: Record<ProcessTab, WorkItemData[]> = {
screen: [
{
id: 'mock-s1', itemNo: 1, itemCode: 'KWWS03', itemName: '와이어', floor: '1층', code: 'FSS-01',
width: 8260, height: 8350, quantity: 2, processType: 'screen',
cuttingInfo: { width: 1210, sheets: 8 },
steps: [
{ id: 's1-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
{ id: 's1-2', name: '절단', isMaterialInput: false, isCompleted: true },
{ id: 's1-3', name: '미싱', isMaterialInput: false, isCompleted: false },
{ id: 's1-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
materialInputs: [
{ id: 'm1', lotNo: 'LOT-2026-001', itemName: '스크린 원단 A', quantity: 500, unit: 'm' },
{ id: 'm2', lotNo: 'LOT-2026-002', itemName: '와이어 B', quantity: 120, unit: 'EA' },
],
},
{
id: 'mock-s2', itemNo: 2, itemCode: 'KWWS05', itemName: '메쉬', floor: '2층', code: 'FSS-03',
width: 6400, height: 5200, quantity: 4, processType: 'screen',
cuttingInfo: { width: 1600, sheets: 4 },
steps: [
{ id: 's2-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
{ id: 's2-2', name: '절단', isMaterialInput: false, isCompleted: false },
{ id: 's2-3', name: '미싱', isMaterialInput: false, isCompleted: false },
{ id: 's2-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
},
{
id: 'mock-s3', itemNo: 3, itemCode: 'KWWS08', itemName: '와이어(광폭)', floor: '3층', code: 'FSS-05',
width: 12000, height: 4500, quantity: 1, processType: 'screen',
cuttingInfo: { width: 2400, sheets: 5 },
steps: [
{ id: 's3-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
{ id: 's3-2', name: '절단', isMaterialInput: false, isCompleted: true },
{ id: 's3-3', name: '미싱', isMaterialInput: false, isCompleted: true },
{ id: 's3-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
materialInputs: [
{ id: 'm3', lotNo: 'LOT-2026-005', itemName: '광폭 원단', quantity: 300, unit: 'm' },
],
},
],
slat: [
{
id: 'mock-l1', itemNo: 1, itemCode: 'KQTS01', itemName: '슬랫코일', floor: '1층', code: 'FSS-01',
width: 8260, height: 8350, quantity: 2, processType: 'slat',
slatInfo: { length: 3910, slatCount: 40, jointBar: 4 },
steps: [
{ id: 'l1-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
{ id: 'l1-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false },
{ id: 'l1-3', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
materialInputs: [
{ id: 'm4', lotNo: 'LOT-2026-010', itemName: '슬랫 코일 A', quantity: 200, unit: 'kg' },
],
},
{
id: 'mock-l2', itemNo: 2, itemCode: 'KQTS03', itemName: '슬랫코일(광폭)', floor: '2층', code: 'FSS-02',
width: 10500, height: 6200, quantity: 3, processType: 'slat',
slatInfo: { length: 5200, slatCount: 55, jointBar: 6 },
steps: [
{ id: 'l2-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
{ id: 'l2-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false },
{ id: 'l2-3', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
},
],
bending: [
{
id: 'mock-b1', itemNo: 1, itemCode: 'KWWS03', itemName: '가이드레일', floor: '1층', code: 'FSS-01',
width: 0, height: 0, quantity: 6, processType: 'bending',
bendingInfo: {
common: {
kind: '혼합형 120X70', type: '혼합형',
lengthQuantities: [{ length: 4000, quantity: 6 }, { length: 3000, quantity: 6 }],
},
detailParts: [
{ partName: '엘바', material: 'EGI 1.6T', barcyInfo: '16 I 75' },
{ partName: '하장바', material: 'EGI 1.6T', barcyInfo: '16|75|16|75|16(A각)' },
],
},
steps: [
{ id: 'b1-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
{ id: 'b1-2', name: '절단', isMaterialInput: false, isCompleted: true },
{ id: 'b1-3', name: '절곡', isMaterialInput: false, isCompleted: false },
{ id: 'b1-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
},
],
};
// ===== 절곡 재공품 전용 목업 데이터 =====
export const MOCK_ITEMS_BENDING_WIP: WorkItemData[] = [
{
id: 'mock-bw1', itemNo: 1, itemCode: 'KWWS03', itemName: '케이스 - 전면부', floor: '-', code: '-',
width: 0, height: 0, quantity: 6, processType: 'bending',
isWip: true,
wipInfo: { specification: 'EGI 1.55T (W576)', lengthQuantity: '4,000mm X 6개' },
steps: [
{ id: 'bw1-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
{ id: 'bw1-2', name: '절단', isMaterialInput: false, isCompleted: false },
],
},
{
id: 'mock-bw2', itemNo: 2, itemCode: 'KWWS04', itemName: '케이스 - 후면부', floor: '-', code: '-',
width: 0, height: 0, quantity: 4, processType: 'bending',
isWip: true,
wipInfo: { specification: 'EGI 1.55T (W576)', lengthQuantity: '3,500mm X 4개' },
steps: [
{ id: 'bw2-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
{ id: 'bw2-2', name: '절단', isMaterialInput: false, isCompleted: false },
],
},
{
id: 'mock-bw3', itemNo: 3, itemCode: 'KWWS05', itemName: '하단마감재', floor: '-', code: '-',
width: 0, height: 0, quantity: 10, processType: 'bending',
isWip: true,
wipInfo: { specification: 'EGI 1.2T (W400)', lengthQuantity: '2,800mm X 10개' },
steps: [
{ id: 'bw3-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
{ id: 'bw3-2', name: '절단', isMaterialInput: false, isCompleted: false },
],
},
];
// ===== 슬랫 조인트바 전용 목업 데이터 =====
export const MOCK_ITEMS_SLAT_JOINTBAR: WorkItemData[] = [
{
id: 'mock-jb1', itemNo: 1, itemCode: 'KQJB01', itemName: '조인트바 A', floor: '1층', code: 'FSS-01',
width: 0, height: 0, quantity: 8, processType: 'slat',
isJointBar: true,
slatJointBarInfo: { specification: 'EGI 1.6T', length: 3910, quantity: 8 },
steps: [
{ id: 'jb1-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
{ id: 'jb1-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false },
{ id: 'jb1-3', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
materialInputs: [
{ id: 'mjb1', lotNo: 'LOT-2026-020', itemName: '조인트바 코일', quantity: 100, unit: 'kg' },
],
},
{
id: 'mock-jb2', itemNo: 2, itemCode: 'KQJB02', itemName: '조인트바 B', floor: '2층', code: 'FSS-02',
width: 0, height: 0, quantity: 12, processType: 'slat',
isJointBar: true,
slatJointBarInfo: { specification: 'EGI 1.6T', length: 5200, quantity: 12 },
steps: [
{ id: 'jb2-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
{ id: 'jb2-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false },
{ id: 'jb2-3', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
},
];
// ===== 하드코딩된 공정별 단계 폴백 =====
export const PROCESS_STEPS_FALLBACK: Record<string, { name: string; isMaterialInput: boolean }[]> = {
screen: [
{ name: '자재투입', isMaterialInput: true },
{ name: '절단', isMaterialInput: false },
{ name: '미싱', isMaterialInput: false },
{ name: '포장완료', isMaterialInput: false },
],
slat: [
{ name: '자재투입', isMaterialInput: true },
{ name: '포밍/절단', isMaterialInput: false },
{ name: '포장완료', isMaterialInput: false },
],
bending: [
{ name: '자재투입', isMaterialInput: true },
{ name: '절단', isMaterialInput: false },
{ name: '절곡', isMaterialInput: false },
{ name: '포장완료', isMaterialInput: false },
],
bending_wip: [
{ name: '자재투입', isMaterialInput: true },
{ name: '절단', isMaterialInput: false },
],
};