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

@@ -1,5 +1,83 @@
# SAM React 작업 현황
## 2025-01-09 (목) - 작업지시 process_type → process_id FK 변환
### 작업 목표
- 작업지시의 `process_type` (varchar enum: 'screen'/'slat'/'bending')를 `process_id` (FK → processes.id)로 변환
- API와 Frontend 전체 스택 마이그레이션
### 수정된 파일
| 파일명 | 설명 |
|--------|------|
| `src/components/production/WorkOrders/types.ts` | processId, processName, processCode 필드 추가, transformApiToFrontend에서 processType 하위 호환 유지 |
| `src/components/production/WorkOrders/actions.ts` | getProcessOptions() 추가, createWorkOrder에서 processId 사용 |
| `src/components/production/WorkOrders/WorkOrderCreate.tsx` | processType enum → processId FK 변경, 동적 공정 옵션 로딩 |
| `src/components/production/WorkOrders/WorkOrderList.tsx` | PROCESS_TYPE_LABELS 제거, order.processName 사용 |
| `src/components/production/WorkOrders/WorkOrderDetail.tsx` | PROCESS_TYPE_LABELS 제거, order.processName 사용 (비즈니스 로직은 processType 유지) |
### 주요 변경 내용
#### 1. types.ts - 타입 및 변환 함수
- `WorkOrder` 인터페이스에 `processId`, `processName`, `processCode` 추가
- `processType``@deprecated` 마킹, 하위 호환용 유지
- `transformApiToFrontend`에서 `processName``processType` 자동 매핑
#### 2. actions.ts - 서버 액션
- `getProcessOptions()`: 공정 목록 API 조회 (GET /api/v1/processes)
- `createWorkOrder()`: `processId` 필드 사용 (기존 processType 제거)
#### 3. WorkOrderCreate.tsx - 등록 폼
- `processType: ProcessType``processId: number | null`
- `useEffect`로 공정 옵션 동적 로딩
- 첫 번째 공정 자동 선택 (기본값)
- Select 컴포넌트 동적 옵션 렌더링
#### 4. WorkOrderList.tsx / WorkOrderDetail.tsx - 목록/상세
- `PROCESS_TYPE_LABELS[order.processType]``order.processName`
- 비즈니스 로직(ProcessSteps, 절곡 확인)은 `processType` 유지
### 빌드 검증
✅ Next.js 빌드 성공 (TypeScript 오류 없음)
### 관련 API 변경 (api 저장소)
- `WorkOrder` 모델: `process_id` FK 추가, `process()` 관계 정의
- `WorkOrderService`: `process_id` 사용
- `WorkOrderStoreRequest/UpdateRequest`: `process_id` 검증 규칙
---
## 2025-01-09 (목) - 작업지시 코드 리뷰 기반 프론트엔드 개선
### 작업 목표
- 작업지시 기능 코드 리뷰 결과 기반 프론트엔드 개선
- Critical, High, Medium 우선순위 항목 전체 수정
### 수정된 파일
| 파일명 | 설명 |
|--------|------|
| `src/components/production/WorkOrders/WorkOrderList.tsx` | useCallback 의존성 순환 수정 |
| `src/components/production/WorkOrders/WorkOrderDetail.tsx` | 작업 버튼 핸들러 구현 |
| `src/components/production/WorkOrders/types.ts` | scheduledDate 매핑, 다중 담당자 타입 추가 |
| `src/components/production/WorkOrders/actions.ts` | API 경로 수정 (/sales-orders → /orders) |
| `src/components/production/WorkOrders/SalesOrderSelectModal.tsx` | debounce 적용 |
| `src/components/production/WorkOrders/hooks/useDebounce.ts` | 신규 생성 - 커스텀 debounce 훅 |
### 주요 변경 내용
1. **useCallback 의존성 수정**: 무한 루프 방지를 위한 의존성 배열 수정
2. **scheduledDate 매핑**: transformFrontendToApi에 scheduled_date 필드 추가
3. **작업 버튼 구현**: "시작"/"완료" 버튼 핸들러 추가
4. **API 경로 수정**: `/api/v1/sales-orders``/api/v1/orders` 변경
5. **debounce 적용**: 커스텀 useDebounce 훅 (300ms) 적용
6. **다중 담당자 타입**: WorkOrderAssigneeApi 인터페이스 및 assignees 필드 추가
### Git 커밋
- `12b4259 refactor(work-orders): 코드 리뷰 기반 프론트엔드 개선`
### 관련 문서
- 계획: `~/.claude/plans/purring-sparking-pinwheel.md`
---
## 2026-01-02 (목) - 견적 등록 자동산출 기능 구현
### 작업 목표

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;