diff --git a/CURRENT_WORKS.md b/CURRENT_WORKS.md index 63d64fb4..5bfe18df 100644 --- a/CURRENT_WORKS.md +++ b/CURRENT_WORKS.md @@ -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 (목) - 견적 등록 자동산출 기능 구현 ### 작업 목표 diff --git a/src/components/production/WorkOrders/WorkOrderCreate.tsx b/src/components/production/WorkOrders/WorkOrderCreate.tsx index cf2ef9a9..aa9fb418 100644 --- a/src/components/production/WorkOrders/WorkOrderCreate.tsx +++ b/src/components/production/WorkOrders/WorkOrderCreate.tsx @@ -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 = { 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([]); const [validationErrors, setValidationErrors] = useState({}); const [isSubmitting, setIsSubmitting] = useState(false); + const [processOptions, setProcessOptions] = useState([]); + 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 = { - 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() {

- 공정코드: {getProcessCode(formData.processType)} + 공정코드: {getSelectedProcessCode()}

diff --git a/src/components/production/WorkOrders/WorkOrderDetail.tsx b/src/components/production/WorkOrders/WorkOrderDetail.tsx index 16a2160b..1d511456 100644 --- a/src/components/production/WorkOrders/WorkOrderDetail.tsx +++ b/src/components/production/WorkOrders/WorkOrderDetail.tsx @@ -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) {

공정구분

-

{PROCESS_TYPE_LABELS[order.processType]}

+

{order.processName}

작업상태

diff --git a/src/components/production/WorkOrders/WorkOrderList.tsx b/src/components/production/WorkOrders/WorkOrderList.tsx index f27d8fc7..26a2b07d 100644 --- a/src/components/production/WorkOrders/WorkOrderList.tsx +++ b/src/components/production/WorkOrders/WorkOrderList.tsx @@ -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() { {globalIndex} {order.workOrderNo} - {PROCESS_TYPE_LABELS[order.processType]} + {order.processName} {order.lotNo} {order.orderDate} {order.isAssigned ? 'Y' : '-'} @@ -297,7 +296,7 @@ export function WorkOrderList() { } infoGrid={
- + diff --git a/src/components/production/WorkOrders/actions.ts b/src/components/production/WorkOrders/actions.ts index 31a5a620..1901a567 100644 --- a/src/components/production/WorkOrders/actions.ts +++ b/src/components/production/WorkOrders/actions.ts @@ -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: '서버 오류가 발생했습니다.' }; + } +} diff --git a/src/components/production/WorkOrders/types.ts b/src/components/production/WorkOrders/types.ts index e95ad150..3f2c4117 100644 --- a/src/components/production/WorkOrders/types.ts +++ b/src/components/production/WorkOrders/types.ts @@ -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 = { @@ -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 = { + '스크린': '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): Record { +export function transformFrontendToApi(data: Partial & { processId?: number }): Record { const result: Record = {}; 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;