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 { WorkLogModal } from '../WorkerScreen/WorkLogModal';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||||
import { getWorkOrderById, updateWorkOrderStatus } from './actions';
|
import { getWorkOrderById, updateWorkOrderStatus, updateWorkOrderItemStatus, type WorkOrderItemStatus } from './actions';
|
||||||
import {
|
import {
|
||||||
WORK_ORDER_STATUS_LABELS,
|
WORK_ORDER_STATUS_LABELS,
|
||||||
WORK_ORDER_STATUS_COLORS,
|
WORK_ORDER_STATUS_COLORS,
|
||||||
@@ -34,33 +34,47 @@ import {
|
|||||||
BENDING_PROCESS_STEPS,
|
BENDING_PROCESS_STEPS,
|
||||||
type WorkOrder,
|
type WorkOrder,
|
||||||
type ProcessType,
|
type ProcessType,
|
||||||
|
type ProcessStep,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// 공정 진행 단계 컴포넌트
|
// 공정 진행 단계 컴포넌트
|
||||||
function ProcessSteps({
|
function ProcessSteps({
|
||||||
processType,
|
processType,
|
||||||
currentStep,
|
currentStep,
|
||||||
|
workSteps,
|
||||||
}: {
|
}: {
|
||||||
processType: ProcessType;
|
processType: ProcessType;
|
||||||
currentStep: number;
|
currentStep: number;
|
||||||
|
workSteps?: ProcessStep[];
|
||||||
}) {
|
}) {
|
||||||
const steps =
|
// 동적 workSteps 우선 사용, 없으면 하드코딩 폴백
|
||||||
processType === 'screen'
|
const steps = workSteps && workSteps.length > 0
|
||||||
|
? workSteps
|
||||||
|
: processType === 'screen'
|
||||||
? SCREEN_PROCESS_STEPS
|
? SCREEN_PROCESS_STEPS
|
||||||
: processType === 'slat'
|
: processType === 'slat'
|
||||||
? SLAT_PROCESS_STEPS
|
? SLAT_PROCESS_STEPS
|
||||||
: BENDING_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 (
|
return (
|
||||||
<div className="bg-white border rounded-lg p-6">
|
<div className="bg-white border rounded-lg p-6">
|
||||||
<h3 className="font-semibold mb-4">공정 진행 ({steps.length}단계)</h3>
|
<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) => {
|
{steps.map((step, index) => {
|
||||||
const isCompleted = index < currentStep;
|
const isCompleted = index < currentStep;
|
||||||
const isCurrent = index === currentStep;
|
const isCurrent = index === currentStep;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={step.key} className="flex items-center">
|
<div key={step.key || `step-${index}`} className="flex items-center">
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-2 px-4 py-2 rounded-full border ${
|
className={`flex items-center gap-2 px-4 py-2 rounded-full border ${
|
||||||
isCompleted
|
isCompleted
|
||||||
@@ -193,6 +207,7 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
|
|||||||
const [order, setOrder] = useState<WorkOrder | null>(null);
|
const [order, setOrder] = useState<WorkOrder | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isStatusUpdating, setIsStatusUpdating] = useState(false);
|
const [isStatusUpdating, setIsStatusUpdating] = useState(false);
|
||||||
|
const [updatingItemId, setUpdatingItemId] = useState<number | null>(null);
|
||||||
|
|
||||||
// API에서 데이터 로드
|
// API에서 데이터 로드
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
@@ -242,6 +257,49 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
|
|||||||
}
|
}
|
||||||
}, [order, orderId]);
|
}, [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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -377,7 +435,11 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
|
|||||||
</div>
|
</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">
|
<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 className="text-right">{item.quantity}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{item.status === 'waiting' && (
|
{item.status === 'waiting' && (
|
||||||
<Button variant="outline" size="sm">
|
<Button
|
||||||
<Play className="w-3 h-3 mr-1" />
|
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>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{item.status === 'in_progress' && (
|
{item.status === 'in_progress' && (
|
||||||
<Button variant="outline" size="sm">
|
<Button
|
||||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
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>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
* - PATCH /api/v1/work-orders/{id}/bending/toggle - 벤딩 필드 토글
|
* - PATCH /api/v1/work-orders/{id}/bending/toggle - 벤딩 필드 토글
|
||||||
* - POST /api/v1/work-orders/{id}/issues - 이슈 등록
|
* - POST /api/v1/work-orders/{id}/issues - 이슈 등록
|
||||||
* - PATCH /api/v1/work-orders/{id}/issues/{issueId}/resolve - 이슈 해결
|
* - PATCH /api/v1/work-orders/{id}/issues/{issueId}/resolve - 이슈 해결
|
||||||
|
* - PATCH /api/v1/work-orders/{id}/items/{itemId}/status - 품목 상태 변경
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use server';
|
'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 {
|
export interface SalesOrderForWorkOrder {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -142,6 +142,13 @@ export const ISSUE_STATUS_LABELS: Record<WorkOrderIssue['status'], string> = {
|
|||||||
resolved: '해결됨',
|
resolved: '해결됨',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 공정 단계 타입
|
||||||
|
export interface ProcessStep {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
// 작업지시 메인 타입
|
// 작업지시 메인 타입
|
||||||
export interface WorkOrder {
|
export interface WorkOrder {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -150,6 +157,7 @@ export interface WorkOrder {
|
|||||||
processId: number; // 공정 ID (FK)
|
processId: number; // 공정 ID (FK)
|
||||||
processName: string; // 공정명 (표시용)
|
processName: string; // 공정명 (표시용)
|
||||||
processCode: string; // 공정코드 (표시용)
|
processCode: string; // 공정코드 (표시용)
|
||||||
|
workSteps?: ProcessStep[]; // 공정 단계 (동적, DB에서 로드)
|
||||||
/** @deprecated process_id FK 사용 */
|
/** @deprecated process_id FK 사용 */
|
||||||
processType: ProcessType; // 하위 호환용
|
processType: ProcessType; // 하위 호환용
|
||||||
status: WorkOrderStatus; // 작업상태
|
status: WorkOrderStatus; // 작업상태
|
||||||
@@ -306,6 +314,7 @@ export interface WorkOrderApi {
|
|||||||
id: number;
|
id: number;
|
||||||
process_code: string;
|
process_code: string;
|
||||||
process_name: string;
|
process_name: string;
|
||||||
|
work_steps?: string[] | { key: string; label: string; order: number }[];
|
||||||
};
|
};
|
||||||
assignee?: { id: number; name: string };
|
assignee?: { id: number; name: string };
|
||||||
assignees?: WorkOrderAssigneeApi[];
|
assignees?: WorkOrderAssigneeApi[];
|
||||||
@@ -367,6 +376,14 @@ export function transformApiToFrontend(api: WorkOrderApi): WorkOrder {
|
|||||||
processId: api.process_id,
|
processId: api.process_id,
|
||||||
processName: api.process?.process_name || '-',
|
processName: api.process?.process_name || '-',
|
||||||
processCode: api.process?.process_code || '-',
|
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 || ''), // 하위 호환
|
processType: processNameToType(api.process?.process_name || ''), // 하위 호환
|
||||||
status: api.status,
|
status: api.status,
|
||||||
client: api.sales_order?.client?.name || '-',
|
client: api.sales_order?.client?.name || '-',
|
||||||
|
|||||||
Reference in New Issue
Block a user