- 모달 컴포넌트에서 Content 분리하여 재사용성 향상 - EstimateDocumentContent, DirectConstructionContent 등 - WorkLogContent, QuotePreviewContent, ReceivingReceiptContent - 파일 입력 공통 UI 컴포넌트 추가 - file-dropzone, file-input, file-list, image-upload - 폼 컴포넌트 코드 정리 및 중복 제거 (-4,056줄) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
524 lines
14 KiB
TypeScript
524 lines
14 KiB
TypeScript
/**
|
|
* 작업자 화면 서버 액션
|
|
* API 연동 완료 (2025-12-26)
|
|
*
|
|
* WorkOrders API를 호출하고 WorkerScreen에 맞는 형식으로 변환
|
|
*/
|
|
|
|
'use server';
|
|
|
|
|
|
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
|
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
|
import type { WorkOrder, WorkOrderStatus } from '../ProductionDashboard/types';
|
|
|
|
// ===== API 타입 =====
|
|
interface WorkOrderApiItem {
|
|
id: number;
|
|
work_order_no: string;
|
|
project_name: string | null;
|
|
process_type: 'screen' | 'slat' | 'bending';
|
|
status: 'unassigned' | 'pending' | 'waiting' | 'in_progress' | 'completed' | 'shipped';
|
|
scheduled_date: string | null;
|
|
memo: string | null;
|
|
created_at: string;
|
|
sales_order?: {
|
|
id: number;
|
|
order_no: string;
|
|
client?: { id: number; name: string };
|
|
};
|
|
assignee?: { id: number; name: string };
|
|
items?: { id: number; item_name: string; quantity: number }[];
|
|
}
|
|
|
|
// ===== 상태 변환 =====
|
|
function mapApiStatus(status: WorkOrderApiItem['status']): WorkOrderStatus {
|
|
switch (status) {
|
|
case 'in_progress':
|
|
return 'inProgress';
|
|
case 'completed':
|
|
case 'shipped':
|
|
return 'completed';
|
|
default:
|
|
return 'waiting';
|
|
}
|
|
}
|
|
|
|
// ===== API → WorkOrder 변환 =====
|
|
function transformToWorkerScreenFormat(api: WorkOrderApiItem): WorkOrder {
|
|
const totalQuantity = (api.items || []).reduce((sum, item) => sum + item.quantity, 0);
|
|
const productName = api.items?.[0]?.item_name || '-';
|
|
|
|
// 납기일 계산 (지연 여부)
|
|
const dueDate = api.scheduled_date || '';
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
const due = dueDate ? new Date(dueDate) : null;
|
|
const isDelayed = due ? due < today : false;
|
|
const delayDays = due && isDelayed
|
|
? Math.ceil((today.getTime() - due.getTime()) / (1000 * 60 * 60 * 24))
|
|
: undefined;
|
|
|
|
return {
|
|
id: String(api.id),
|
|
orderNo: api.work_order_no,
|
|
productName,
|
|
process: api.process_type,
|
|
client: api.sales_order?.client?.name || '-',
|
|
projectName: api.project_name || '-',
|
|
assignees: api.assignee ? [api.assignee.name] : [],
|
|
quantity: totalQuantity,
|
|
dueDate,
|
|
priority: 5, // 기본 우선순위
|
|
status: mapApiStatus(api.status),
|
|
isUrgent: false, // 긴급 여부는 별도 필드 필요
|
|
isDelayed,
|
|
delayDays,
|
|
instruction: api.memo || undefined,
|
|
createdAt: api.created_at,
|
|
};
|
|
}
|
|
|
|
// ===== 내 작업 목록 조회 =====
|
|
export async function getMyWorkOrders(): Promise<{
|
|
success: boolean;
|
|
data: WorkOrder[];
|
|
error?: string;
|
|
}> {
|
|
try {
|
|
// 작업 대기 + 작업중 상태만 조회 (완료 제외)
|
|
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders?per_page=100&assigned_to_me=1`;
|
|
|
|
console.log('[WorkerScreenActions] GET my work orders:', url);
|
|
|
|
const { response, error } = await serverFetch(url, { method: 'GET' });
|
|
|
|
if (error || !response) {
|
|
console.warn('[WorkerScreenActions] GET error:', error?.message);
|
|
return {
|
|
success: false,
|
|
data: [],
|
|
error: error?.message || '네트워크 오류가 발생했습니다.',
|
|
};
|
|
}
|
|
|
|
if (!response.ok) {
|
|
console.warn('[WorkerScreenActions] GET error:', response.status);
|
|
return {
|
|
success: false,
|
|
data: [],
|
|
error: `API 오류: ${response.status}`,
|
|
};
|
|
}
|
|
|
|
const result = await response.json();
|
|
|
|
if (!result.success) {
|
|
return {
|
|
success: false,
|
|
data: [],
|
|
error: result.message || '작업 목록 조회에 실패했습니다.',
|
|
};
|
|
}
|
|
|
|
const apiData = result.data?.data || [];
|
|
|
|
// 완료/출하 상태 제외하고 변환
|
|
const workOrders = apiData
|
|
.filter((item: WorkOrderApiItem) => !['completed', 'shipped'].includes(item.status))
|
|
.map(transformToWorkerScreenFormat);
|
|
|
|
return {
|
|
success: true,
|
|
data: workOrders,
|
|
};
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('[WorkerScreenActions] getMyWorkOrders error:', error);
|
|
return {
|
|
success: false,
|
|
data: [],
|
|
error: '서버 오류가 발생했습니다.',
|
|
};
|
|
}
|
|
}
|
|
|
|
// ===== 작업 완료 처리 =====
|
|
export async function completeWorkOrder(
|
|
id: string,
|
|
materials?: { materialId: number; quantity: number; lotNo?: string }[]
|
|
): Promise<{ success: boolean; lotNo?: string; error?: string }> {
|
|
try {
|
|
// 상태를 completed로 변경
|
|
const { response, error } = await serverFetch(
|
|
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${id}/status`,
|
|
{
|
|
method: 'PATCH',
|
|
body: JSON.stringify({
|
|
status: 'completed',
|
|
materials,
|
|
}),
|
|
}
|
|
);
|
|
|
|
if (error || !response) {
|
|
return {
|
|
success: false,
|
|
error: error?.message || '네트워크 오류가 발생했습니다.',
|
|
};
|
|
}
|
|
|
|
const result = await response.json();
|
|
console.log('[WorkerScreenActions] Complete response:', result);
|
|
|
|
if (!response.ok || !result.success) {
|
|
return {
|
|
success: false,
|
|
error: result.message || '작업 완료 처리에 실패했습니다.',
|
|
};
|
|
}
|
|
|
|
// LOT 번호 생성 (임시)
|
|
const lotNo = `KD-SA-${new Date().toISOString().slice(2, 10).replace(/-/g, '')}-01`;
|
|
|
|
return {
|
|
success: true,
|
|
lotNo,
|
|
};
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('[WorkerScreenActions] completeWorkOrder error:', error);
|
|
return {
|
|
success: false,
|
|
error: '서버 오류가 발생했습니다.',
|
|
};
|
|
}
|
|
}
|
|
|
|
// ===== 자재 목록 조회 (BOM 기준) =====
|
|
export interface MaterialForInput {
|
|
id: number;
|
|
materialCode: string;
|
|
materialName: string;
|
|
unit: string;
|
|
currentStock: number;
|
|
fifoRank: number;
|
|
}
|
|
|
|
export async function getMaterialsForWorkOrder(
|
|
workOrderId: string
|
|
): Promise<{
|
|
success: boolean;
|
|
data: MaterialForInput[];
|
|
error?: string;
|
|
}> {
|
|
try {
|
|
// 작업지시 BOM 기준 자재 목록 조회
|
|
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${workOrderId}/materials`;
|
|
|
|
console.log('[WorkerScreenActions] GET materials for work order:', url);
|
|
|
|
const { response, error } = await serverFetch(url, { method: 'GET' });
|
|
|
|
if (error || !response) {
|
|
console.warn('[WorkerScreenActions] GET materials error:', error?.message);
|
|
return {
|
|
success: false,
|
|
data: [],
|
|
error: error?.message || '네트워크 오류가 발생했습니다.',
|
|
};
|
|
}
|
|
|
|
if (!response.ok) {
|
|
console.warn('[WorkerScreenActions] GET materials error:', response.status);
|
|
return {
|
|
success: false,
|
|
data: [],
|
|
error: `API 오류: ${response.status}`,
|
|
};
|
|
}
|
|
|
|
const result = await response.json();
|
|
|
|
if (!result.success) {
|
|
return {
|
|
success: false,
|
|
data: [],
|
|
error: result.message || '자재 목록 조회에 실패했습니다.',
|
|
};
|
|
}
|
|
|
|
// API 응답을 MaterialForInput 형식으로 변환
|
|
const materials: MaterialForInput[] = (result.data || []).map((item: {
|
|
id: number;
|
|
material_code: string;
|
|
material_name: string;
|
|
unit: string;
|
|
current_stock: number;
|
|
fifo_rank: number;
|
|
}) => ({
|
|
id: item.id,
|
|
materialCode: item.material_code,
|
|
materialName: item.material_name,
|
|
unit: item.unit,
|
|
currentStock: item.current_stock,
|
|
fifoRank: item.fifo_rank,
|
|
}));
|
|
|
|
return {
|
|
success: true,
|
|
data: materials,
|
|
};
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('[WorkerScreenActions] getMaterialsForWorkOrder error:', error);
|
|
return {
|
|
success: false,
|
|
data: [],
|
|
error: '서버 오류가 발생했습니다.',
|
|
};
|
|
}
|
|
}
|
|
|
|
// ===== 자재 투입 등록 =====
|
|
export async function registerMaterialInput(
|
|
workOrderId: string,
|
|
materialIds: number[]
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
try {
|
|
const { response, error } = await serverFetch(
|
|
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${workOrderId}/material-inputs`,
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify({ material_ids: materialIds }),
|
|
}
|
|
);
|
|
|
|
if (error || !response) {
|
|
return {
|
|
success: false,
|
|
error: error?.message || '네트워크 오류가 발생했습니다.',
|
|
};
|
|
}
|
|
|
|
const result = await response.json();
|
|
console.log('[WorkerScreenActions] Register material input response:', result);
|
|
|
|
if (!response.ok || !result.success) {
|
|
return {
|
|
success: false,
|
|
error: result.message || '자재 투입 등록에 실패했습니다.',
|
|
};
|
|
}
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('[WorkerScreenActions] registerMaterialInput error:', error);
|
|
return {
|
|
success: false,
|
|
error: '서버 오류가 발생했습니다.',
|
|
};
|
|
}
|
|
}
|
|
|
|
// ===== 이슈 보고 =====
|
|
export async function reportIssue(
|
|
workOrderId: string,
|
|
data: {
|
|
title: string;
|
|
description?: string;
|
|
priority?: 'low' | 'medium' | 'high';
|
|
}
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
try {
|
|
const { response, error } = await serverFetch(
|
|
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${workOrderId}/issues`,
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}
|
|
);
|
|
|
|
if (error || !response) {
|
|
return {
|
|
success: false,
|
|
error: error?.message || '네트워크 오류가 발생했습니다.',
|
|
};
|
|
}
|
|
|
|
const result = await response.json();
|
|
console.log('[WorkerScreenActions] Report issue response:', result);
|
|
|
|
if (!response.ok || !result.success) {
|
|
return {
|
|
success: false,
|
|
error: result.message || '이슈 보고에 실패했습니다.',
|
|
};
|
|
}
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('[WorkerScreenActions] reportIssue error:', error);
|
|
return {
|
|
success: false,
|
|
error: '서버 오류가 발생했습니다.',
|
|
};
|
|
}
|
|
}
|
|
|
|
// ===== 공정 단계 조회 =====
|
|
export interface ProcessStepItem {
|
|
id: string;
|
|
itemNo: string;
|
|
location: string;
|
|
isPriority: boolean;
|
|
spec: string;
|
|
material: string;
|
|
lot: string;
|
|
}
|
|
|
|
export interface ProcessStep {
|
|
id: string;
|
|
stepNo: number;
|
|
name: string;
|
|
isInspection?: boolean;
|
|
completed: number;
|
|
total: number;
|
|
items: ProcessStepItem[];
|
|
}
|
|
|
|
export async function getProcessSteps(
|
|
workOrderId: string
|
|
): Promise<{
|
|
success: boolean;
|
|
data: ProcessStep[];
|
|
error?: string;
|
|
}> {
|
|
try {
|
|
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${workOrderId}/process-steps`;
|
|
|
|
console.log('[WorkerScreenActions] GET process steps:', url);
|
|
|
|
const { response, error } = await serverFetch(url, { method: 'GET' });
|
|
|
|
if (error || !response) {
|
|
console.warn('[WorkerScreenActions] GET process steps error:', error?.message);
|
|
return {
|
|
success: false,
|
|
data: [],
|
|
error: error?.message || '네트워크 오류가 발생했습니다.',
|
|
};
|
|
}
|
|
|
|
if (!response.ok) {
|
|
console.warn('[WorkerScreenActions] GET process steps error:', response.status);
|
|
return {
|
|
success: false,
|
|
data: [],
|
|
error: `API 오류: ${response.status}`,
|
|
};
|
|
}
|
|
|
|
const result = await response.json();
|
|
|
|
if (!result.success) {
|
|
return {
|
|
success: false,
|
|
data: [],
|
|
error: result.message || '공정 단계 조회에 실패했습니다.',
|
|
};
|
|
}
|
|
|
|
// API 응답을 ProcessStep 형식으로 변환
|
|
const steps: ProcessStep[] = (result.data || []).map((step: {
|
|
id: number;
|
|
step_no: number;
|
|
name: string;
|
|
is_inspection?: boolean;
|
|
completed: number;
|
|
total: number;
|
|
items?: {
|
|
id: number;
|
|
item_no: string;
|
|
location: string;
|
|
is_priority: boolean;
|
|
spec: string;
|
|
material: string;
|
|
lot: string;
|
|
}[];
|
|
}) => ({
|
|
id: String(step.id),
|
|
stepNo: step.step_no,
|
|
name: step.name,
|
|
isInspection: step.is_inspection,
|
|
completed: step.completed,
|
|
total: step.total,
|
|
items: (step.items || []).map((item) => ({
|
|
id: String(item.id),
|
|
itemNo: item.item_no,
|
|
location: item.location,
|
|
isPriority: item.is_priority,
|
|
spec: item.spec,
|
|
material: item.material,
|
|
lot: item.lot,
|
|
})),
|
|
}));
|
|
|
|
return {
|
|
success: true,
|
|
data: steps,
|
|
};
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('[WorkerScreenActions] getProcessSteps error:', error);
|
|
return {
|
|
success: false,
|
|
data: [],
|
|
error: '서버 오류가 발생했습니다.',
|
|
};
|
|
}
|
|
}
|
|
|
|
// ===== 검사 요청 =====
|
|
export async function requestInspection(
|
|
workOrderId: string,
|
|
stepId: string
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
try {
|
|
const { response, error } = await serverFetch(
|
|
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${workOrderId}/process-steps/${stepId}/inspection-request`,
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify({}),
|
|
}
|
|
);
|
|
|
|
if (error || !response) {
|
|
return {
|
|
success: false,
|
|
error: error?.message || '네트워크 오류가 발생했습니다.',
|
|
};
|
|
}
|
|
|
|
const result = await response.json();
|
|
console.log('[WorkerScreenActions] Inspection request response:', result);
|
|
|
|
if (!response.ok || !result.success) {
|
|
return {
|
|
success: false,
|
|
error: result.message || '검사 요청에 실패했습니다.',
|
|
};
|
|
}
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('[WorkerScreenActions] requestInspection error:', error);
|
|
return {
|
|
success: false,
|
|
error: '서버 오류가 발생했습니다.',
|
|
};
|
|
}
|
|
} |