From f5fbe1efc8e56cfdb4fbc25d4d2c3b14870b45a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Sat, 21 Feb 2026 15:46:58 +0900 Subject: [PATCH] =?UTF-8?q?feat(WEB):=20=EC=A0=88=EA=B3=A1=ED=92=88=20?= =?UTF-8?q?=EC=84=A0=EC=83=9D=EC=82=B0=E2=86=92=EC=9E=AC=EA=B3=A0=EC=A0=81?= =?UTF-8?q?=EC=9E=AC=20Phase=202=20-=20=EC=88=98=EB=8F=99=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=EC=A7=80=EC=8B=9C=20=EB=B0=8F=20=EC=9E=AC=EA=B3=A0=20?= =?UTF-8?q?=ED=95=84=ED=84=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WorkOrderCreate: 수동 모드 품목 검색/추가/수량 관리 UI 구현 - WorkOrders/actions: items 파라미터 추가, searchItemsForWorkOrder 함수 추가 - StockStatusList: 품목분류(BENDING/SCREEN/STEEL/ALUMINUM) 필터 추가 - StockStatus/actions: itemCategory 파라미터 지원 --- .../material/StockStatus/StockStatusList.tsx | 18 +- .../material/StockStatus/actions.ts | 2 + .../production/WorkOrders/WorkOrderCreate.tsx | 215 +++++++++++++++++- .../production/WorkOrders/actions.ts | 71 ++++++ 4 files changed, 301 insertions(+), 5 deletions(-) diff --git a/src/components/material/StockStatus/StockStatusList.tsx b/src/components/material/StockStatus/StockStatusList.tsx index c2f13e6a..8184bfba 100644 --- a/src/components/material/StockStatus/StockStatusList.tsx +++ b/src/components/material/StockStatus/StockStatusList.tsx @@ -61,6 +61,7 @@ export function StockStatusList() { // ===== 검색 및 필터 상태 ===== const [searchTerm, setSearchTerm] = useState(''); const [filterValues, setFilterValues] = useState>({ + itemCategory: 'all', wipStatus: 'all', useStatus: 'all', }); @@ -69,10 +70,12 @@ export function StockStatusList() { const loadData = useCallback(async () => { try { setIsLoading(true); + const itemCategory = filterValues.itemCategory as string; const [stocksResult, statsResult] = await Promise.all([ getStocks({ page: 1, perPage: 9999, // 전체 데이터 로드 (클라이언트 사이드 필터링) + itemCategory: itemCategory !== 'all' ? itemCategory : undefined, startDate, endDate, }), @@ -93,7 +96,7 @@ export function StockStatusList() { } finally { setIsLoading(false); } - }, [startDate, endDate]); + }, [startDate, endDate, filterValues.itemCategory]); // 초기 데이터 로드 및 날짜 변경 시 재로드 useEffect(() => { @@ -207,9 +210,20 @@ export function StockStatusList() { }, ]; - // ===== 필터 설정 (재공품, 상태) ===== + // ===== 필터 설정 (카테고리, 재공품, 상태) ===== // 참고: IntegratedListTemplateV2에서 자동으로 '전체' 옵션을 추가하므로 options에서 제외 const filterConfig: FilterFieldConfig[] = [ + { + key: 'itemCategory', + label: '품목분류', + type: 'single', + options: [ + { value: 'BENDING', label: '절곡품' }, + { value: 'SCREEN', label: '스크린' }, + { value: 'STEEL', label: '철재' }, + { value: 'ALUMINUM', label: '알루미늄' }, + ], + }, { key: 'wipStatus', label: '재공품', diff --git a/src/components/material/StockStatus/actions.ts b/src/components/material/StockStatus/actions.ts index 365513ff..05d73912 100644 --- a/src/components/material/StockStatus/actions.ts +++ b/src/components/material/StockStatus/actions.ts @@ -222,6 +222,7 @@ function transformApiToStats(data: StockApiStatsResponse): StockStats { // ===== 재고 목록 조회 ===== export async function getStocks(params?: { page?: number; perPage?: number; search?: string; itemType?: string; + itemCategory?: string; status?: string; useStatus?: string; location?: string; sortBy?: string; sortDir?: string; startDate?: string; endDate?: string; }) { @@ -231,6 +232,7 @@ export async function getStocks(params?: { per_page: params?.perPage, search: params?.search, item_type: params?.itemType !== 'all' ? params?.itemType : undefined, + item_category: params?.itemCategory !== 'all' ? params?.itemCategory : undefined, status: params?.status !== 'all' ? params?.status : undefined, is_active: params?.useStatus && params.useStatus !== 'all' ? (params.useStatus === 'active' ? '1' : '0') : undefined, location: params?.location, diff --git a/src/components/production/WorkOrders/WorkOrderCreate.tsx b/src/components/production/WorkOrders/WorkOrderCreate.tsx index 48db5fba..70213d80 100644 --- a/src/components/production/WorkOrders/WorkOrderCreate.tsx +++ b/src/components/production/WorkOrders/WorkOrderCreate.tsx @@ -8,7 +8,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useRouter } from 'next/navigation'; -import { ArrowLeft, FileText, X, Edit2, Loader2 } from 'lucide-react'; +import { ArrowLeft, FileText, X, Edit2, Loader2, Plus, Search, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { DatePicker } from '@/components/ui/date-picker'; @@ -28,13 +28,22 @@ import { SalesOrderSelectModal } from './SalesOrderSelectModal'; import { AssigneeSelectModal } from './AssigneeSelectModal'; import { toast } from 'sonner'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; -import { createWorkOrder, getProcessOptions, type ProcessOption } from './actions'; +import { createWorkOrder, getProcessOptions, searchItemsForWorkOrder, type ProcessOption, type ManualItemOption } from './actions'; import { PROCESS_TYPE_LABELS, type ProcessType, type SalesOrder } from './types'; import { workOrderCreateConfig } from './workOrderConfig'; import { useDevFill } from '@/components/dev'; import { generateWorkOrderData } from '@/components/dev/generators/workOrderData'; +// 수동 등록 품목 +interface ManualItem { + item_id?: number; + item_name: string; + specification: string; + quantity: number; + unit: string; +} + // Validation 에러 타입 interface ValidationErrors { [key: string]: string; @@ -98,6 +107,13 @@ export function WorkOrderCreate() { const [processOptions, setProcessOptions] = useState([]); const [isLoadingProcesses, setIsLoadingProcesses] = useState(true); + // 수동 등록 품목 관리 + const [manualItems, setManualItems] = useState([]); + const [itemSearchQuery, setItemSearchQuery] = useState(''); + const [itemSearchResults, setItemSearchResults] = useState([]); + const [isSearchingItems, setIsSearchingItems] = useState(false); + const [showItemSearch, setShowItemSearch] = useState(false); + // 공정 옵션 로드 useEffect(() => { async function loadProcessOptions() { @@ -169,6 +185,50 @@ export function WorkOrderCreate() { }); }; + // 품목 검색 (수동 등록용) + const handleItemSearch = async (query: string) => { + setItemSearchQuery(query); + if (query.length < 1) { + setItemSearchResults([]); + return; + } + setIsSearchingItems(true); + const result = await searchItemsForWorkOrder(query, 'BENDING'); + if (result.success) { + setItemSearchResults(result.data); + } + setIsSearchingItems(false); + }; + + // 검색 결과에서 품목 추가 + const handleAddItem = (item: ManualItemOption) => { + // 이미 추가된 품목인지 확인 + if (manualItems.some(mi => mi.item_id === item.id)) { + toast.error('이미 추가된 품목입니다.'); + return; + } + setManualItems(prev => [...prev, { + item_id: item.id, + item_name: item.name, + specification: item.specification, + quantity: 1, + unit: item.unit, + }]); + setShowItemSearch(false); + setItemSearchQuery(''); + setItemSearchResults([]); + }; + + // 품목 수량 변경 + const handleItemQtyChange = (index: number, qty: number) => { + setManualItems(prev => prev.map((item, i) => i === index ? { ...item, quantity: qty } : item)); + }; + + // 품목 삭제 + const handleRemoveItem = (index: number) => { + setManualItems(prev => prev.filter((_, i) => i !== index)); + }; + // 폼 제출 const handleSubmit = async (): Promise<{ success: boolean; error?: string }> => { // Validation 체크 @@ -185,6 +245,9 @@ export function WorkOrderCreate() { if (!formData.projectName) { errors.projectName = '현장명을 입력해주세요'; } + if (manualItems.length === 0) { + errors.items = '품목을 1개 이상 추가해주세요'; + } } if (!formData.processId) { @@ -216,6 +279,15 @@ export function WorkOrderCreate() { scheduledDate: formData.shipmentDate, assigneeIds: formData.assignees.map(id => parseInt(id)), note: formData.note || undefined, + ...(mode === 'manual' && manualItems.length > 0 ? { + items: manualItems.map(item => ({ + item_id: item.item_id, + item_name: item.item_name, + specification: item.specification, + quantity: item.quantity, + unit: item.unit, + })), + } : {}), }); if (!result.success) { @@ -496,6 +568,143 @@ export function WorkOrderCreate() { + {/* 품목 (수동 등록 모드) */} + {mode === 'manual' && ( +
+
+

+ 품목 목록 + {manualItems.length > 0 && ( + + ({manualItems.length}개) + + )} +

+ +
+ + {/* 품목 검색 */} + {showItemSearch && ( +
+
+
+ + handleItemSearch(e.target.value)} + placeholder="절곡품 코드 또는 품목명으로 검색" + className="pl-9" + /> +
+ +
+ {isSearchingItems && ( +
+ + 검색 중... +
+ )} + {itemSearchResults.length > 0 && ( +
+ {itemSearchResults.map((item) => ( + + ))} +
+ )} + {itemSearchQuery.length > 0 && !isSearchingItems && itemSearchResults.length === 0 && ( +

검색 결과가 없습니다.

+ )} +
+ )} + + {/* 품목 목록 테이블 */} + {manualItems.length > 0 ? ( +
+ + + + + + + + + + + + + {manualItems.map((item, index) => ( + + + + + + + + + ))} + +
No품목명규격수량단위
{index + 1}{item.item_name}{item.specification || '-'} + handleItemQtyChange(index, parseInt(e.target.value) || 1)} + className="w-24 text-center mx-auto h-8" + /> + {item.unit} + +
+
+ ) : ( +
+

추가된 품목이 없습니다.

+

위의 '품목 추가' 버튼으로 절곡품을 검색하여 추가하세요.

+
+ )} + + {validationErrors.items && ( +

{validationErrors.items}

+ )} +
+ )} + {/* 비고 */}

비고

@@ -508,7 +717,7 @@ export function WorkOrderCreate() { />
- ), [mode, formData, validationErrors, processOptions, isLoadingProcesses, assigneeNames, getSelectedProcessCode]); + ), [mode, formData, validationErrors, processOptions, isLoadingProcesses, assigneeNames, getSelectedProcessCode, manualItems, showItemSearch, itemSearchQuery, itemSearchResults, isSearchingItems]); return ( <> diff --git a/src/components/production/WorkOrders/actions.ts b/src/components/production/WorkOrders/actions.ts index 3af799a8..05b5c4fc 100644 --- a/src/components/production/WorkOrders/actions.ts +++ b/src/components/production/WorkOrders/actions.ts @@ -218,6 +218,13 @@ export async function createWorkOrder( assigneeId?: number; // 단일 담당자 (하위 호환) assigneeIds?: number[]; // 다중 담당자 teamId?: number; + items?: Array<{ // 수동 등록 시 품목 목록 + item_id?: number; + item_name: string; + specification?: string; + quantity?: number; + unit?: string; + }>; } ): Promise<{ success: boolean; data?: WorkOrder; error?: string }> { try { @@ -233,6 +240,7 @@ export async function createWorkOrder( sales_order_id: data.salesOrderId, assignee_ids: assigneeIds, // 배열로 전송 team_id: data.teamId, + ...(data.items && data.items.length > 0 ? { items: data.items } : {}), }; @@ -268,6 +276,56 @@ export async function createWorkOrder( } } +// ===== 품목 검색 (수동 등록용) ===== +export interface ManualItemOption { + id: number; + code: string; + name: string; + specification: string; + unit: string; + itemCategory: string; +} + +export async function searchItemsForWorkOrder( + query?: string, + itemCategory?: string +): Promise<{ success: boolean; data: ManualItemOption[] }> { + try { + const { response, error } = await serverFetch( + buildApiUrl('/api/v1/items', { + search: query, + item_category: itemCategory, + per_page: 50, + }), + { method: 'GET' } + ); + + if (error || !response) { + return { success: false, data: [] }; + } + + const result = await response.json(); + if (!response.ok || !result.success) { + return { success: false, data: [] }; + } + + const items: ManualItemOption[] = (result.data?.data || []).map((item: Record) => ({ + id: item.id as number, + code: (item.item_code || item.code) as string, + name: (item.item_name || item.name) as string, + specification: (item.specification || '') as string, + unit: (item.unit || 'EA') as string, + itemCategory: (item.item_category || '') as string, + })); + + return { success: true, data: items }; + } catch (error) { + if (isNextRedirectError(error)) throw error; + console.error('[WorkOrderActions] searchItemsForWorkOrder error:', error); + return { success: false, data: [] }; + } +} + // ===== 작업지시 수정 ===== export async function updateWorkOrder( id: string, @@ -606,6 +664,18 @@ export interface InspectionReportItem { inspection_data: Record | null; } +export interface InspectionReportNodeGroup { + node_id: number | null; + node_name: string; + floor: string; + code: string; + width: number; + height: number; + total_quantity: number; + options: Record; + items: InspectionReportItem[]; +} + export interface InspectionReportData { work_order: { id: number; @@ -621,6 +691,7 @@ export interface InspectionReportData { site_name: string | null; order_date: string | null; } | null; + node_groups?: InspectionReportNodeGroup[]; items: InspectionReportItem[]; summary: { total_items: number;