feat(work-order): 품목 상태 변경 UI 및 작업지시 상태 자동 반영
- updateWorkOrderItemStatus action 추가 (API 연동) - handleItemStatusChange 핸들러에 작업지시 상태 자동 업데이트 로직 추가 - 품목 시작/완료 버튼 클릭 시 로딩 상태 표시 - 작업지시 상태 변경 시 toast.info로 사용자 알림 - ProcessStep 타입 추가 및 workSteps 동적 로드 지원
This commit is contained in:
@@ -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 (
|
||||
<div className="bg-white border rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-4">공정 진행</h3>
|
||||
<p className="text-gray-500">공정 단계가 설정되지 않았습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{steps.map((step, index) => {
|
||||
const isCompleted = index < currentStep;
|
||||
const isCurrent = index === currentStep;
|
||||
|
||||
return (
|
||||
<div key={step.key} className="flex items-center">
|
||||
<div key={step.key || `step-${index}`} className="flex items-center">
|
||||
<div
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-full border ${
|
||||
isCompleted
|
||||
@@ -193,6 +207,7 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
|
||||
const [order, setOrder] = useState<WorkOrder | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isStatusUpdating, setIsStatusUpdating] = useState(false);
|
||||
const [updatingItemId, setUpdatingItemId] = useState<number | null>(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<WorkOrderItemStatus, string> = {
|
||||
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) {
|
||||
</div>
|
||||
|
||||
{/* 공정 진행 */}
|
||||
<ProcessSteps processType={order.processType} currentStep={order.currentStep} />
|
||||
<ProcessSteps
|
||||
processType={order.processType}
|
||||
currentStep={order.currentStep}
|
||||
workSteps={order.workSteps}
|
||||
/>
|
||||
|
||||
{/* 작업 품목 */}
|
||||
<div className="bg-white border rounded-lg p-6">
|
||||
@@ -408,14 +470,32 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
|
||||
<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
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleItemStatusChange(item.id, 'in_progress')}
|
||||
disabled={updatingItemId === item.id}
|
||||
>
|
||||
{updatingItemId === item.id ? (
|
||||
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
|
||||
) : (
|
||||
<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
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleItemStatusChange(item.id, 'completed')}
|
||||
disabled={updatingItemId === item.id}
|
||||
>
|
||||
{updatingItemId === item.id ? (
|
||||
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||
)}
|
||||
완료
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -142,6 +142,13 @@ export const ISSUE_STATUS_LABELS: Record<WorkOrderIssue['status'], string> = {
|
||||
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 || '-',
|
||||
|
||||
Reference in New Issue
Block a user