diff --git a/src/components/production/WorkOrders/WorkOrderDetail.tsx b/src/components/production/WorkOrders/WorkOrderDetail.tsx index 820ae5c8..37efcff9 100644 --- a/src/components/production/WorkOrders/WorkOrderDetail.tsx +++ b/src/components/production/WorkOrders/WorkOrderDetail.tsx @@ -23,7 +23,7 @@ import { PageLayout } from '@/components/organisms/PageLayout'; import { WorkLogModal } from '../WorkerScreen/WorkLogModal'; import { toast } from 'sonner'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; -import { getWorkOrderById, updateWorkOrderStatus } from './actions'; +import { getWorkOrderById, updateWorkOrderStatus, updateWorkOrderItemStatus, type WorkOrderItemStatus } from './actions'; import { WORK_ORDER_STATUS_LABELS, WORK_ORDER_STATUS_COLORS, @@ -34,33 +34,47 @@ import { BENDING_PROCESS_STEPS, type WorkOrder, type ProcessType, + type ProcessStep, } from './types'; // 공정 진행 단계 컴포넌트 function ProcessSteps({ processType, currentStep, + workSteps, }: { processType: ProcessType; currentStep: number; + workSteps?: ProcessStep[]; }) { - const steps = - processType === 'screen' + // 동적 workSteps 우선 사용, 없으면 하드코딩 폴백 + const steps = workSteps && workSteps.length > 0 + ? workSteps + : processType === 'screen' ? SCREEN_PROCESS_STEPS : processType === 'slat' - ? SLAT_PROCESS_STEPS - : BENDING_PROCESS_STEPS; + ? SLAT_PROCESS_STEPS + : BENDING_PROCESS_STEPS; + + if (steps.length === 0) { + return ( +
+

공정 진행

+

공정 단계가 설정되지 않았습니다.

+
+ ); + } return (

공정 진행 ({steps.length}단계)

-
+
{steps.map((step, index) => { const isCompleted = index < currentStep; const isCurrent = index === currentStep; return ( -
+
(null); const [isLoading, setIsLoading] = useState(true); const [isStatusUpdating, setIsStatusUpdating] = useState(false); + const [updatingItemId, setUpdatingItemId] = useState(null); // API에서 데이터 로드 const loadData = useCallback(async () => { @@ -242,6 +257,49 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) { } }, [order, orderId]); + // 품목 상태 변경 핸들러 + const handleItemStatusChange = useCallback(async (itemId: number, newStatus: WorkOrderItemStatus) => { + if (!order) return; + + setUpdatingItemId(itemId); + try { + const result = await updateWorkOrderItemStatus(orderId, itemId, newStatus); + if (result.success) { + // 로컬 상태 업데이트 (품목 + 작업지시 상태) + setOrder(prev => { + if (!prev) return prev; + return { + ...prev, + status: result.workOrderStatus || prev.status, + items: prev.items.map(item => + item.id === itemId ? { ...item, status: newStatus } : item + ), + }; + }); + + const statusLabels: Record = { + waiting: '대기', + in_progress: '작업중', + completed: '완료', + }; + toast.success(`품목 상태가 '${statusLabels[newStatus]}'(으)로 변경되었습니다.`); + + // 작업지시 상태가 변경된 경우 추가 알림 + if (result.workOrderStatusChanged && result.workOrderStatus) { + const workOrderStatusLabel = WORK_ORDER_STATUS_LABELS[result.workOrderStatus as keyof typeof WORK_ORDER_STATUS_LABELS] || result.workOrderStatus; + toast.info(`작업지시 상태가 '${workOrderStatusLabel}'(으)로 자동 변경되었습니다.`); + } + } else { + toast.error(result.error || '품목 상태 변경에 실패했습니다.'); + } + } catch (error) { + console.error('[WorkOrderDetail] handleItemStatusChange error:', error); + toast.error('품목 상태 변경 중 오류가 발생했습니다.'); + } finally { + setUpdatingItemId(null); + } + }, [order, orderId]); + // 로딩 상태 if (isLoading) { return ( @@ -377,7 +435,11 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
{/* 공정 진행 */} - + {/* 작업 품목 */}
@@ -408,14 +470,32 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) { {item.quantity} {item.status === 'waiting' && ( - )} {item.status === 'in_progress' && ( - )} diff --git a/src/components/production/WorkOrders/actions.ts b/src/components/production/WorkOrders/actions.ts index 1901a567..6e56f968 100644 --- a/src/components/production/WorkOrders/actions.ts +++ b/src/components/production/WorkOrders/actions.ts @@ -13,6 +13,7 @@ * - PATCH /api/v1/work-orders/{id}/bending/toggle - 벤딩 필드 토글 * - POST /api/v1/work-orders/{id}/issues - 이슈 등록 * - PATCH /api/v1/work-orders/{id}/issues/{issueId}/resolve - 이슈 해결 + * - PATCH /api/v1/work-orders/{id}/items/{itemId}/status - 품목 상태 변경 */ 'use server'; @@ -559,6 +560,62 @@ export async function resolveWorkOrderIssue( } } +// ===== 품목 상태 변경 ===== +export type WorkOrderItemStatus = 'waiting' | 'in_progress' | 'completed'; + +export async function updateWorkOrderItemStatus( + workOrderId: string, + itemId: number, + status: WorkOrderItemStatus +): Promise<{ + success: boolean; + itemId: number; + status: WorkOrderItemStatus; + workOrderStatus?: string; + workOrderStatusChanged?: boolean; + error?: string; +}> { + try { + console.log('[WorkOrderActions] PATCH item status request:', { workOrderId, itemId, status }); + + const { response, error } = await serverFetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${workOrderId}/items/${itemId}/status`, + { + method: 'PATCH', + body: JSON.stringify({ status }), + } + ); + + if (error || !response) { + return { success: false, itemId, status, error: error?.message || 'API 요청 실패' }; + } + + const result = await response.json(); + console.log('[WorkOrderActions] PATCH item status response:', result); + + if (!response.ok || !result.success) { + return { + success: false, + itemId, + status, + error: result.message || '품목 상태 변경에 실패했습니다.', + }; + } + + return { + success: true, + itemId, + status: result.data?.item?.status || status, + workOrderStatus: result.data?.work_order_status, + workOrderStatusChanged: result.data?.work_order_status_changed || false, + }; + } catch (error) { + if (isNextRedirectError(error)) throw error; + console.error('[WorkOrderActions] updateWorkOrderItemStatus error:', error); + return { success: false, itemId, status, error: '서버 오류가 발생했습니다.' }; + } +} + // ===== 수주 목록 조회 (작업지시 생성용) ===== export interface SalesOrderForWorkOrder { id: number; diff --git a/src/components/production/WorkOrders/types.ts b/src/components/production/WorkOrders/types.ts index 3f2c4117..a69c702f 100644 --- a/src/components/production/WorkOrders/types.ts +++ b/src/components/production/WorkOrders/types.ts @@ -142,6 +142,13 @@ export const ISSUE_STATUS_LABELS: Record = { resolved: '해결됨', }; +// 공정 단계 타입 +export interface ProcessStep { + key: string; + label: string; + order: number; +} + // 작업지시 메인 타입 export interface WorkOrder { id: string; @@ -150,6 +157,7 @@ export interface WorkOrder { processId: number; // 공정 ID (FK) processName: string; // 공정명 (표시용) processCode: string; // 공정코드 (표시용) + workSteps?: ProcessStep[]; // 공정 단계 (동적, DB에서 로드) /** @deprecated process_id FK 사용 */ processType: ProcessType; // 하위 호환용 status: WorkOrderStatus; // 작업상태 @@ -306,6 +314,7 @@ export interface WorkOrderApi { id: number; process_code: string; process_name: string; + work_steps?: string[] | { key: string; label: string; order: number }[]; }; assignee?: { id: number; name: string }; assignees?: WorkOrderAssigneeApi[]; @@ -367,6 +376,14 @@ export function transformApiToFrontend(api: WorkOrderApi): WorkOrder { processId: api.process_id, processName: api.process?.process_name || '-', processCode: api.process?.process_code || '-', + // work_steps: string[] 또는 ProcessStep[] 형식 모두 지원 + workSteps: api.process?.work_steps + ? (api.process.work_steps as (string | { key: string; label: string; order: number })[]).map((step, idx) => + typeof step === 'string' + ? { key: `step-${idx}`, label: step, order: idx + 1 } + : step + ) + : undefined, processType: processNameToType(api.process?.process_name || ''), // 하위 호환 status: api.status, client: api.sales_order?.client?.name || '-',