Files
sam-react-prod/src/components/production/WorkerScreen/actions.ts
권혁성 e508014224 fix(WEB): Turbopack use server 파일 간 export type 런타임 에러 수정
- 검사 템플릿 타입(InspectionTemplateData 등)을 WorkerScreen/types.ts로 분리
- use server 파일에서 export type 제거 (Turbopack 모듈 평가 시 값으로 처리되는 문제)
- 모든 타입 import를 types.ts 직접 참조로 변경
2026-02-10 19:27:45 +09:00

555 lines
20 KiB
TypeScript

/**
* 작업자 화면 서버 액션
* API 연동 (2025-12-26 초기, 2026-02-06 options + step-progress 확장)
*
* WorkOrders API를 호출하고 WorkerScreen에 맞는 형식으로 변환
*/
'use server';
import { executeServerAction } from '@/lib/api/execute-server-action';
import type { WorkOrder, WorkOrderStatus } from '../ProductionDashboard/types';
import type { WorkItemData, WorkStepData, ProcessTab } from './types';
// ===== API 타입 =====
interface WorkOrderApiItem {
id: number;
work_order_no: string;
project_name: string | null;
process_id: number | null;
process?: {
id: number;
process_name: string;
process_code: string;
department?: string | null;
};
/** @deprecated process_id + process relation 사용 */
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 };
root_nodes_count?: number;
};
assignee?: { id: number; name: string };
items?: {
id: number;
item_name: string;
quantity: number;
specification?: string | null;
options?: Record<string, unknown> | null;
source_order_item?: {
id: number;
order_node_id: number | null;
node?: { id: number; name: string; code: string } | null;
} | null;
}[];
}
// ===== 상태 변환 =====
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 + Number(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;
// process relation → processCode/processName 매핑
// 신규: process relation 사용, 폴백: deprecated process_type 필드
const rawProcessName = api.process?.process_name || '';
const rawProcessCode = api.process?.process_code || '';
// 한글 공정명 → 탭 키 매핑 (필터링에 사용)
const nameToTab: Record<string, { code: string; name: string }> = {
'스크린': { code: 'screen', name: '스크린' },
'슬랫': { code: 'slat', name: '슬랫' },
'절곡': { code: 'bending', name: '절곡' },
};
// 1차: process relation의 process_name으로 매핑
let processInfo = Object.entries(nameToTab).find(
([key]) => rawProcessName.includes(key)
)?.[1];
// 2차: deprecated process_type 폴백
if (!processInfo && api.process_type) {
const legacyMap: Record<string, { code: string; name: string }> = {
screen: { code: 'screen', name: '스크린' },
slat: { code: 'slat', name: '슬랫' },
bending: { code: 'bending', name: '절곡' },
};
processInfo = legacyMap[api.process_type];
}
// 3차: 그래도 없으면 원시값 사용
if (!processInfo) {
processInfo = { code: rawProcessCode || 'unknown', name: rawProcessName || '알수없음' };
}
// 아이템을 개소(node)별로 그룹핑
const nodeMap = new Map<string, { nodeId: number | null; nodeName: string; items: typeof api.items }>();
for (const item of (api.items || [])) {
const nodeId = item.source_order_item?.order_node_id ?? null;
const nodeName = item.source_order_item?.node?.name || '미지정';
const key = nodeId != null ? String(nodeId) : 'unassigned';
if (!nodeMap.has(key)) {
nodeMap.set(key, { nodeId, nodeName, items: [] });
}
nodeMap.get(key)!.items!.push(item);
}
const nodeGroups = Array.from(nodeMap.values()).map((g) => ({
nodeId: g.nodeId,
nodeName: g.nodeName,
items: (g.items || []).map((it) => ({
id: it.id,
itemName: it.item_name,
quantity: Number(it.quantity),
specification: it.specification,
options: it.options,
})),
totalQuantity: (g.items || []).reduce((sum, it) => sum + Number(it.quantity), 0),
}));
return {
id: String(api.id),
orderNo: api.work_order_no,
productName,
processCode: processInfo.code,
processName: processInfo.name,
client: api.sales_order?.client?.name || '-',
projectName: api.project_name || '-',
assignees: api.assignee ? [api.assignee.name] : [],
quantity: totalQuantity,
shutterCount: api.sales_order?.root_nodes_count || 0,
dueDate,
priority: 5, // 기본 우선순위
status: mapApiStatus(api.status),
isUrgent: false, // 긴급 여부는 별도 필드 필요
isDelayed,
delayDays,
instruction: api.memo || undefined,
salesOrderNo: api.sales_order?.order_no || undefined,
createdAt: api.created_at,
nodeGroups,
};
}
const API_URL = process.env.NEXT_PUBLIC_API_URL;
// ===== 내 작업 목록 조회 =====
export async function getMyWorkOrders(): Promise<{
success: boolean;
data: WorkOrder[];
error?: string;
}> {
interface PaginatedWO { data: WorkOrderApiItem[] }
const result = await executeServerAction<PaginatedWO>({
url: `${API_URL}/api/v1/work-orders?per_page=100&worker_screen=1`,
errorMessage: '작업 목록 조회에 실패했습니다.',
});
if (!result.success || !result.data) return { success: false, data: [], error: result.error };
const workOrders = (result.data.data || [])
.filter((item) => !['completed', 'shipped'].includes(item.status))
.map(transformToWorkerScreenFormat);
return { success: true, data: workOrders };
}
// ===== 작업 완료 처리 =====
export async function completeWorkOrder(
id: string,
materials?: { materialId: number; quantity: number; lotNo?: string }[]
): Promise<{ success: boolean; lotNo?: string; error?: string }> {
const result = await executeServerAction({
url: `${API_URL}/api/v1/work-orders/${id}/status`,
method: 'PATCH',
body: { status: 'completed', materials },
errorMessage: '작업 완료 처리에 실패했습니다.',
});
if (!result.success) return { success: false, error: result.error };
const lotNo = `KD-SA-${new Date().toISOString().slice(2, 10).replace(/-/g, '')}-01`;
return { success: true, lotNo };
}
// ===== 자재 목록 조회 (로트 기준) =====
export interface MaterialForInput {
stockLotId: number | null; // StockLot ID (null이면 재고 없음)
itemId: number;
lotNo: string | null; // 실제 입고 로트번호
materialCode: string;
materialName: string;
specification: string;
unit: string;
requiredQty: number; // 필요 수량
lotAvailableQty: number; // 로트별 가용 수량
fifoRank: number;
}
export async function getMaterialsForWorkOrder(
workOrderId: string
): Promise<{
success: boolean;
data: MaterialForInput[];
error?: string;
}> {
interface MaterialApiItem {
stock_lot_id: number | null; item_id: number; lot_no: string | null;
material_code: string; material_name: string; specification: string;
unit: string; required_qty: number; lot_available_qty: number; fifo_rank: number;
}
const result = await executeServerAction<MaterialApiItem[]>({
url: `${API_URL}/api/v1/work-orders/${workOrderId}/materials`,
errorMessage: '자재 목록 조회에 실패했습니다.',
});
if (!result.success || !result.data) return { success: false, data: [], error: result.error };
return {
success: true,
data: result.data.map((item) => ({
stockLotId: item.stock_lot_id, itemId: item.item_id, lotNo: item.lot_no,
materialCode: item.material_code, materialName: item.material_name,
specification: item.specification ?? '', unit: item.unit,
requiredQty: item.required_qty, lotAvailableQty: item.lot_available_qty, fifoRank: item.fifo_rank,
})),
};
}
// ===== 자재 투입 등록 (로트별 수량) =====
export async function registerMaterialInput(
workOrderId: string,
inputs: { stock_lot_id: number; qty: number }[]
): Promise<{ success: boolean; error?: string }> {
const result = await executeServerAction({
url: `${API_URL}/api/v1/work-orders/${workOrderId}/material-inputs`,
method: 'POST',
body: { inputs },
errorMessage: '자재 투입 등록에 실패했습니다.',
});
return { success: result.success, error: result.error };
}
// ===== 이슈 보고 =====
export async function reportIssue(
workOrderId: string,
data: {
title: string;
description?: string;
priority?: 'low' | 'medium' | 'high';
}
): Promise<{ success: boolean; error?: string }> {
const result = await executeServerAction({
url: `${API_URL}/api/v1/work-orders/${workOrderId}/issues`,
method: 'POST',
body: data,
errorMessage: '이슈 보고에 실패했습니다.',
});
return { success: result.success, error: result.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;
}> {
interface StepApiItem {
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 }[];
}
const result = await executeServerAction<StepApiItem[]>({
url: `${API_URL}/api/v1/work-orders/${workOrderId}/process-steps`,
errorMessage: '공정 단계 조회에 실패했습니다.',
});
if (!result.success || !result.data) return { success: false, data: [], error: result.error };
return {
success: true,
data: result.data.map((step) => ({
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,
})),
})),
};
}
// ===== 검사 요청 =====
export async function requestInspection(
workOrderId: string,
stepId: string
): Promise<{ success: boolean; error?: string }> {
const result = await executeServerAction({
url: `${API_URL}/api/v1/work-orders/${workOrderId}/process-steps/${stepId}/inspection-request`,
method: 'POST',
body: {},
errorMessage: '검사 요청에 실패했습니다.',
});
return { success: result.success, error: result.error };
}
// ===== 공정 단계 진행 현황 조회 =====
export interface StepProgressItem {
id: number;
process_step_id: number;
step_code: string;
step_name: string;
sort_order: number;
needs_inspection: boolean;
connection_type: string | null;
completion_type: string | null;
status: string;
is_completed: boolean;
completed_at: string | null;
completed_by: number | null;
}
export async function getStepProgress(
workOrderId: string
): Promise<{
success: boolean;
data: StepProgressItem[];
error?: string;
}> {
const result = await executeServerAction<StepProgressItem[]>({
url: `${API_URL}/api/v1/work-orders/${workOrderId}/step-progress`,
errorMessage: '단계 진행 조회에 실패했습니다.',
});
if (!result.success || !result.data) return { success: false, data: [], error: result.error };
return { success: true, data: result.data };
}
// ===== 공정 단계 완료 토글 =====
export async function toggleStepProgress(
workOrderId: string,
progressId: number
): Promise<{ success: boolean; data?: StepProgressItem; error?: string }> {
const result = await executeServerAction<StepProgressItem>({
url: `${API_URL}/api/v1/work-orders/${workOrderId}/step-progress/${progressId}/toggle`,
method: 'PATCH',
errorMessage: '단계 토글에 실패했습니다.',
});
return { success: result.success, data: result.data, error: result.error };
}
// ===== 작업지시 상세 조회 (items + options 포함) =====
export async function getWorkOrderDetail(
workOrderId: string
): Promise<{
success: boolean;
data: WorkItemData[];
error?: string;
}> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await executeServerAction<any>({
url: `${API_URL}/api/v1/work-orders/${workOrderId}`,
errorMessage: '작업지시 상세 조회에 실패했습니다.',
});
if (!result.success || !result.data) return { success: false, data: [], error: result.error };
const wo = result.data;
const processName = (wo.process?.process_name || '').toLowerCase();
const processType: ProcessTab =
processName.includes('스크린') ? 'screen' :
processName.includes('슬랫') ? 'slat' :
processName.includes('절곡') ? 'bending' : 'screen';
const items: WorkItemData[] = (wo.items || []).map((item: {
id: number; item_name: string; specification: string | null;
quantity: string; unit: string | null; status: string;
sort_order: number; options: Record<string, unknown> | null;
}, index: number) => {
const opts = item.options || {};
const stepProgressList = wo.step_progress || [];
const processSteps = wo.process?.steps || [];
let steps: WorkStepData[];
if (stepProgressList.length > 0) {
steps = stepProgressList
.filter((sp: { work_order_item_id: number | null }) => !sp.work_order_item_id || sp.work_order_item_id === item.id)
.map((sp: { id: number; process_step: { step_name: string; step_code: string } | null; status: string }) => ({
id: String(sp.id),
name: sp.process_step?.step_name || '',
isMaterialInput: (sp.process_step?.step_code || '').includes('MAT') || (sp.process_step?.step_name || '').includes('자재투입'),
isCompleted: sp.status === 'completed',
}));
} else {
steps = processSteps.map((ps: { id: number; step_name: string; step_code: string }, si: number) => ({
id: `${item.id}-step-${si}`,
name: ps.step_name,
isMaterialInput: ps.step_code.includes('MAT') || ps.step_name.includes('자재투입'),
isCompleted: false,
}));
}
const workItem: WorkItemData = {
id: String(item.id), itemNo: index + 1,
itemCode: wo.work_order_no || '-', itemName: item.item_name || '-',
floor: (opts.floor as string) || '-', code: (opts.code as string) || '-',
width: (opts.width as number) || 0, height: (opts.height as number) || 0,
quantity: Number(item.quantity) || 0, processType, steps, materialInputs: [],
};
if (opts.cutting_info) {
const ci = opts.cutting_info as { width: number; sheets: number };
workItem.cuttingInfo = { width: ci.width, sheets: ci.sheets };
}
if (opts.slat_info) {
const si = opts.slat_info as { length: number; slat_count: number; joint_bar: number };
workItem.slatInfo = { length: si.length, slatCount: si.slat_count, jointBar: si.joint_bar };
}
if (opts.bending_info) {
const bi = opts.bending_info as {
common: { kind: string; type: string; length_quantities: { length: number; quantity: number }[] };
detail_parts: { part_name: string; material: string; barcy_info: string }[];
};
workItem.bendingInfo = {
common: { kind: bi.common.kind, type: bi.common.type, lengthQuantities: bi.common.length_quantities || [] },
detailParts: (bi.detail_parts || []).map(dp => ({ partName: dp.part_name, material: dp.material, barcyInfo: dp.barcy_info })),
};
}
if (opts.is_wip) {
workItem.isWip = true;
const wi = opts.wip_info as { specification: string; length_quantity: string; drawing_url?: string } | undefined;
if (wi) {
workItem.wipInfo = { specification: wi.specification, lengthQuantity: wi.length_quantity, drawingUrl: wi.drawing_url };
}
}
if (opts.is_joint_bar) {
workItem.isJointBar = true;
const jb = opts.slat_joint_bar_info as { specification: string; length: number; quantity: number } | undefined;
if (jb) workItem.slatJointBarInfo = jb;
}
return workItem;
});
return { success: true, data: items };
}
// ===== 개소별 중간검사 데이터 저장 =====
export async function saveItemInspection(
workOrderId: string,
itemId: number,
processType: string,
inspectionData: Record<string, unknown>
): Promise<{ success: boolean; data?: Record<string, unknown>; error?: string }> {
const result = await executeServerAction<Record<string, unknown>>({
url: `${API_URL}/api/v1/work-orders/${workOrderId}/items/${itemId}/inspection`,
method: 'POST',
body: { process_type: processType, inspection_data: inspectionData },
errorMessage: '검사 데이터 저장에 실패했습니다.',
});
return { success: result.success, data: result.data, error: result.error };
}
// ===== 작업지시 전체 검사 데이터 조회 =====
export interface InspectionDataItem {
item_id: number;
item_name: string;
specification: string | null;
quantity: number;
sort_order: number;
options: Record<string, unknown> | null;
inspection_data: Record<string, unknown>;
}
export async function getWorkOrderInspectionData(
workOrderId: string
): Promise<{
success: boolean;
data?: { work_order_id: number; items: InspectionDataItem[]; total: number };
error?: string;
}> {
const result = await executeServerAction<{ work_order_id: number; items: InspectionDataItem[]; total: number }>({
url: `${API_URL}/api/v1/work-orders/${workOrderId}/inspection-data`,
errorMessage: '검사 데이터 조회에 실패했습니다.',
});
return { success: result.success, data: result.data, error: result.error };
}
// ===== 검사 문서 템플릿 타입 (types.ts에서 import) =====
import type { InspectionTemplateData } from './types';
export async function getInspectionTemplate(
workOrderId: string
): Promise<{
success: boolean;
data?: InspectionTemplateData;
error?: string;
}> {
const result = await executeServerAction<InspectionTemplateData>({
url: `${API_URL}/api/v1/work-orders/${workOrderId}/inspection-template`,
errorMessage: '검사 템플릿 조회에 실패했습니다.',
});
return { success: result.success, data: result.data, error: result.error };
}
// ===== 검사 문서 저장 (Document + DocumentData) =====
export async function saveInspectionDocument(
workOrderId: string,
data: {
title?: string;
data: Record<string, unknown>[];
approvers?: { role_name: string; user_id?: number }[];
}
): Promise<{
success: boolean;
data?: { document_id: number; document_no: string; status: string };
error?: string;
}> {
const result = await executeServerAction<{ document_id: number; document_no: string; status: string }>({
url: `${API_URL}/api/v1/work-orders/${workOrderId}/inspection-document`,
method: 'POST',
body: data,
errorMessage: '검사 문서 저장에 실패했습니다.',
});
return { success: result.success, data: result.data, error: result.error };
}