From a0343eec9372dfdbc9a5b982fc910b30eb15e41f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Fri, 23 Jan 2026 21:32:24 +0900 Subject: [PATCH] =?UTF-8?q?feat(WEB):=20=EB=B6=80=EC=8B=A4=EC=B1=84?= =?UTF-8?q?=EA=B6=8C,=20=EC=9E=AC=EA=B3=A0,=20=EC=9E=85=EA=B3=A0,=20?= =?UTF-8?q?=EC=88=98=EC=A3=BC=20UI=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BadDebtCollection 액션/타입 리팩토링 - ReceivingProcessDialog 입고처리 개선 - StockStatusList 재고현황 UI 개선 - OrderSalesDetailView 수주 상세 수정 - UniversalListPage 범용 리스트 개선 - production-order 페이지 수정 --- .../[id]/production-order/page.tsx | 73 +++---- .../sales/order-management-sales/page.tsx | 4 +- .../accounting/BadDebtCollection/actions.ts | 181 ++++++++---------- .../accounting/BadDebtCollection/types.ts | 24 ++- .../ReceivingProcessDialog.tsx | 21 +- .../material/ReceivingManagement/types.ts | 10 +- .../material/StockStatus/StockStatusList.tsx | 22 ++- .../material/StockStatus/actions.ts | 114 +++++++---- src/components/material/StockStatus/types.ts | 81 ++++---- .../orders/OrderSalesDetailView.tsx | 4 +- src/components/orders/actions.ts | 11 +- .../templates/UniversalListPage/index.tsx | 21 +- 12 files changed, 315 insertions(+), 251 deletions(-) diff --git a/src/app/[locale]/(protected)/sales/order-management-sales/[id]/production-order/page.tsx b/src/app/[locale]/(protected)/sales/order-management-sales/[id]/production-order/page.tsx index e0e925ae..ecae2546 100644 --- a/src/app/[locale]/(protected)/sales/order-management-sales/[id]/production-order/page.tsx +++ b/src/app/[locale]/(protected)/sales/order-management-sales/[id]/production-order/page.tsx @@ -27,9 +27,9 @@ import { TableRow, } from "@/components/ui/table"; import { Textarea } from "@/components/ui/textarea"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Label } from "@/components/ui/label"; import { Factory, ArrowLeft, BarChart3, CheckCircle2, AlertCircle, User } from "lucide-react"; +import { AssigneeSelectModal } from "@/components/production/WorkOrders/AssigneeSelectModal"; import { PageLayout } from "@/components/organisms/PageLayout"; import { AlertDialog, @@ -50,8 +50,6 @@ import { type CreateProductionOrderData, } from "@/components/orders/actions"; import { getProcessList } from "@/components/process-management/actions"; -import { getEmployees } from "@/components/hr/EmployeeManagement/actions"; -import type { Employee } from "@/components/hr/EmployeeManagement/types"; import type { Process } from "@/types/process"; import { formatAmount } from "@/utils/formatAmount"; @@ -352,28 +350,28 @@ export default function ProductionOrderCreatePage() { const [error, setError] = useState(null); const [order, setOrder] = useState(null); const [processes, setProcesses] = useState([]); - const [employees, setEmployees] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); // 우선순위 상태 const [selectedPriority, setSelectedPriority] = useState("normal"); - const [selectedAssignee, setSelectedAssignee] = useState(""); + const [selectedAssignees, setSelectedAssignees] = useState([]); + const [assigneeNames, setAssigneeNames] = useState([]); + const [isAssigneeModalOpen, setIsAssigneeModalOpen] = useState(false); const [memo, setMemo] = useState(""); // 성공 다이얼로그 상태 const [showSuccessDialog, setShowSuccessDialog] = useState(false); const [generatedWorkOrders, setGeneratedWorkOrders] = useState>([]); - // 수주 데이터, 공정 목록, 직원 목록 로드 + // 수주 데이터, 공정 목록 로드 const fetchData = useCallback(async () => { setLoading(true); setError(null); try { - // 수주 정보, 공정 목록, 직원 목록을 병렬로 로드 - const [orderResult, processResult, employeeResult] = await Promise.all([ + // 수주 정보, 공정 목록을 병렬로 로드 + const [orderResult, processResult] = await Promise.all([ getOrderById(orderId), getProcessList({ status: "사용중" }), - getEmployees({ status: "active", per_page: 100 }), ]); if (orderResult.success && orderResult.data) { @@ -385,10 +383,6 @@ export default function ProductionOrderCreatePage() { if (processResult.success && processResult.data) { setProcesses(processResult.data.items); } - - if (employeeResult.data) { - setEmployees(employeeResult.data); - } } catch { setError("서버 오류가 발생했습니다."); } finally { @@ -412,7 +406,7 @@ export default function ProductionOrderCreatePage() { if (!order) return; // 담당자 필수 검증 - if (!selectedAssignee) { + if (selectedAssignees.length === 0) { setError("담당자를 선택해주세요."); return; } @@ -436,9 +430,14 @@ export default function ProductionOrderCreatePage() { .map((g) => parseInt(g.process.id, 10)) .filter((id) => !isNaN(id)); + // 담당자 ID 배열 변환 (string[] → number[]) + const assigneeIds = selectedAssignees + .map(id => parseInt(id, 10)) + .filter(id => !isNaN(id)); + const productionData: CreateProductionOrderData = { priority: selectedPriority, - assigneeId: parseInt(selectedAssignee, 10), + assigneeIds: assigneeIds.length > 0 ? assigneeIds : undefined, memo: memo || undefined, processIds: allProcessIds.length > 0 ? allProcessIds : undefined, }; @@ -726,27 +725,26 @@ export default function ProductionOrderCreatePage() { )} - {/* 담당자 선택 */} + {/* 담당자 선택 (다중 선택) */}
- - {!selectedAssignee && ( +
setIsAssigneeModalOpen(true)} + className={cn( + "flex min-h-10 w-full md:w-[400px] cursor-pointer items-center rounded-md border bg-white px-3 py-2 text-sm ring-offset-background hover:bg-accent/50", + selectedAssignees.length === 0 ? "border-red-300" : "border-input" + )} + > + {assigneeNames.length > 0 ? ( + {assigneeNames.join(', ')} + ) : ( + 담당자를 선택하세요 (팀/개인) + )} +
+ {selectedAssignees.length === 0 && (

담당자는 필수 선택 항목입니다.

)}
@@ -1024,6 +1022,17 @@ export default function ProductionOrderCreatePage() { + + {/* 담당자 선택 모달 */} + { + setSelectedAssignees(ids); + setAssigneeNames(names); + }} + /> ); } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/sales/order-management-sales/page.tsx b/src/app/[locale]/(protected)/sales/order-management-sales/page.tsx index f23e4567..72d726d5 100644 --- a/src/app/[locale]/(protected)/sales/order-management-sales/page.tsx +++ b/src/app/[locale]/(protected)/sales/order-management-sales/page.tsx @@ -494,7 +494,7 @@ export default function OrderManagementSalesPage() { {order.siteName} {getOrderStatusBadge(order.status)} {order.expectedShipDate || "-"} - {order.deliveryMethod || "-"} + {order.deliveryMethodLabel || "-"} e.stopPropagation()}> {isSelected && (
@@ -564,7 +564,7 @@ export default function OrderManagementSalesPage() { - + { last_page: number; } -// API 악성채권 데이터 타입 -interface BadDebtApiData { +// API 개별 악성채권 타입 +interface BadDebtItemApiData { id: number; - tenant_id: number; - client_id: number; - debt_amount: string; + debt_amount: number; status: 'collecting' | 'legal_action' | 'recovered' | 'bad_debt'; overdue_days: number; - occurred_at: string; - closed_at: string | null; - assigned_manager_id: number | null; is_active: boolean; - note: string | null; - created_at: string; - updated_at: string; - deleted_at: string | null; - client?: { - id: number; - code: string; - name: string; - business_number: string | null; - representative_name: string | null; - client_type: string | null; - business_type: string | null; - business_category: string | null; - zip_code: string | null; - address1: string | null; - address2: string | null; - phone: string | null; - mobile: string | null; - fax: string | null; - email: string | null; - contact_name: string | null; - contact_phone: string | null; - }; - assigned_manager?: { + occurred_at: string | null; + assigned_user?: { id: number; name: string; - department?: { - id: number; - name: string; - }; - position: string | null; - phone: string | null; - }; - documents?: Array<{ + } | null; +} + +// API 악성채권 데이터 타입 (거래처 기준) +interface BadDebtApiData { + id: number; + client_id: number; + client_code: string; + client_name: string; + business_no: string | null; + contact_person: string | null; + phone: string | null; + mobile: string | null; + email: string | null; + address: string | null; + client_type: string | null; + // 집계 데이터 + total_debt_amount: number; + max_overdue_days: number; + bad_debt_count: number; + // 대표 상태 + status: 'collecting' | 'legal_action' | 'recovered' | 'bad_debt'; + is_active: boolean; + // 담당자 + assigned_user?: { id: number; - file_name: string; - file_path: string; - file_size: number; - mime_type: string; - created_at: string; - }>; - memos?: Array<{ - id: number; - content: string; - created_at: string; - created_by: number; - created_by_user?: { - id: number; - name: string; - }; - }>; + name: string; + } | null; + // 개별 악성채권 목록 + bad_debts: BadDebtItemApiData[]; } // 통계 API 응답 타입 @@ -165,61 +143,66 @@ function mapClientTypeToVendorType(clientType?: string | null): 'sales' | 'purch } /** - * API 데이터 → 프론트엔드 타입 변환 + * API 데이터 → 프론트엔드 타입 변환 (거래처 기준) */ function transformApiToFrontend(apiData: BadDebtApiData): BadDebtRecord { - const client = apiData.client; - const manager = apiData.assigned_manager; + const manager = apiData.assigned_user; + const firstBadDebt = apiData.bad_debts?.[0]; return { - id: String(apiData.id), + id: String(apiData.id), // Client ID vendorId: String(apiData.client_id), - vendorCode: client?.code || '', - vendorName: client?.name || '거래처 없음', - businessNumber: client?.business_number || '', - representativeName: client?.representative_name || '', - vendorType: mapClientTypeToVendorType(client?.client_type), - businessType: client?.business_type || '', - businessCategory: client?.business_category || '', - zipCode: client?.zip_code || '', - address1: client?.address1 || '', - address2: client?.address2 || '', - phone: client?.phone || '', - mobile: client?.mobile || '', - fax: client?.fax || '', - email: client?.email || '', - contactName: client?.contact_name || '', - contactPhone: client?.contact_phone || '', + vendorCode: apiData.client_code || '', + vendorName: apiData.client_name || '거래처 없음', + businessNumber: apiData.business_no || '', + representativeName: '', + vendorType: mapClientTypeToVendorType(apiData.client_type), + businessType: '', + businessCategory: '', + zipCode: '', + address1: apiData.address || '', + address2: '', + phone: apiData.phone || '', + mobile: apiData.mobile || '', + fax: '', + email: apiData.email || '', + contactName: apiData.contact_person || '', + contactPhone: '', systemManager: '', - debtAmount: parseFloat(apiData.debt_amount) || 0, + // 집계 데이터 + debtAmount: apiData.total_debt_amount || 0, + badDebtCount: apiData.bad_debt_count || 0, status: mapApiStatusToFrontend(apiData.status), - overdueDays: apiData.overdue_days || 0, + overdueDays: apiData.max_overdue_days || 0, overdueToggle: apiData.is_active, - occurrenceDate: apiData.occurred_at, - endDate: apiData.closed_at, - assignedManagerId: apiData.assigned_manager_id ? String(apiData.assigned_manager_id) : null, + occurrenceDate: firstBadDebt?.occurred_at || '', + endDate: null, + assignedManagerId: manager ? String(manager.id) : null, assignedManager: manager ? { id: String(manager.id), - departmentName: manager.department?.name || '', + departmentName: '', name: manager.name, - position: manager.position || '', - phone: manager.phone || '', + position: '', + phone: '', } : null, settingToggle: apiData.is_active, - files: apiData.documents?.map(doc => ({ - id: String(doc.id), - name: doc.file_name, - url: doc.file_path, - type: 'additional' as const, - })) || [], - memos: apiData.memos?.map(memo => ({ - id: String(memo.id), - content: memo.content, - createdAt: memo.created_at, - createdBy: memo.created_by_user?.name || `User ${memo.created_by}`, - })) || [], - createdAt: apiData.created_at, - updatedAt: apiData.updated_at, + // 개별 악성채권 목록 + badDebts: (apiData.bad_debts || []).map(bd => ({ + id: String(bd.id), + debtAmount: bd.debt_amount || 0, + status: mapApiStatusToFrontend(bd.status), + overdueDays: bd.overdue_days || 0, + isActive: bd.is_active, + occurredAt: bd.occurred_at, + assignedManager: bd.assigned_user ? { + id: String(bd.assigned_user.id), + name: bd.assigned_user.name, + } : null, + })), + files: [], + memos: [], + createdAt: '', + updatedAt: '', }; } diff --git a/src/components/accounting/BadDebtCollection/types.ts b/src/components/accounting/BadDebtCollection/types.ts index 064f0723..318bff7b 100644 --- a/src/components/accounting/BadDebtCollection/types.ts +++ b/src/components/accounting/BadDebtCollection/types.ts @@ -31,7 +31,18 @@ export interface AttachedFile { type: 'businessRegistration' | 'taxInvoice' | 'additional'; } -// 악성채권 레코드 +// 개별 악성채권 항목 (거래처별 하위 목록) +export interface BadDebtItem { + id: string; + debtAmount: number; + status: CollectionStatus; + overdueDays: number; + isActive: boolean; + occurredAt: string | null; + assignedManager: { id: string; name: string } | null; +} + +// 악성채권 레코드 (거래처 기준) export interface BadDebtRecord { id: string; // 거래처 기본 정보 @@ -55,16 +66,19 @@ export interface BadDebtRecord { contactName: string; contactPhone: string; systemManager: string; - // 악성채권 정보 - debtAmount: number; - status: CollectionStatus; - overdueDays: number; + // 악성채권 집계 정보 (거래처 기준) + debtAmount: number; // 총 미수금액 + badDebtCount: number; // 악성채권 건수 + status: CollectionStatus; // 대표 상태 (가장 최근) + overdueDays: number; // 최대 연체일수 overdueToggle: boolean; occurrenceDate: string; endDate: string | null; assignedManagerId: string | null; assignedManager: Manager | null; settingToggle: boolean; + // 개별 악성채권 목록 + badDebts: BadDebtItem[]; // 첨부 파일 files: AttachedFile[]; // 메모 diff --git a/src/components/material/ReceivingManagement/ReceivingProcessDialog.tsx b/src/components/material/ReceivingManagement/ReceivingProcessDialog.tsx index d667520e..1329cf04 100644 --- a/src/components/material/ReceivingManagement/ReceivingProcessDialog.tsx +++ b/src/components/material/ReceivingManagement/ReceivingProcessDialog.tsx @@ -3,8 +3,8 @@ /** * 입고처리 다이얼로그 * - 발주 정보 표시 - * - 입고LOT*, 공급업체LOT, 입고수량*, 입고위치* 입력 - * - 비고 입력 + * - 입고LOT*, 공급업체LOT, 입고수량* 입력 (필수) + * - 입고위치, 비고 입력 (선택) */ import { useState, useCallback } from 'react'; @@ -64,13 +64,11 @@ export function ReceivingProcessDialog({ open, onOpenChange, detail, onComplete errors.push('입고수량은 필수 입력 항목입니다. 유효한 숫자를 입력해주세요.'); } - if (!receivingLocation.trim()) { - errors.push('입고위치는 필수 입력 항목입니다.'); - } + // 입고위치는 선택 항목 (필수 검사 제거) setValidationErrors(errors); return errors.length === 0; - }, [receivingLot, receivingQty, receivingLocation]); + }, [receivingLot, receivingQty]); // 입고 처리 const handleSubmit = useCallback(async () => { @@ -84,7 +82,7 @@ export function ReceivingProcessDialog({ open, onOpenChange, detail, onComplete receivingQty: Number(receivingQty), receivingLot, supplierLot: supplierLot || undefined, - receivingLocation, + receivingLocation: receivingLocation || undefined, remark: remark || undefined, }; @@ -197,15 +195,10 @@ export function ReceivingProcessDialog({ open, onOpenChange, detail, onComplete />
- + { - setReceivingLocation(e.target.value); - setValidationErrors([]); - }} + onChange={(e) => setReceivingLocation(e.target.value)} placeholder="예: A-01" />
diff --git a/src/components/material/ReceivingManagement/types.ts b/src/components/material/ReceivingManagement/types.ts index 6784a736..f9f331bf 100644 --- a/src/components/material/ReceivingManagement/types.ts +++ b/src/components/material/ReceivingManagement/types.ts @@ -94,11 +94,11 @@ export interface InspectionFormData { // 입고처리 폼 데이터 export interface ReceivingProcessFormData { - receivingLot: string; // 입고LOT * - supplierLot?: string; // 공급업체LOT - receivingQty: number; // 입고수량 * - receivingLocation: string; // 입고위치 * - remark?: string; // 비고 + receivingLot: string; // 입고LOT * + supplierLot?: string; // 공급업체LOT + receivingQty: number; // 입고수량 * + receivingLocation?: string; // 입고위치 (선택) + remark?: string; // 비고 } // 통계 데이터 diff --git a/src/components/material/StockStatus/StockStatusList.tsx b/src/components/material/StockStatus/StockStatusList.tsx index f5f21513..3ededd92 100644 --- a/src/components/material/StockStatus/StockStatusList.tsx +++ b/src/components/material/StockStatus/StockStatusList.tsx @@ -119,14 +119,12 @@ export function StockStatusList() { // ===== 탭 옵션 (기본 탭 + 품목유형별 통계) ===== const tabs: TabOption[] = useMemo(() => { - // 기본 탭 정의 (API 데이터 없어도 항상 표시) + // 기본 탭 정의 (Item 모델의 MATERIAL_TYPES: RM, SM, CS) const defaultTabs: { value: string; label: string }[] = [ { value: 'all', label: '전체' }, - { value: 'raw_material', label: '원자재' }, - { value: 'bent_part', label: '절곡부품' }, - { value: 'purchased_part', label: '구매부품' }, - { value: 'sub_material', label: '부자재' }, - { value: 'consumable', label: '소모품' }, + { value: 'RM', label: '원자재' }, + { value: 'SM', label: '부자재' }, + { value: 'CS', label: '소모품' }, ]; return defaultTabs.map((tab) => { @@ -287,9 +285,13 @@ export function StockStatusList() {
- - {STOCK_STATUS_LABELS[item.status]} - + {item.status ? ( + + {STOCK_STATUS_LABELS[item.status]} + + ) : ( + - + )} {item.location} @@ -334,7 +336,7 @@ export function StockStatusList() { /> diff --git a/src/components/material/StockStatus/actions.ts b/src/components/material/StockStatus/actions.ts index 3beae9c8..2f4f1d65 100644 --- a/src/components/material/StockStatus/actions.ts +++ b/src/components/material/StockStatus/actions.ts @@ -1,11 +1,11 @@ /** * 재고 현황 서버 액션 * - * API Endpoints: - * - GET /api/v1/stocks - 목록 조회 + * API Endpoints (Item 기준): + * - GET /api/v1/stocks - 목록 조회 (Item + Stock LEFT JOIN) * - GET /api/v1/stocks/stats - 통계 조회 * - GET /api/v1/stocks/stats-by-type - 품목유형별 통계 조회 - * - GET /api/v1/stocks/{id} - 상세 조회 (LOT 포함) + * - GET /api/v1/stocks/{id} - 상세 조회 (Item 기준, LOT 포함) */ 'use server'; @@ -23,16 +23,12 @@ import type { LotStatusType, } from './types'; -// ===== API 데이터 타입 ===== -interface StockApiData { +// ===== API 데이터 타입 (Item 기준) ===== + +// Stock 관계 데이터 +interface StockRelationData { id: number; - tenant_id: number; - item_code: string; - item_name: string; - item_type: ItemType; - item_type_label?: string; - specification?: string; - unit: string; + item_id: number; stock_qty: string | number; safety_stock: string | number; reserved_qty: string | number; @@ -42,12 +38,30 @@ interface StockApiData { days_elapsed?: number; location?: string; status: StockStatusType; - status_label?: string; last_receipt_date?: string; last_issue_date?: string; + lots?: StockLotApiData[]; +} + +// Item API 응답 데이터 +interface ItemApiData { + id: number; + tenant_id: number; + code: string; // Item.code (기존 item_code) + name: string; // Item.name (기존 item_name) + item_type: ItemType; // Item.item_type (RM, SM, CS) + unit: string; + category_id?: number; + category?: { + id: number; + name: string; + }; + description?: string; + attributes?: Record; + is_active: boolean; created_at?: string; updated_at?: string; - lots?: StockLotApiData[]; + stock?: StockRelationData | null; // Stock 관계 (없으면 null) } interface StockLotApiData { @@ -71,8 +85,8 @@ interface StockLotApiData { updated_at?: string; } -interface StockApiPaginatedResponse { - data: StockApiData[]; +interface ItemApiPaginatedResponse { + data: ItemApiData[]; current_page: number; last_page: number; per_page: number; @@ -84,6 +98,7 @@ interface StockApiStatsResponse { normal_count: number; low_count: number; out_count: number; + no_stock_count: number; } interface StockApiStatsByTypeResponse { @@ -95,19 +110,23 @@ interface StockApiStatsByTypeResponse { } // ===== API → Frontend 변환 (목록용) ===== -function transformApiToListItem(data: StockApiData): StockItem { +function transformApiToListItem(data: ItemApiData): StockItem { + const stock = data.stock; + const hasStock = !!stock; + return { id: String(data.id), - itemCode: data.item_code, - itemName: data.item_name, + itemCode: data.code, + itemName: data.name, itemType: data.item_type, unit: data.unit || 'EA', - stockQty: parseFloat(String(data.stock_qty)) || 0, - safetyStock: parseFloat(String(data.safety_stock)) || 0, - lotCount: data.lot_count || 0, - lotDaysElapsed: data.days_elapsed || 0, - status: data.status, - location: data.location || '-', + stockQty: hasStock ? (parseFloat(String(stock.stock_qty)) || 0) : 0, + safetyStock: hasStock ? (parseFloat(String(stock.safety_stock)) || 0) : 0, + lotCount: hasStock ? (stock.lot_count || 0) : 0, + lotDaysElapsed: hasStock ? (stock.days_elapsed || 0) : 0, + status: hasStock ? stock.status : null, + location: hasStock ? (stock.location || '-') : '-', + hasStock, }; } @@ -129,22 +148,38 @@ function transformApiToLot(data: StockLotApiData): LotDetail { } // ===== API → Frontend 변환 (상세용) ===== -function transformApiToDetail(data: StockApiData): StockDetail { +function transformApiToDetail(data: ItemApiData): StockDetail { + const stock = data.stock; + const hasStock = !!stock; + + // description 또는 attributes에서 규격 정보 추출 + let specification = '-'; + if (data.description) { + specification = data.description; + } else if (data.attributes && typeof data.attributes === 'object') { + // attributes에서 규격 관련 정보 추출 시도 + const attrs = data.attributes as Record; + if (attrs.specification) { + specification = String(attrs.specification); + } + } + return { id: String(data.id), - itemCode: data.item_code, - itemName: data.item_name, + itemCode: data.code, + itemName: data.name, itemType: data.item_type, - category: '-', // API에서 category 제공 안 함 - specification: data.specification || '-', + category: data.category?.name || '-', + specification, unit: data.unit || 'EA', - currentStock: parseFloat(String(data.stock_qty)) || 0, - safetyStock: parseFloat(String(data.safety_stock)) || 0, - location: data.location || '-', - lotCount: data.lot_count || 0, - lastReceiptDate: data.last_receipt_date || '-', - status: data.status, - lots: (data.lots || []).map(transformApiToLot), + currentStock: hasStock ? (parseFloat(String(stock.stock_qty)) || 0) : 0, + safetyStock: hasStock ? (parseFloat(String(stock.safety_stock)) || 0) : 0, + location: hasStock ? (stock.location || '-') : '-', + lotCount: hasStock ? (stock.lot_count || 0) : 0, + lastReceiptDate: hasStock ? (stock.last_receipt_date || '-') : '-', + status: hasStock ? stock.status : null, + hasStock, + lots: hasStock && stock.lots ? stock.lots.map(transformApiToLot) : [], }; } @@ -155,6 +190,7 @@ function transformApiToStats(data: StockApiStatsResponse): StockStats { normalCount: data.normal_count, lowCount: data.low_count, outCount: data.out_count, + noStockCount: data.no_stock_count || 0, }; } @@ -238,7 +274,7 @@ export async function getStocks(params?: { }; } - const paginatedData: StockApiPaginatedResponse = result.data || { + const paginatedData: ItemApiPaginatedResponse = result.data || { data: [], current_page: 1, last_page: 1, @@ -340,7 +376,7 @@ export async function getStockStatsByType(): Promise<{ } } -// ===== 재고 상세 조회 (LOT 포함) ===== +// ===== 재고 상세 조회 (Item 기준, LOT 포함) ===== export async function getStockById(id: string): Promise<{ success: boolean; data?: StockDetail; diff --git a/src/components/material/StockStatus/types.ts b/src/components/material/StockStatus/types.ts index eaed22aa..8e2fb305 100644 --- a/src/components/material/StockStatus/types.ts +++ b/src/components/material/StockStatus/types.ts @@ -1,26 +1,24 @@ /** * 재고현황 타입 정의 + * + * Item 모델 기준 (MATERIAL_TYPES: SM, RM, CS) */ -// 품목유형 -export type ItemType = 'raw_material' | 'bent_part' | 'purchased_part' | 'sub_material' | 'consumable'; +// 품목유형 (Item 모델의 MATERIAL_TYPES) +export type ItemType = 'RM' | 'SM' | 'CS'; // 품목유형 라벨 export const ITEM_TYPE_LABELS: Record = { - raw_material: '원자재', - bent_part: '절곡부품', - purchased_part: '구매부품', - sub_material: '부자재', - consumable: '소모품', + RM: '원자재', + SM: '부자재', + CS: '소모품', }; // 품목유형 스타일 (뱃지용) export const ITEM_TYPE_STYLES: Record = { - raw_material: 'bg-blue-100 text-blue-800', - bent_part: 'bg-purple-100 text-purple-800', - purchased_part: 'bg-gray-100 text-gray-800', - sub_material: 'bg-green-100 text-green-800', - consumable: 'bg-orange-100 text-orange-800', + RM: 'bg-blue-100 text-blue-800', + SM: 'bg-green-100 text-green-800', + CS: 'bg-orange-100 text-orange-800', }; // 재고 상태 @@ -42,19 +40,20 @@ export const LOT_STATUS_LABELS: Record = { used: '사용완료', }; -// 재고 목록 아이템 +// 재고 목록 아이템 (Item 기준 + Stock 정보) export interface StockItem { id: string; - itemCode: string; // 품목코드 - itemName: string; // 품목명 - itemType: ItemType; // 품목유형 - unit: string; // 단위 (EA, M, m² 등) - stockQty: number; // 재고량 - safetyStock: number; // 안전재고 - lotCount: number; // LOT 개수 - lotDaysElapsed: number; // 경과일 (가장 오래된 LOT 기준) - status: StockStatusType; // 상태 - location: string; // 위치 + itemCode: string; // Item.code + itemName: string; // Item.name + itemType: ItemType; // Item.item_type (RM, SM, CS) + unit: string; // Item.unit + stockQty: number; // Stock.stock_qty (없으면 0) + safetyStock: number; // Stock.safety_stock (없으면 0) + lotCount: number; // Stock.lot_count (없으면 0) + lotDaysElapsed: number; // Stock.days_elapsed (없으면 0) + status: StockStatusType | null; // Stock.status (없으면 null) + location: string; // Stock.location (없으면 '-') + hasStock: boolean; // Stock 데이터 존재 여부 } // LOT별 상세 재고 @@ -72,24 +71,25 @@ export interface LotDetail { status: LotStatusType; // 상태 } -// 재고 상세 정보 +// 재고 상세 정보 (Item 기준) export interface StockDetail { - // 기본 정보 + // 기본 정보 (Item) id: string; - itemCode: string; // 품목코드 - itemName: string; // 품목명 - itemType: ItemType; // 품목유형 - category: string; // 카테고리 - specification: string; // 규격 - unit: string; // 단위 + itemCode: string; // Item.code + itemName: string; // Item.name + itemType: ItemType; // Item.item_type (RM, SM, CS) + category: string; // Item.category?.name + specification: string; // Item.attributes 또는 description + unit: string; // Item.unit - // 재고 현황 - currentStock: number; // 현재 재고량 - safetyStock: number; // 안전 재고 - location: string; // 재고 위치 - lotCount: number; // LOT 개수 - lastReceiptDate: string; // 최근 입고일 - status: StockStatusType; // 재고 상태 + // 재고 현황 (Stock - 없으면 기본값) + currentStock: number; // Stock.stock_qty + safetyStock: number; // Stock.safety_stock + location: string; // Stock.location + lotCount: number; // Stock.lot_count + lastReceiptDate: string; // Stock.last_receipt_date + status: StockStatusType | null; // Stock.status + hasStock: boolean; // Stock 데이터 존재 여부 // LOT별 상세 재고 lots: LotDetail[]; @@ -97,10 +97,11 @@ export interface StockDetail { // 통계 데이터 export interface StockStats { - totalItems: number; // 전체 품목 수 + totalItems: number; // 전체 자재 품목 수 (Item 기준) normalCount: number; // 정상 재고 수 lowCount: number; // 재고 부족 수 outCount: number; // 재고 없음 수 + noStockCount: number; // 재고 정보 없는 품목 수 } // 필터 탭 @@ -108,4 +109,4 @@ export interface FilterTab { key: 'all' | ItemType; label: string; count: number; -} \ No newline at end of file +} diff --git a/src/components/orders/OrderSalesDetailView.tsx b/src/components/orders/OrderSalesDetailView.tsx index f212cbec..c24d35e6 100644 --- a/src/components/orders/OrderSalesDetailView.tsx +++ b/src/components/orders/OrderSalesDetailView.tsx @@ -350,7 +350,7 @@ export function OrderSalesDetailView({ orderId }: OrderSalesDetailViewProps) { - + @@ -485,7 +485,7 @@ export function OrderSalesDetailView({ orderId }: OrderSalesDetailViewProps) { managerContact: order.contact, deliveryRequestDate: order.deliveryRequestDate, expectedShipDate: order.expectedShipDate, - deliveryMethod: order.deliveryMethod, + deliveryMethod: order.deliveryMethodLabel, address: order.address, items: order.items, subtotal: order.subtotal, diff --git a/src/components/orders/actions.ts b/src/components/orders/actions.ts index 7a0f170d..4f5839b7 100644 --- a/src/components/orders/actions.ts +++ b/src/components/orders/actions.ts @@ -26,6 +26,7 @@ interface ApiOrder { discount_amount: number; delivery_date: string | null; delivery_method_code: string | null; + delivery_method_label?: string; // API에서 조회한 배송방식 라벨 received_at: string | null; memo: string | null; remarks: string | null; @@ -219,6 +220,7 @@ export interface Order { statusCode: string; // 원본 status_code expectedShipDate?: string; // delivery_date deliveryMethod?: string; // delivery_method_code + deliveryMethodLabel?: string; // 배송방식 라벨 (API에서 조회) amount: number; // total_amount supplyAmount: number; taxAmount: number; @@ -334,6 +336,7 @@ export interface CreateProductionOrderData { processIds?: number[]; // 공정별 다중 작업지시 생성용 priority?: 'urgent' | 'high' | 'normal' | 'low'; assigneeId?: number; + assigneeIds?: number[]; // 다중 담당자 선택용 teamId?: number; scheduledDate?: string; memo?: string; @@ -444,6 +447,7 @@ function transformApiToFrontend(apiData: ApiOrder): Order { statusCode: apiData.status_code, expectedShipDate: apiData.delivery_date ?? undefined, deliveryMethod: apiData.delivery_method_code ?? undefined, + deliveryMethodLabel: apiData.delivery_method_label ?? apiData.delivery_method_code ?? undefined, amount: apiData.total_amount, supplyAmount: apiData.supply_amount, taxAmount: apiData.tax_amount, @@ -1046,7 +1050,12 @@ export async function createProductionOrder( apiData.process_id = data.processId; } if (data?.priority) apiData.priority = data.priority; - if (data?.assigneeId) apiData.assignee_id = data.assigneeId; + // 다중 담당자 ID (우선) 또는 단일 담당자 ID + if (data?.assigneeIds && data.assigneeIds.length > 0) { + apiData.assignee_ids = data.assigneeIds; + } else if (data?.assigneeId) { + apiData.assignee_id = data.assigneeId; + } if (data?.teamId) apiData.team_id = data.teamId; if (data?.scheduledDate) apiData.scheduled_date = data.scheduledDate; if (data?.memo) apiData.memo = data.memo; diff --git a/src/components/templates/UniversalListPage/index.tsx b/src/components/templates/UniversalListPage/index.tsx index 58a1e236..6d46be1c 100644 --- a/src/components/templates/UniversalListPage/index.tsx +++ b/src/components/templates/UniversalListPage/index.tsx @@ -71,6 +71,10 @@ export function UniversalListPage({ // 모바일 인피니티 스크롤 로딩 상태 (서버 사이드 페이지네이션 시) const [isMobileLoading, setIsMobileLoading] = useState(false); + // 서버 사이드 페이지네이션 상태 (API에서 반환하는 값) + const [serverTotalCount, setServerTotalCount] = useState(initialTotalCount || 0); + const [serverTotalPages, setServerTotalPages] = useState(1); + // ===== ID 추출 헬퍼 ===== const getItemId = useCallback( (item: T): string => { @@ -125,8 +129,12 @@ export function UniversalListPage({ }, [config.clientSideFiltering, filteredData, currentPage, itemsPerPage, rawData]); // 총 개수 및 페이지 수 - const totalCount = config.clientSideFiltering ? filteredData.length : rawData.length; - const totalPages = Math.ceil(totalCount / itemsPerPage); + // 서버 사이드 페이지네이션: API에서 반환한 값 사용 + // 클라이언트 사이드 페이지네이션: 로컬 데이터 길이 사용 + const totalCount = config.clientSideFiltering ? filteredData.length : serverTotalCount; + const totalPages = config.clientSideFiltering + ? Math.ceil(totalCount / itemsPerPage) + : serverTotalPages; // 표시할 데이터 const displayData = config.clientSideFiltering ? paginatedData : rawData; @@ -171,6 +179,15 @@ export function UniversalListPage({ if (result.success && result.data) { setRawData(result.data); + // 서버 사이드 페이지네이션: API에서 반환한 totalCount, totalPages 저장 + if (!config.clientSideFiltering) { + if (typeof result.totalCount === 'number') { + setServerTotalCount(result.totalCount); + } + if (typeof result.totalPages === 'number') { + setServerTotalPages(result.totalPages); + } + } } else { toast.error(result.error || '데이터를 불러오는데 실패했습니다.'); }