feat(work-order): 품목 상태 변경 UI 및 작업지시 상태 자동 반영

- updateWorkOrderItemStatus action 추가 (API 연동)
- handleItemStatusChange 핸들러에 작업지시 상태 자동 업데이트 로직 추가
- 품목 시작/완료 버튼 클릭 시 로딩 상태 표시
- 작업지시 상태 변경 시 toast.info로 사용자 알림
- ProcessStep 타입 추가 및 workSteps 동적 로드 지원
This commit is contained in:
2026-01-13 16:00:59 +09:00
parent 2b9c70b550
commit fcba883f42
3 changed files with 166 additions and 12 deletions

View File

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

View File

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

View File

@@ -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 || '-',