refactor(work-orders): process_type을 process_id FK로 변환

- types.ts: processId, processName, processCode 추가, transform 함수 구현
- actions.ts: getProcessOptions() 추가, CRUD에 transform 적용
- WorkOrderCreate.tsx: 공정 목록 API 동적 로딩
- WorkOrderList.tsx: processName 표시로 변경
- WorkOrderDetail.tsx: processName 표시, processType은 로직용 유지
This commit is contained in:
2026-01-09 16:28:49 +09:00
parent 9d30555265
commit 78e193c8df
6 changed files with 227 additions and 37 deletions

View File

@@ -5,7 +5,7 @@
* API 연동 완료 (2025-12-26)
*/
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { ArrowLeft, FileText, X, Edit2, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -25,8 +25,8 @@ import { PageLayout } from '@/components/organisms/PageLayout';
import { SalesOrderSelectModal } from './SalesOrderSelectModal';
import { AssigneeSelectModal } from './AssigneeSelectModal';
import { toast } from 'sonner';
import { createWorkOrder } from './actions';
import { PROCESS_TYPE_LABELS, type ProcessType, type SalesOrder } from './types';
import { createWorkOrder, getProcessOptions, type ProcessOption } from './actions';
import { type SalesOrder } from './types';
// Validation 에러 타입
interface ValidationErrors {
@@ -38,6 +38,7 @@ const FIELD_NAME_MAP: Record<string, string> = {
selectedOrder: '수주',
client: '발주처',
projectName: '현장명',
processId: '공정',
shipmentDate: '출고예정일',
};
@@ -55,7 +56,7 @@ interface FormData {
itemCount: number;
// 작업지시 정보
processType: ProcessType;
processId: number | null; // 공정 ID (FK → processes.id)
shipmentDate: string;
priority: number;
assignees: string[];
@@ -71,7 +72,7 @@ const initialFormData: FormData = {
projectName: '',
orderNo: '',
itemCount: 0,
processType: 'screen',
processId: null,
shipmentDate: '',
priority: 5,
assignees: [],
@@ -87,6 +88,27 @@ export function WorkOrderCreate() {
const [assigneeNames, setAssigneeNames] = useState<string[]>([]);
const [validationErrors, setValidationErrors] = useState<ValidationErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [processOptions, setProcessOptions] = useState<ProcessOption[]>([]);
const [isLoadingProcesses, setIsLoadingProcesses] = useState(true);
// 공정 옵션 로드
useEffect(() => {
async function loadProcessOptions() {
setIsLoadingProcesses(true);
const result = await getProcessOptions();
if (result.success) {
setProcessOptions(result.data);
// 첫 번째 공정을 기본값으로 설정
if (result.data.length > 0 && !formData.processId) {
setFormData(prev => ({ ...prev, processId: result.data[0].id }));
}
} else {
toast.error(result.error || '공정 목록을 불러오는데 실패했습니다.');
}
setIsLoadingProcesses(false);
}
loadProcessOptions();
}, []);
// 수주 선택 핸들러
const handleSelectOrder = (order: SalesOrder) => {
@@ -104,7 +126,7 @@ export function WorkOrderCreate() {
const handleClearOrder = () => {
setFormData({
...initialFormData,
processType: formData.processType,
processId: formData.processId,
shipmentDate: formData.shipmentDate,
priority: formData.priority,
});
@@ -128,6 +150,10 @@ export function WorkOrderCreate() {
}
}
if (!formData.processId) {
errors.processId = '공정을 선택해주세요';
}
if (!formData.shipmentDate) {
errors.shipmentDate = '출고예정일을 선택해주세요';
}
@@ -149,7 +175,7 @@ export function WorkOrderCreate() {
const result = await createWorkOrder({
salesOrderId: formData.selectedOrder?.id ? parseInt(formData.selectedOrder.id) : undefined,
projectName: formData.projectName,
processType: formData.processType,
processId: formData.processId!, // 공정 ID (FK → processes.id)
scheduledDate: formData.shipmentDate,
assigneeIds: formData.assignees.map(id => parseInt(id)),
memo: formData.note || undefined,
@@ -174,14 +200,10 @@ export function WorkOrderCreate() {
router.back();
};
// 공정 코드 표시
const getProcessCode = (type: ProcessType) => {
const codes: Record<ProcessType, string> = {
screen: 'P-001 | 작업일지: WL-SCR',
slat: 'P-002 | 작업일지: WL-SLT',
bending: 'P-003 | 작업일지: WL-FLD',
};
return codes[type];
// 선택된 공정 코드 가져오기
const getSelectedProcessCode = (): string => {
const selectedProcess = processOptions.find(p => p.id === formData.processId);
return selectedProcess?.processCode || '-';
};
return (
@@ -396,22 +418,23 @@ export function WorkOrderCreate() {
<div className="space-y-2">
<Label> *</Label>
<Select
value={formData.processType}
onValueChange={(value) => setFormData({ ...formData, processType: value as ProcessType })}
value={formData.processId?.toString() || ''}
onValueChange={(value) => setFormData({ ...formData, processId: parseInt(value) })}
disabled={isLoadingProcesses}
>
<SelectTrigger className="bg-white">
<SelectValue />
<SelectValue placeholder={isLoadingProcesses ? '로딩 중...' : '공정을 선택하세요'} />
</SelectTrigger>
<SelectContent>
{Object.entries(PROCESS_TYPE_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key}>
{label}
{processOptions.map((process) => (
<SelectItem key={process.id} value={process.id.toString()}>
{process.processName}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
: {getProcessCode(formData.processType)}
: {getSelectedProcessCode()}
</p>
</div>

View File

@@ -23,7 +23,6 @@ import { WorkLogModal } from '../WorkerScreen/WorkLogModal';
import { toast } from 'sonner';
import { getWorkOrderById, updateWorkOrderStatus } from './actions';
import {
PROCESS_TYPE_LABELS,
WORK_ORDER_STATUS_LABELS,
WORK_ORDER_STATUS_COLORS,
ITEM_STATUS_LABELS,
@@ -344,7 +343,7 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
</div>
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{PROCESS_TYPE_LABELS[order.processType]}</p>
<p className="font-medium">{order.processName}</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1"></p>

View File

@@ -23,7 +23,6 @@ import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard
import { toast } from 'sonner';
import { getWorkOrders, getWorkOrderStats } from './actions';
import {
PROCESS_TYPE_LABELS,
WORK_ORDER_STATUS_LABELS,
WORK_ORDER_STATUS_COLORS,
type WorkOrder,
@@ -248,7 +247,7 @@ export function WorkOrderList() {
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell className="font-medium">{order.workOrderNo}</TableCell>
<TableCell>{PROCESS_TYPE_LABELS[order.processType]}</TableCell>
<TableCell>{order.processName}</TableCell>
<TableCell>{order.lotNo}</TableCell>
<TableCell>{order.orderDate}</TableCell>
<TableCell className="text-center">{order.isAssigned ? 'Y' : '-'}</TableCell>
@@ -297,7 +296,7 @@ export function WorkOrderList() {
}
infoGrid={
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<InfoField label="공정" value={PROCESS_TYPE_LABELS[order.processType]} />
<InfoField label="공정" value={order.processName} />
<InfoField label="로트번호" value={order.lotNo} />
<InfoField label="발주처" value={order.client} />
<InfoField label="작업자" value={order.assignee || '-'} />

View File

@@ -24,7 +24,6 @@ import type {
WorkOrder,
WorkOrderStats,
WorkOrderStatus,
ProcessType,
WorkOrderApiPaginatedResponse,
WorkOrderStatsApi,
} from './types';
@@ -47,7 +46,7 @@ export async function getWorkOrders(params?: {
page?: number;
perPage?: number;
status?: WorkOrderStatus | 'all';
processType?: ProcessType | 'all';
processId?: number | 'all'; // 공정 ID (FK → processes.id)
search?: string;
startDate?: string;
endDate?: string;
@@ -71,8 +70,8 @@ export async function getWorkOrders(params?: {
if (params?.status && params.status !== 'all') {
searchParams.set('status', params.status);
}
if (params?.processType && params.processType !== 'all') {
searchParams.set('process_type', params.processType);
if (params?.processId && params.processId !== 'all') {
searchParams.set('process_id', String(params.processId));
}
if (params?.search) searchParams.set('search', params.search);
if (params?.startDate) searchParams.set('start_date', params.startDate);
@@ -727,3 +726,65 @@ export async function getDepartmentsWithUsers(): Promise<{
return { success: false, data: [], error: '서버 오류가 발생했습니다.' };
}
}
// ===== 공정 목록 조회 (작업지시 생성용) =====
export interface ProcessOption {
id: number;
processCode: string;
processName: string;
}
export async function getProcessOptions(): Promise<{
success: boolean;
data: ProcessOption[];
error?: string;
}> {
try {
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/processes/options`;
console.log('[WorkOrderActions] GET process options:', url);
const { response, error } = await serverFetch(url, { method: 'GET' });
if (error || !response) {
return { success: false, data: [], error: error?.message || 'API 요청 실패' };
}
if (!response.ok) {
console.warn('[WorkOrderActions] GET process options 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 응답 변환
const processes: ProcessOption[] = (result.data || []).map(
(item: {
id: number;
process_code: string;
process_name: string;
}) => ({
id: item.id,
processCode: item.process_code,
processName: item.process_name,
})
);
return {
success: true,
data: processes,
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[WorkOrderActions] getProcessOptions error:', error);
return { success: false, data: [], error: '서버 오류가 발생했습니다.' };
}
}

View File

@@ -2,7 +2,15 @@
* 작업지시 관리 타입 정의
*/
// 공정 구분
// 공정 정보 (API 관계)
export interface ProcessInfo {
id: number;
process_code: string;
process_name: string;
}
// @deprecated process_type은 process_id FK로 변경됨
// 하위 호환성을 위해 유지
export type ProcessType = 'screen' | 'slat' | 'bending';
export const PROCESS_TYPE_LABELS: Record<ProcessType, string> = {
@@ -139,7 +147,11 @@ export interface WorkOrder {
id: string;
workOrderNo: string; // 작업지시번호 (KD-WO-251217-12)
lotNo: string; // 로트번호 (KD-TS-251217-10)
processType: ProcessType; // 공정구분
processId: number; // 공정 ID (FK)
processName: string; // 공정명 (표시용)
processCode: string; // 공정코드 (표시용)
/** @deprecated process_id FK 사용 */
processType: ProcessType; // 하위 호환용
status: WorkOrderStatus; // 작업상태
// 기본 정보
@@ -272,7 +284,7 @@ export interface WorkOrderApi {
work_order_no: string;
sales_order_id: number | null;
project_name: string | null;
process_type: 'screen' | 'slat' | 'bending';
process_id: number; // FK to processes.id
status: 'unassigned' | 'pending' | 'waiting' | 'in_progress' | 'completed' | 'shipped';
assignee_id: number | null;
team_id: number | null;
@@ -290,6 +302,11 @@ export interface WorkOrderApi {
order_no: string;
client?: { id: number; name: string };
};
process?: {
id: number;
process_code: string;
process_name: string;
};
assignee?: { id: number; name: string };
assignees?: WorkOrderAssigneeApi[];
team?: { id: number; name: string };
@@ -333,11 +350,24 @@ export function transformApiToFrontend(api: WorkOrderApi): WorkOrder {
const primaryAssignee = assignees.find(a => a.isPrimary);
const assigneeName = primaryAssignee?.name || api.assignee?.name || '-';
// 공정명 → 하위호환용 processType 매핑
const processNameToType = (name: string): ProcessType => {
const mapping: Record<string, ProcessType> = {
'스크린': 'screen',
'슬랫': 'slat',
'절곡': 'bending',
};
return mapping[name] || 'screen';
};
return {
id: String(api.id),
workOrderNo: api.work_order_no,
lotNo: api.sales_order?.order_no || '-',
processType: api.process_type,
processId: api.process_id,
processName: api.process?.process_name || '-',
processCode: api.process?.process_code || '-',
processType: processNameToType(api.process?.process_name || ''), // 하위 호환
status: api.status,
client: api.sales_order?.client?.name || '-',
projectName: api.project_name || '-',
@@ -414,11 +444,11 @@ function getStatusStep(status: WorkOrderStatus): number {
}
// Frontend → API 변환 (등록/수정용)
export function transformFrontendToApi(data: Partial<WorkOrder>): Record<string, unknown> {
export function transformFrontendToApi(data: Partial<WorkOrder> & { processId?: number }): Record<string, unknown> {
const result: Record<string, unknown> = {};
if (data.projectName !== undefined) result.project_name = data.projectName;
if (data.processType !== undefined) result.process_type = data.processType;
if (data.processId !== undefined) result.process_id = data.processId;
if (data.status !== undefined) result.status = data.status;
if (data.scheduledDate !== undefined) result.scheduled_date = data.scheduledDate;
if (data.dueDate !== undefined && data.scheduledDate === undefined) result.scheduled_date = data.dueDate;