diff --git a/src/components/material/ReceivingManagement/ReceivingDetail.tsx b/src/components/material/ReceivingManagement/ReceivingDetail.tsx index 5b1e5525..5fec58a9 100644 --- a/src/components/material/ReceivingManagement/ReceivingDetail.tsx +++ b/src/components/material/ReceivingManagement/ReceivingDetail.tsx @@ -17,6 +17,7 @@ import { useState, useCallback, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { Upload, FileText } from 'lucide-react'; +import { SearchableSelect, type SearchableSelectOption } from '@/components/ui/searchable-select'; import { InspectionModalV2 } from '@/app/[locale]/(protected)/quality/qms/components/InspectionModalV2'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -32,7 +33,14 @@ import { import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate'; import { receivingConfig } from './receivingConfig'; import { ServerErrorPage } from '@/components/common/ServerErrorPage'; -import { getReceivingById, createReceiving, updateReceiving } from './actions'; +import { + getReceivingById, + createReceiving, + updateReceiving, + searchItems, + searchSuppliers, + type ItemOption, +} from './actions'; import { RECEIVING_STATUS_OPTIONS, type ReceivingDetail as ReceivingDetailType, @@ -82,6 +90,24 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) { // 수입검사 성적서 모달 상태 const [isInspectionModalOpen, setIsInspectionModalOpen] = useState(false); + // 품목/발주처 검색 옵션 + const [itemOptions, setItemOptions] = useState([]); + const [supplierOptions, setSupplierOptions] = useState([]); + + // 품목/발주처 옵션 초기 로드 + useEffect(() => { + if (!isNewMode && !isEditMode) return; + const loadOptions = async () => { + const [itemsResult, suppliersResult] = await Promise.all([ + searchItems(), + searchSuppliers(), + ]); + if (itemsResult.success) setItemOptions(itemsResult.data); + if (suppliersResult.success) setSupplierOptions(suppliersResult.data); + }; + loadOptions(); + }, [isNewMode, isEditMode]); + // API 데이터 로드 const loadData = useCallback(async () => { if (isNewMode) { @@ -277,18 +303,30 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) { {/* 로트번호 - 읽기전용 */} {renderReadOnlyField('로트번호', formData.lotNo, true)} - {/* 품목코드 - 수정가능 */} + {/* 품목코드 - 검색 선택 */}
-
{/* 품목명 - 읽기전용 */} @@ -300,18 +338,21 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) { {/* 단위 - 읽기전용 */} {renderReadOnlyField('단위', formData.unit, true)} - {/* 발주처 - 수정가능 */} + {/* 발주처 - 검색 선택 */}
-
{/* 입고수량 - 수정가능 */} @@ -459,14 +500,16 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) { ) || {}} itemId={isNewMode ? undefined : id} isLoading={isLoading} - isSaving={isSaving} headerActions={customHeaderActions} renderView={() => renderViewContent()} renderForm={() => renderFormContent()} - onSave={handleSave} + onSubmit={async () => { + await handleSave(); + return { success: true }; + }} onCancel={handleCancel} /> @@ -478,6 +521,7 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) { id: 'import-inspection', type: 'import', title: '수입검사 성적서', + count: 0, }} documentItem={{ id: id, diff --git a/src/components/material/ReceivingManagement/actions.ts b/src/components/material/ReceivingManagement/actions.ts index f7257ae2..882354d3 100644 --- a/src/components/material/ReceivingManagement/actions.ts +++ b/src/components/material/ReceivingManagement/actions.ts @@ -310,6 +310,7 @@ function transformApiToListItem(data: ReceivingApiData): ReceivingItem { orderUnit: data.order_unit || 'EA', receivingQty: data.receiving_qty ? parseFloat(String(data.receiving_qty)) : undefined, lotNo: data.lot_no, + unit: data.order_unit || 'EA', status: data.status, }; } @@ -334,6 +335,7 @@ function transformApiToDetail(data: ReceivingApiData): ReceivingDetail { supplierLot: data.supplier_lot, receivingLocation: data.receiving_location, receivingManager: data.receiving_manager, + unit: data.order_unit || 'EA', }; } @@ -341,8 +343,10 @@ function transformApiToDetail(data: ReceivingApiData): ReceivingDetail { function transformApiToStats(data: ReceivingApiStatsResponse): ReceivingStats { return { receivingPendingCount: data.receiving_pending_count, - shippingCount: data.shipping_count, + receivingCompletedCount: data.shipping_count, inspectionPendingCount: data.inspection_pending_count, + inspectionCompletedCount: data.today_receiving_count, + shippingCount: data.shipping_count, todayReceivingCount: data.today_receiving_count, }; } @@ -745,6 +749,135 @@ export async function processReceiving( } } +// ===== 품목 검색 (입고 등록용) ===== +export interface ItemOption { + value: string; // itemCode + label: string; // itemCode 표시 + description?: string; // 품목명 + 규격 + itemName: string; + specification: string; + unit: string; +} + +const MOCK_ITEMS: ItemOption[] = [ + { value: 'STEEL-001', label: 'STEEL-001', description: 'SUS304 스테인리스 판재 (1000x2000x3T)', itemName: 'SUS304 스테인리스 판재', specification: '1000x2000x3T', unit: 'EA' }, + { value: 'STEEL-002', label: 'STEEL-002', description: '알루미늄 프로파일 (40x40x2000L)', itemName: '알루미늄 프로파일', specification: '40x40x2000L', unit: 'EA' }, + { value: 'ELEC-002', label: 'ELEC-002', description: 'MCU 컨트롤러 IC (STM32F103C8T6)', itemName: 'MCU 컨트롤러 IC', specification: 'STM32F103C8T6', unit: 'EA' }, + { value: 'ELEC-005', label: 'ELEC-005', description: 'DC 모터 24V (24V 100RPM)', itemName: 'DC 모터 24V', specification: '24V 100RPM', unit: 'EA' }, + { value: 'ELEC-007', label: 'ELEC-007', description: '커패시터 100uF (100uF 50V)', itemName: '커패시터 100uF', specification: '100uF 50V', unit: 'EA' }, + { value: 'PLAS-003', label: 'PLAS-003', description: 'ABS 사출 케이스 (150x100x50)', itemName: 'ABS 사출 케이스', specification: '150x100x50', unit: 'SET' }, + { value: 'CHEM-001', label: 'CHEM-001', description: '에폭시 접착제 (500ml)', itemName: '에폭시 접착제', specification: '500ml', unit: 'EA' }, + { value: 'BOLT-001', label: 'BOLT-001', description: 'SUS 볼트 M8x30 (M8x30 SUS304)', itemName: 'SUS 볼트 M8x30', specification: 'M8x30 SUS304', unit: 'EA' }, +]; + +export async function searchItems(query?: string): Promise<{ + success: boolean; + data: ItemOption[]; +}> { + if (USE_MOCK_DATA) { + if (!query) return { success: true, data: MOCK_ITEMS }; + const q = query.toLowerCase(); + const filtered = MOCK_ITEMS.filter( + (item) => + item.value.toLowerCase().includes(q) || + item.itemName.toLowerCase().includes(q) + ); + return { success: true, data: filtered }; + } + + try { + const searchParams = new URLSearchParams(); + if (query) searchParams.set('search', query); + searchParams.set('per_page', '50'); + + const { response, error } = await serverFetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/v1/items?${searchParams.toString()}`, + { method: 'GET', cache: 'no-store' } + ); + + if (error || !response) { + return { success: false, data: [] }; + } + + const result = await response.json(); + if (!response.ok || !result.success) { + return { success: false, data: [] }; + } + + const items: ItemOption[] = (result.data?.data || []).map((item: Record) => ({ + value: item.item_code, + label: item.item_code, + description: `${item.item_name} (${item.specification || '-'})`, + itemName: item.item_name, + specification: item.specification || '', + unit: item.unit || 'EA', + })); + + return { success: true, data: items }; + } catch (error) { + if (isNextRedirectError(error)) throw error; + return { success: false, data: [] }; + } +} + +// ===== 발주처 검색 (입고 등록용) ===== +export interface SupplierOption { + value: string; + label: string; +} + +const MOCK_SUPPLIERS: SupplierOption[] = [ + { value: '(주)대한철강', label: '(주)대한철강' }, + { value: '삼성전자부품', label: '삼성전자부품' }, + { value: '한국플라스틱', label: '한국플라스틱' }, + { value: '글로벌전자', label: '글로벌전자' }, + { value: '동양화학', label: '동양화학' }, + { value: '한국볼트', label: '한국볼트' }, + { value: '지오TNS (KG스틸)', label: '지오TNS (KG스틸)' }, +]; + +export async function searchSuppliers(query?: string): Promise<{ + success: boolean; + data: SupplierOption[]; +}> { + if (USE_MOCK_DATA) { + if (!query) return { success: true, data: MOCK_SUPPLIERS }; + const q = query.toLowerCase(); + const filtered = MOCK_SUPPLIERS.filter((s) => s.label.toLowerCase().includes(q)); + return { success: true, data: filtered }; + } + + try { + const searchParams = new URLSearchParams(); + if (query) searchParams.set('search', query); + searchParams.set('per_page', '50'); + + const { response, error } = await serverFetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/v1/suppliers?${searchParams.toString()}`, + { method: 'GET', cache: 'no-store' } + ); + + if (error || !response) { + return { success: false, data: [] }; + } + + const result = await response.json(); + if (!response.ok || !result.success) { + return { success: false, data: [] }; + } + + const suppliers: SupplierOption[] = (result.data?.data || []).map((s: Record) => ({ + value: s.name, + label: s.name, + })); + + return { success: true, data: suppliers }; + } catch (error) { + if (isNextRedirectError(error)) throw error; + return { success: false, data: [] }; + } +} + // ===== 수입검사 템플릿 타입 (ImportInspectionDocument와 동일) ===== export interface InspectionTemplateResponse { templateId: string; diff --git a/src/components/ui/searchable-select.tsx b/src/components/ui/searchable-select.tsx new file mode 100644 index 00000000..4d48e815 --- /dev/null +++ b/src/components/ui/searchable-select.tsx @@ -0,0 +1,153 @@ +'use client'; + +/** + * SearchableSelect - 검색 가능한 단일 선택 컴포넌트 + * + * Popover + Command 패턴으로 구현 + * - 검색 가능하지만 직접 입력은 불가 (선택만 허용) + * - 선택 후 Popover 자동 닫힘 + */ + +import * as React from 'react'; +import { Check, ChevronsUpDown, Search } from 'lucide-react'; +import { cn } from './utils'; +import { Button } from './button'; +import { Popover, PopoverContent, PopoverTrigger } from './popover'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from './command'; + +export interface SearchableSelectOption { + value: string; + label: string; + description?: string; +} + +interface SearchableSelectProps { + options: SearchableSelectOption[]; + value: string; + onChange: (value: string, option: SearchableSelectOption) => void; + placeholder?: string; + searchPlaceholder?: string; + emptyText?: string; + disabled?: boolean; + className?: string; + /** 서버 검색 모드: 검색어 변경 시 호출 */ + onSearch?: (query: string) => void; + /** 로딩 상태 */ + isLoading?: boolean; +} + +export function SearchableSelect({ + options, + value, + onChange, + placeholder = '선택하세요', + searchPlaceholder = '검색...', + emptyText = '결과가 없습니다', + disabled = false, + className, + onSearch, + isLoading = false, +}: SearchableSelectProps) { + const [open, setOpen] = React.useState(false); + const [searchQuery, setSearchQuery] = React.useState(''); + + const selectedOption = options.find((opt) => opt.value === value); + + const handleSelect = (selectedValue: string) => { + const option = options.find((opt) => opt.value === selectedValue); + if (option) { + onChange(selectedValue, option); + setOpen(false); + setSearchQuery(''); + } + }; + + const handleSearchChange = (query: string) => { + setSearchQuery(query); + onSearch?.(query); + }; + + // Popover 닫힐 때 검색어 초기화 + const handleOpenChange = (isOpen: boolean) => { + setOpen(isOpen); + if (!isOpen) { + setSearchQuery(''); + } + }; + + return ( + + + + + + + + + {isLoading ? ( +
+ + 검색 중... +
+ ) : ( + <> + {emptyText} + + {options.map((option) => ( + handleSelect(option.value)} + className="cursor-pointer" + > + +
+ {option.label} + {option.description && ( + + {option.description} + + )} +
+
+ ))} +
+ + )} +
+
+
+
+ ); +} \ No newline at end of file