diff options
| author | joonhoekim <26rote@gmail.com> | 2025-09-22 18:59:13 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-09-22 18:59:13 +0900 |
| commit | ba35e67845f935c8ce0151c9ef1fefa0b0510faf (patch) | |
| tree | d05eb27fab2acc54a839b2590c89e860d58fb747 | |
| parent | e4bd037d158513e45373ad9e1ef13f71af12162a (diff) | |
(김준회) AVL 피드백 반영 (이진용 프로 건)
27 files changed, 2785 insertions, 2041 deletions
diff --git a/app/[lng]/evcp/(evcp)/avl/avl-page-client.tsx b/app/[lng]/evcp/(evcp)/avl/avl-page-client.tsx index f8df3c49..7152bdc2 100644 --- a/app/[lng]/evcp/(evcp)/avl/avl-page-client.tsx +++ b/app/[lng]/evcp/(evcp)/avl/avl-page-client.tsx @@ -1,11 +1,6 @@ "use client" import { useEffect, useState } from "react" -import { - ResizablePanelGroup, - ResizablePanel, - ResizableHandle, -} from "@/components/ui/resizable" import { AvlTable } from "@/lib/avl/table/avl-table" import { AvlRegistrationArea } from "@/lib/avl/table/avl-registration-area" import { getAvlLists } from "@/lib/avl/service" @@ -21,7 +16,6 @@ export function AvlPageClient({ initialData }: AvlPageClientProps) { const [avlListData, setAvlListData] = useState<AvlListItem[]>(initialData) const [isLoading, setIsLoading] = useState(false) const [registrationMode, setRegistrationMode] = useState<'standard' | 'project' | null>(null) - const [selectedAvlRow, setSelectedAvlRow] = useState<AvlListItem | null>(null) // 초기 데이터 설정 useEffect(() => { @@ -71,47 +65,33 @@ export function AvlPageClient({ initialData }: AvlPageClientProps) { setRegistrationMode(mode) } - // 행 선택 핸들러 - const handleRowSelect = (selectedRow: AvlListItem | null) => { - setSelectedAvlRow(selectedRow) - } + // 행 선택 핸들러 (현재는 사용하지 않지만 향후 확장 대비) + const handleRowSelect = () => {} return ( - <div className="h-screen flex flex-col"> - <div className="flex-1 overflow-hidden"> - - - {/* info button and header section */} - <div className="flex items-center gap-2 mt-2"> - <h2 className="text-2xl font-bold tracking-tight"> - AVL(Approved Vendor List) 목록 - </h2> - <InformationButton pagePath="evcp/avl" /> - </div> - - <ResizablePanelGroup direction="vertical" className="h-full"> - {/* 상단 패널: AVL 목록 */} - <ResizablePanel defaultSize={40} minSize={20}> - <div className="h-full p-4"> - <AvlTable - data={avlListData} - onRefresh={handleRefresh} - isLoading={isLoading} - onRegistrationModeChange={handleRegistrationModeChange} - onRowSelect={handleRowSelect} - /> - </div> - </ResizablePanel> + <div className="min-h-screen"> + {/* info button and header section */} + <div className="flex items-center gap-2 mt-2"> + <h2 className="text-2xl font-bold tracking-tight"> + AVL(Approved Vendor List) 목록 + </h2> + <InformationButton pagePath="evcp/avl" /> + </div> - <ResizableHandle withHandle /> + {/* 상단: AVL 목록 */} + <div className="p-4"> + <AvlTable + data={avlListData} + onRefresh={handleRefresh} + isLoading={isLoading} + onRegistrationModeChange={handleRegistrationModeChange} + onRowSelect={handleRowSelect} + /> + </div> - {/* 하단 패널: AVL 등록 */} - <ResizablePanel defaultSize={60} minSize={30}> - <div className="h-full p-4 overflow-x-auto overflow-y-hidden"> - <AvlRegistrationArea disabled={registrationMode === null} /> - </div> - </ResizablePanel> - </ResizablePanelGroup> + {/* 하단: AVL 등록 */} + <div className="p-4 overflow-x-auto"> + <AvlRegistrationArea disabled={registrationMode === null} /> </div> </div> ) diff --git a/components/common/discipline/discipline-service.ts b/components/common/discipline/discipline-service.ts new file mode 100644 index 00000000..cb6edb0e --- /dev/null +++ b/components/common/discipline/discipline-service.ts @@ -0,0 +1,123 @@ +"use server" + +import db from '@/db/db' +import { cmctbCd } from '@/db/schema/NONSAP/nonsap' +import { eq, or, ilike, and } from 'drizzle-orm' + +// 설계공종코드 타입 정의 +export interface DisciplineCode { + CD: string // 설계공종코드 + USR_DF_CHAR_18: string // 설계공종명 +} + +// 설계공종코드 검색 옵션 +export interface DisciplineSearchOptions { + searchTerm?: string + limit?: number +} + +/** + * 설계공종코드 목록 조회 + * 내부 PostgreSQL DB의 cmctbCd 테이블에서 CD_CLF = 'PLJP43' 조건으로 조회 + */ +export async function getDisciplineCodes(options: DisciplineSearchOptions = {}): Promise<{ + success: boolean + data: DisciplineCode[] + error?: string +}> { + try { + const { searchTerm, limit = 100 } = options + + // 검색어가 있는 경우와 없는 경우를 분리해서 처리 + let result; + + if (searchTerm && searchTerm.trim()) { + const term = `%${searchTerm.trim().toUpperCase()}%` + result = await db + .select({ + CD: cmctbCd.CD, + USR_DF_CHAR_18: cmctbCd.USR_DF_CHAR_18 + }) + .from(cmctbCd) + .where( + and( + eq(cmctbCd.CD_CLF, 'PLJP43'), + or( + ilike(cmctbCd.CD, term), + ilike(cmctbCd.USR_DF_CHAR_18, term) + ) + ) + ) + .orderBy(cmctbCd.CD) + .limit(limit > 0 ? limit : 100) + } else { + result = await db + .select({ + CD: cmctbCd.CD, + USR_DF_CHAR_18: cmctbCd.USR_DF_CHAR_18 + }) + .from(cmctbCd) + .where(eq(cmctbCd.CD_CLF, 'PLJP43')) + .orderBy(cmctbCd.CD) + .limit(limit > 0 ? limit : 100) + } + + // null 값 처리 + const cleanedResult = result + .filter(item => item.CD && item.USR_DF_CHAR_18) + .map(item => ({ + CD: item.CD!, + USR_DF_CHAR_18: item.USR_DF_CHAR_18! + })) + + return { + success: true, + data: cleanedResult + } + } catch (error) { + console.error('Error fetching discipline codes:', error) + return { + success: false, + data: [], + error: '설계공종코드를 조회하는 중 오류가 발생했습니다.' + } + } +} + +/** + * 특정 설계공종코드 조회 + * 내부 PostgreSQL DB의 cmctbCd 테이블에서 조회 + */ +export async function getDisciplineCodeByCode(code: string): Promise<DisciplineCode | null> { + if (!code.trim()) { + return null + } + + try { + const result = await db + .select({ + CD: cmctbCd.CD, + USR_DF_CHAR_18: cmctbCd.USR_DF_CHAR_18 + }) + .from(cmctbCd) + .where( + and( + eq(cmctbCd.CD_CLF, 'PLJP43'), + eq(cmctbCd.CD, code.trim().toUpperCase()) + ) + ) + .limit(1) + + if (result.length > 0 && result[0].CD && result[0].USR_DF_CHAR_18) { + return { + CD: result[0].CD, + USR_DF_CHAR_18: result[0].USR_DF_CHAR_18 + } + } + + return null + } catch (error) { + console.error('Error fetching discipline code by code:', error) + return null + } +} diff --git a/components/common/discipline/engineering-discipline-selector.tsx b/components/common/discipline/engineering-discipline-selector.tsx new file mode 100644 index 00000000..8bf0cbb9 --- /dev/null +++ b/components/common/discipline/engineering-discipline-selector.tsx @@ -0,0 +1,315 @@ +'use client' + +/** + * 설계공종코드 선택기 + * + * @description + * - 오라클에서 CMCTB_CD 테이블에 대해, CD_CLF = 'PLJP43' 인 건들을 조회 + * - 조회 결과 중 CD 가 설계공종코드 + * - USR_DF_CHAR_18 이 설계공종명 + */ + +import { useState, useCallback, useMemo, useTransition } from 'react' +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Search, Check } from 'lucide-react' +import { + ColumnDef, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, + SortingState, + ColumnFiltersState, + VisibilityState, + RowSelectionState, +} from '@tanstack/react-table' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { getDisciplineCodes, DisciplineCode, DisciplineSearchOptions } from './discipline-service' +import { toast } from 'sonner' + +// 간단한 디바운스 함수 +function debounce<T extends (...args: unknown[]) => void>(func: T, delay: number): T { + let timeoutId: NodeJS.Timeout + return ((...args: Parameters<T>) => { + clearTimeout(timeoutId) + timeoutId = setTimeout(() => func(...args), delay) + }) as T +} + +export interface EngineeringDisciplineSelectorProps { + selectedDiscipline?: DisciplineCode + onDisciplineSelect: (discipline: DisciplineCode) => void + disabled?: boolean + searchOptions?: Partial<DisciplineSearchOptions> + placeholder?: string + className?: string +} + +export function EngineeringDisciplineSelector({ + selectedDiscipline, + onDisciplineSelect, + disabled, + searchOptions = {}, + placeholder = "설계공종을 선택하세요", + className +}: EngineeringDisciplineSelectorProps) { + const [open, setOpen] = useState(false) + const [disciplines, setDisciplines] = useState<DisciplineCode[]>([]) + const [sorting, setSorting] = useState<SortingState>([]) + const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]) + const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({}) + const [rowSelection, setRowSelection] = useState<RowSelectionState>({}) + const [globalFilter, setGlobalFilter] = useState('') + const [isPending, startTransition] = useTransition() + + // searchOptions 안정화 + const stableSearchOptions = useMemo(() => ({ + limit: 100, + ...searchOptions + }), [searchOptions]) + + // 설계공종 선택 핸들러 + const handleDisciplineSelect = useCallback((discipline: DisciplineCode) => { + onDisciplineSelect(discipline) + setOpen(false) + }, [onDisciplineSelect]) + + // 테이블 컬럼 정의 + const columns: ColumnDef<DisciplineCode>[] = useMemo(() => [ + { + accessorKey: 'CD', + header: '설계공종코드', + cell: ({ row }) => ( + <div className="font-mono text-sm">{row.getValue('CD')}</div> + ), + }, + { + accessorKey: 'USR_DF_CHAR_18', + header: '설계공종명', + cell: ({ row }) => ( + <div className="max-w-[200px] truncate">{row.getValue('USR_DF_CHAR_18')}</div> + ), + }, + { + id: 'actions', + header: '선택', + cell: ({ row }) => ( + <Button + variant="ghost" + size="sm" + onClick={(e) => { + e.stopPropagation() + handleDisciplineSelect(row.original) + }} + > + <Check className="h-4 w-4" /> + </Button> + ), + }, + ], [handleDisciplineSelect]) + + // 설계공종 테이블 설정 + const table = useReactTable({ + data: disciplines, + columns, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setRowSelection, + onGlobalFilterChange: setGlobalFilter, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + state: { + sorting, + columnFilters, + columnVisibility, + rowSelection, + globalFilter, + }, + }) + + // 서버 액션을 사용한 설계공종 목록 로드 + const loadDisciplines = useCallback(async (searchTerm?: string) => { + startTransition(async () => { + try { + const result = await getDisciplineCodes({ + ...stableSearchOptions, + searchTerm: searchTerm + }) + + if (result.success) { + setDisciplines(result.data) + } else { + toast.error(result.error || '설계공종코드를 불러오는데 실패했습니다.') + setDisciplines([]) + } + } catch (error) { + console.error('설계공종코드 목록 로드 실패:', error) + toast.error('설계공종코드를 불러오는 중 오류가 발생했습니다.') + setDisciplines([]) + } + }) + }, [stableSearchOptions]) + + // 디바운스된 검색 (클라이언트 로직만) + const debouncedSearch = useMemo( + () => debounce((searchTerm: string) => { + loadDisciplines(searchTerm) + }, 300), + [loadDisciplines] + ) + + // 다이얼로그 열기 핸들러 + const handleDialogOpenChange = useCallback((newOpen: boolean) => { + setOpen(newOpen) + if (newOpen && disciplines.length === 0) { + loadDisciplines() + } + }, [loadDisciplines, disciplines.length]) + + // 검색어 변경 핸들러 + const handleSearchChange = useCallback((value: string) => { + setGlobalFilter(value) + if (open) { + debouncedSearch(value) + } + }, [open, debouncedSearch]) + + return ( + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + <DialogTrigger asChild> + <Button + variant="outline" + disabled={disabled} + className={`w-full justify-start ${className || ''}`} + > + {selectedDiscipline ? ( + <div className="flex items-center gap-2 w-full"> + <span className="font-mono text-sm">[{selectedDiscipline.CD}]</span> + <span className="truncate flex-1 text-left">{selectedDiscipline.USR_DF_CHAR_18}</span> + </div> + ) : ( + <span className="text-muted-foreground">{placeholder}</span> + )} + </Button> + </DialogTrigger> + <DialogContent className="max-w-4xl max-h-[80vh]"> + <DialogHeader> + <DialogTitle>설계공종 선택</DialogTitle> + <div className="text-sm text-muted-foreground"> + 설계공종코드(CD_CLF=PLJP43) 조회 + </div> + </DialogHeader> + + <div className="space-y-4"> + <div className="flex items-center space-x-2"> + <Search className="h-4 w-4" /> + <Input + placeholder="설계공종코드, 설계공종명으로 검색..." + value={globalFilter} + onChange={(e) => handleSearchChange(e.target.value)} + className="flex-1" + /> + </div> + + {isPending ? ( + <div className="flex justify-center py-8"> + <div className="text-sm text-muted-foreground">설계공종코드를 불러오는 중...</div> + </div> + ) : ( + <div className="border rounded-md"> + <Table> + <TableHeader> + {table.getHeaderGroups().map((headerGroup) => ( + <TableRow key={headerGroup.id}> + {headerGroup.headers.map((header) => ( + <TableHead key={header.id}> + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + </TableHead> + ))} + </TableRow> + ))} + </TableHeader> + <TableBody> + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + <TableRow + key={row.id} + data-state={row.getIsSelected() && "selected"} + className="cursor-pointer hover:bg-muted/50" + onClick={() => handleDisciplineSelect(row.original)} + > + {row.getVisibleCells().map((cell) => ( + <TableCell key={cell.id}> + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + </TableCell> + ))} + </TableRow> + )) + ) : ( + <TableRow> + <TableCell + colSpan={columns.length} + className="h-24 text-center" + > + 검색 결과가 없습니다. + </TableCell> + </TableRow> + )} + </TableBody> + </Table> + </div> + )} + + <div className="flex items-center justify-between"> + <div className="text-sm text-muted-foreground"> + 총 {table.getFilteredRowModel().rows.length}개 설계공종 + </div> + <div className="flex items-center space-x-2"> + <Button + variant="outline" + size="sm" + onClick={() => table.previousPage()} + disabled={!table.getCanPreviousPage()} + > + 이전 + </Button> + <div className="text-sm"> + {table.getState().pagination.pageIndex + 1} / {table.getPageCount()} + </div> + <Button + variant="outline" + size="sm" + onClick={() => table.nextPage()} + disabled={!table.getCanNextPage()} + > + 다음 + </Button> + </div> + </div> + </div> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/components/common/discipline/index.ts b/components/common/discipline/index.ts new file mode 100644 index 00000000..67f499bf --- /dev/null +++ b/components/common/discipline/index.ts @@ -0,0 +1,9 @@ +// 공용 설계공종 관련 컴포넌트 및 서비스 +export { EngineeringDisciplineSelector } from './engineering-discipline-selector' +export type { EngineeringDisciplineSelectorProps } from './engineering-discipline-selector' +export { + getDisciplineCodes, + getDisciplineCodeByCode, + type DisciplineCode, + type DisciplineSearchOptions +} from './discipline-service' diff --git a/components/common/selectors/place-of-shipping/index.ts b/components/common/selectors/place-of-shipping/index.ts new file mode 100644 index 00000000..8d157b4d --- /dev/null +++ b/components/common/selectors/place-of-shipping/index.ts @@ -0,0 +1,2 @@ +export { PlaceOfShippingSelector } from './place-of-shipping-selector' +export type { PlaceOfShippingSelectorProps } from './place-of-shipping-selector' diff --git a/components/common/selectors/place-of-shipping/place-of-shipping-selector.tsx b/components/common/selectors/place-of-shipping/place-of-shipping-selector.tsx new file mode 100644 index 00000000..8230e265 --- /dev/null +++ b/components/common/selectors/place-of-shipping/place-of-shipping-selector.tsx @@ -0,0 +1,75 @@ + + + +'use client' + +/** + * 선적지/하역지 선택기 + * + * placeOfShipping 테이블 기준으로 선적지/하역지에 쓰이는 장소코드 및 장소명 선택 + */ + +import { Select, SelectItem, SelectContent } from "@/components/ui/select" +import { SelectTrigger } from "@/components/ui/select" +import { SelectValue } from "@/components/ui/select" +import { useState, useEffect } from "react" +import { getPlaceOfShippingForSelection } from "./place-of-shipping-service" + +interface PlaceOfShippingData { + code: string + description: string +} + +interface PlaceOfShippingSelectorProps { + value?: string + onValueChange?: (value: string) => void + placeholder?: string + disabled?: boolean + className?: string +} + +export function PlaceOfShippingSelector({ + value = "", + onValueChange, + placeholder = "선적지/하역지 선택", + disabled = false, + className +}: PlaceOfShippingSelectorProps) { + const [placeOfShippingData, setPlaceOfShippingData] = useState<PlaceOfShippingData[]>([]) + const [isLoading, setIsLoading] = useState(true) + + useEffect(() => { + const loadData = async () => { + try { + const data = await getPlaceOfShippingForSelection() + setPlaceOfShippingData(data) + } catch (error) { + console.error('선적지/하역지 데이터 로드 실패:', error) + setPlaceOfShippingData([]) + } finally { + setIsLoading(false) + } + } + + loadData() + }, []) + + return ( + <Select + value={value} + onValueChange={onValueChange} + disabled={disabled || isLoading} + > + <SelectTrigger className={className}> + <SelectValue placeholder={isLoading ? "로딩 중..." : placeholder} /> + </SelectTrigger> + <SelectContent> + {placeOfShippingData.map((item) => ( + <SelectItem key={item.code} value={item.code}> + {item.code} {item.description} + </SelectItem> + ))} + </SelectContent> + </Select> + ) +}
\ No newline at end of file diff --git a/components/common/selectors/place-of-shipping/place-of-shipping-service.tsx b/components/common/selectors/place-of-shipping/place-of-shipping-service.tsx new file mode 100644 index 00000000..22026739 --- /dev/null +++ b/components/common/selectors/place-of-shipping/place-of-shipping-service.tsx @@ -0,0 +1,17 @@ +'use server' + +import db from "@/db/db" +import { placeOfShipping } from "@/db/schema" +import { eq } from "drizzle-orm" + + +/** + * @returns 활성화된 모든 선적지/하역지 코드 및 장소명 + */ +export async function getPlaceOfShippingForSelection(): Promise<{ code: string; description: string }[]> { + const data = await db.select().from(placeOfShipping).where(eq(placeOfShipping.isActive, true)).orderBy(placeOfShipping.code) + return data.map((item) => ({ + code: item.code, + description: item.description + })) +}
\ No newline at end of file diff --git a/components/common/selectors/vendor-tier/index.ts b/components/common/selectors/vendor-tier/index.ts new file mode 100644 index 00000000..15b71410 --- /dev/null +++ b/components/common/selectors/vendor-tier/index.ts @@ -0,0 +1,2 @@ +export { VendorTierSelector } from './vendor-tier-selector' +export type { VendorTierSelectorProps } from './vendor-tier-selector' diff --git a/components/common/selectors/vendor-tier/vendor-tier-selector.tsx b/components/common/selectors/vendor-tier/vendor-tier-selector.tsx new file mode 100644 index 00000000..ba33c9cf --- /dev/null +++ b/components/common/selectors/vendor-tier/vendor-tier-selector.tsx @@ -0,0 +1,46 @@ +"use client" + +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { useState } from "react" + + + +/** + * 하드코딩된 티어 셀렉터 + * + * 벤더의 티어 + * + * 코드, 의미 + * Tier 1, "당사 협조도 우수, 필수 견적 의뢰 업체" + * Tier 2, "해당 품목 주요 제작사, Tier 1 후보군" + * 등급 외 "(Tier 1, 2 미해당 업체)" + */ + +interface VendorTierSelectorProps { + value?: string + onValueChange?: (value: string) => void + placeholder?: string + disabled?: boolean + className?: string +} + +export function VendorTierSelector({ + value = "", + onValueChange, + placeholder = "티어 선택", + disabled = false, + className +}: VendorTierSelectorProps) { + return ( + <Select value={value} onValueChange={onValueChange} disabled={disabled}> + <SelectTrigger className={className}> + <SelectValue placeholder={placeholder} /> + </SelectTrigger> + <SelectContent> + <SelectItem value="Tier 1">Tier 1: 당사 협조도 우수, 필수 견적 의뢰 업체</SelectItem> + <SelectItem value="Tier 2">Tier 2: 해당 품목 주요 제작사, Tier 1 후보군</SelectItem> + <SelectItem value="none">등급 외: Tier 1, 2 미해당 업체</SelectItem> + </SelectContent> + </Select> + ) +}
\ No newline at end of file diff --git a/components/common/ship-type/index.ts b/components/common/ship-type/index.ts new file mode 100644 index 00000000..fe202d76 --- /dev/null +++ b/components/common/ship-type/index.ts @@ -0,0 +1,2 @@ +export * from './ship-type-service' +export * from './ship-type-selector' diff --git a/components/common/ship-type/ship-type-selector.tsx b/components/common/ship-type/ship-type-selector.tsx new file mode 100644 index 00000000..7837f0ac --- /dev/null +++ b/components/common/ship-type/ship-type-selector.tsx @@ -0,0 +1,308 @@ +"use client" + +import React, { useState, useCallback, useMemo, useTransition } from 'react' +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Search, Check } from 'lucide-react' +import { + ColumnDef, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, + SortingState, + ColumnFiltersState, + VisibilityState, + RowSelectionState, +} from '@tanstack/react-table' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { getShipTypes, ShipTypeItem } from './ship-type-service' +import { toast } from 'sonner' + +// 간단한 디바운스 함수 +function debounce<T extends (...args: unknown[]) => void>(func: T, delay: number): T { + let timeoutId: NodeJS.Timeout + return ((...args: Parameters<T>) => { + clearTimeout(timeoutId) + timeoutId = setTimeout(() => func(...args), delay) + }) as T +} + +export interface ShipTypeSelectorProps { + selectedShipType?: ShipTypeItem + onShipTypeSelect: (shipType: ShipTypeItem) => void + disabled?: boolean + placeholder?: string + className?: string +} + +/** + * 선종 선택기 + * + * @description + * - CMCTB_CDNM 테이블에서 CD_CLF = 'PSA330' AND DEL_YN = 'N' 조건으로 선종 조회 + * - 조회 결과 중 CD가 선종코드, CDNM이 선종명 + * - 검색과 동시에 선택 (선종코드와 선종명으로 검색) + * - 건수가 대략 50개 정도이므로, 페이지네이션 없이 한번에 데이터 페칭 + */ +export function ShipTypeSelector({ + selectedShipType, + onShipTypeSelect, + disabled, + placeholder = "선종을 선택하세요", + className +}: ShipTypeSelectorProps) { + const [open, setOpen] = useState(false) + const [shipTypes, setShipTypes] = useState<ShipTypeItem[]>([]) + const [sorting, setSorting] = useState<SortingState>([]) + const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]) + const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({}) + const [rowSelection, setRowSelection] = useState<RowSelectionState>({}) + const [globalFilter, setGlobalFilter] = useState('') + const [isPending, startTransition] = useTransition() + + // 서버 액션을 사용한 선종 목록 로드 + const loadShipTypes = useCallback(async (searchTerm?: string) => { + startTransition(async () => { + try { + const result = await getShipTypes({ + searchTerm: searchTerm, + limit: 100 + }) + + if (result.success) { + setShipTypes(result.data) + } else { + toast.error(result.error || '선종을 불러오는데 실패했습니다.') + setShipTypes([]) + } + } catch (error) { + console.error('선종 목록 로드 실패:', error) + toast.error('선종을 불러오는 중 오류가 발생했습니다.') + setShipTypes([]) + } + }) + }, []) + + // 디바운스된 검색 (클라이언트 로직만) + const debouncedSearch = useMemo( + () => debounce((searchTerm: string) => { + loadShipTypes(searchTerm) + }, 300), + [loadShipTypes] + ) + + // 다이얼로그 열기 핸들러 + const handleDialogOpenChange = useCallback((newOpen: boolean) => { + setOpen(newOpen) + if (newOpen && shipTypes.length === 0) { + loadShipTypes() + } + }, [loadShipTypes, shipTypes.length]) + + // 검색어 변경 핸들러 + const handleSearchChange = useCallback((value: string) => { + setGlobalFilter(value) + if (open) { + debouncedSearch(value) + } + }, [open, debouncedSearch]) + + // 선종 선택 핸들러 + const handleShipTypeSelect = useCallback((shipType: ShipTypeItem) => { + onShipTypeSelect(shipType) + setOpen(false) + }, [onShipTypeSelect]) + + // 테이블 컬럼 정의 + const columns: ColumnDef<ShipTypeItem>[] = useMemo(() => [ + { + accessorKey: 'CD', + header: '선종코드', + cell: ({ row }) => ( + <div className="font-mono text-sm">{row.getValue('CD')}</div> + ), + }, + { + accessorKey: 'CDNM', + header: '선종명', + cell: ({ row }) => ( + <div className="max-w-[200px] truncate">{row.getValue('CDNM')}</div> + ), + }, + { + id: 'actions', + header: '선택', + cell: ({ row }) => ( + <Button + variant="ghost" + size="sm" + onClick={(e) => { + e.stopPropagation() + handleShipTypeSelect(row.original) + }} + > + <Check className="h-4 w-4" /> + </Button> + ), + }, + ], [handleShipTypeSelect]) + + // 선종 테이블 설정 + const table = useReactTable({ + data: shipTypes, + columns, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setRowSelection, + onGlobalFilterChange: setGlobalFilter, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + state: { + sorting, + columnFilters, + columnVisibility, + rowSelection, + globalFilter, + }, + }) + + return ( + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + <DialogTrigger asChild> + <Button + variant="outline" + disabled={disabled} + className={`w-full justify-start ${className || ''}`} + > + {selectedShipType ? ( + <div className="flex items-center gap-2 w-full"> + <span className="font-mono text-sm">[{selectedShipType.CD}]</span> + <span className="truncate flex-1 text-left">{selectedShipType.CDNM}</span> + </div> + ) : ( + <span className="text-muted-foreground">{placeholder}</span> + )} + </Button> + </DialogTrigger> + <DialogContent className="max-w-4xl max-h-[80vh]"> + <DialogHeader> + <DialogTitle>선종 선택</DialogTitle> + <div className="text-sm text-muted-foreground"> + 선종코드(CD_CLF=PSA330) 조회 + </div> + </DialogHeader> + + <div className="space-y-4"> + <div className="flex items-center space-x-2"> + <Search className="h-4 w-4" /> + <Input + placeholder="선종코드, 선종명으로 검색..." + value={globalFilter} + onChange={(e) => handleSearchChange(e.target.value)} + className="flex-1" + /> + </div> + + {isPending ? ( + <div className="flex justify-center py-8"> + <div className="text-sm text-muted-foreground">선종을 불러오는 중...</div> + </div> + ) : ( + <div className="border rounded-md"> + <Table> + <TableHeader> + {table.getHeaderGroups().map((headerGroup) => ( + <TableRow key={headerGroup.id}> + {headerGroup.headers.map((header) => ( + <TableHead key={header.id}> + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + </TableHead> + ))} + </TableRow> + ))} + </TableHeader> + <TableBody> + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + <TableRow + key={row.id} + data-state={row.getIsSelected() && "selected"} + className="cursor-pointer hover:bg-muted/50" + onClick={() => handleShipTypeSelect(row.original)} + > + {row.getVisibleCells().map((cell) => ( + <TableCell key={cell.id}> + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + </TableCell> + ))} + </TableRow> + )) + ) : ( + <TableRow> + <TableCell + colSpan={columns.length} + className="h-24 text-center" + > + 검색 결과가 없습니다. + </TableCell> + </TableRow> + )} + </TableBody> + </Table> + </div> + )} + + <div className="flex items-center justify-between"> + <div className="text-sm text-muted-foreground"> + 총 {table.getFilteredRowModel().rows.length}개 선종 + </div> + <div className="flex items-center space-x-2"> + <Button + variant="outline" + size="sm" + onClick={() => table.previousPage()} + disabled={!table.getCanPreviousPage()} + > + 이전 + </Button> + <div className="text-sm"> + {table.getState().pagination.pageIndex + 1} / {table.getPageCount()} + </div> + <Button + variant="outline" + size="sm" + onClick={() => table.nextPage()} + disabled={!table.getCanNextPage()} + > + 다음 + </Button> + </div> + </div> + </div> + </DialogContent> + </Dialog> + ) +} + diff --git a/components/common/ship-type/ship-type-service.ts b/components/common/ship-type/ship-type-service.ts new file mode 100644 index 00000000..9a952478 --- /dev/null +++ b/components/common/ship-type/ship-type-service.ts @@ -0,0 +1,120 @@ +"use server" + +import db from '@/db/db' +import { cmctbCdnm } from '@/db/schema/NONSAP/nonsap' +import { eq, or, ilike, and, asc } from 'drizzle-orm' + +// 선종 타입 정의 +export interface ShipTypeItem { + CD: string // 선종코드 + CDNM: string // 선종명 + displayText: string // 표시용 텍스트 (CD + " - " + CDNM) +} + +// 선종 검색 옵션 +export interface ShipTypeSearchOptions { + searchTerm?: string + limit?: number +} + +/** + * 선종 목록 조회 + * cmctbCdnm 테이블에서 CD_CLF = 'PSA330' AND DEL_YN = 'N' 조건으로 조회 + */ +export async function getShipTypes(options: ShipTypeSearchOptions = {}): Promise<{ + success: boolean + data: ShipTypeItem[] + error?: string +}> { + try { + const { searchTerm, limit = 100 } = options + + // WHERE 조건 구성 + let whereClause = and( + eq(cmctbCdnm.CD_CLF, 'PSA330'), + eq(cmctbCdnm.DEL_YN, 'N') + ) + + // 검색어가 있는 경우 추가 조건 + if (searchTerm && searchTerm.trim()) { + const term = `%${searchTerm.trim().toUpperCase()}%` + whereClause = and( + whereClause, + or( + ilike(cmctbCdnm.CD, term), + ilike(cmctbCdnm.CDNM, term) + ) + ) + } + + const result = await db + .select({ + CD: cmctbCdnm.CD, + CDNM: cmctbCdnm.CDNM + }) + .from(cmctbCdnm) + .where(whereClause) + .orderBy(asc(cmctbCdnm.CD)) + .limit(limit > 0 ? limit : 100) + + // null 값 처리 및 displayText 추가 + const cleanedResult = result + .filter(item => item.CD && item.CDNM) + .map(item => ({ + CD: item.CD!, + CDNM: item.CDNM!, + displayText: `${item.CD} - ${item.CDNM}` + })) + + return { + success: true, + data: cleanedResult + } + } catch (error) { + console.error('Error fetching ship types:', error) + return { + success: false, + data: [], + error: '선종을 조회하는 중 오류가 발생했습니다.' + } + } +} + +/** + * 특정 선종 조회 (코드로) + */ +export async function getShipTypeByCode(code: string): Promise<ShipTypeItem | null> { + if (!code.trim()) { + return null + } + + try { + const result = await db + .select({ + CD: cmctbCdnm.CD, + CDNM: cmctbCdnm.CDNM + }) + .from(cmctbCdnm) + .where( + and( + eq(cmctbCdnm.CD_CLF, 'PSA330'), + eq(cmctbCdnm.DEL_YN, 'N'), + eq(cmctbCdnm.CD, code.trim().toUpperCase()) + ) + ) + .limit(1) + + if (result.length > 0 && result[0].CD && result[0].CDNM) { + return { + CD: result[0].CD, + CDNM: result[0].CDNM, + displayText: `${result[0].CD} - ${result[0].CDNM}` + } + } + + return null + } catch (error) { + console.error('Error fetching ship type by code:', error) + return null + } +} diff --git a/components/common/vendor/index.ts b/components/common/vendor/index.ts new file mode 100644 index 00000000..6e4a5a80 --- /dev/null +++ b/components/common/vendor/index.ts @@ -0,0 +1,19 @@ +// 벤더 선택기 컴포넌트들 및 관련 타입 export + +// 서비스 및 타입 +export { + searchVendorsForSelector, + getAllVendors, + getVendorById, + getVendorStatuses, + type VendorSearchItem, + type VendorSearchOptions, + type VendorPagination +} from './vendor-service' + +// 기본 선택기 컴포넌트 +export { VendorSelector } from './vendor-selector' + +// 다이얼로그 컴포넌트들 +export { VendorSelectorDialogSingle } from './vendor-selector-dialog-single' +export { VendorSelectorDialogMulti } from './vendor-selector-dialog-multi' diff --git a/components/common/vendor/vendor-selector-dialog-multi.tsx b/components/common/vendor/vendor-selector-dialog-multi.tsx new file mode 100644 index 00000000..32c8fa54 --- /dev/null +++ b/components/common/vendor/vendor-selector-dialog-multi.tsx @@ -0,0 +1,320 @@ +"use client" + +import React, { useState, useCallback } from "react" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { VendorSelector } from "./vendor-selector" +import { VendorSearchItem } from "./vendor-service" +import { X } from "lucide-react" + +/** + * 벤더 다중 선택 Dialog 컴포넌트 + * + * @description + * - VendorSelector를 Dialog로 래핑한 다중 선택 컴포넌트 + * - 버튼 클릭 시 Dialog가 열리고, 여러 벤더를 선택한 후 확인 버튼으로 완료 + * - 최대 선택 개수 제한 가능 + * + * @VendorSearchItem_Structure + * 상태에서 관리되는 벤더 객체의 형태: + * ```typescript + * interface VendorSearchItem { + * id: number; // 벤더 ID + * vendorName: string; // 벤더명 + * vendorCode: string | null; // 벤더코드 (없을 수 있음) + * status: string; // 벤더 상태 (ACTIVE, PENDING_REVIEW 등) + * displayText: string; // 표시용 텍스트 (vendorName + vendorCode) + * } + * ``` + * + * @state + * - open: Dialog 열림/닫힘 상태 + * - selectedVendors: 현재 선택된 벤더들 (배열) + * - tempSelectedVendors: Dialog 내에서 임시로 선택된 벤더들 (확인 버튼 클릭 전까지) + * + * @callback + * - onVendorsSelect: 벤더 선택 완료 시 호출되는 콜백 + * - 매개변수: VendorSearchItem[] + * - 선택된 벤더들의 배열 (빈 배열일 수도 있음) + * + * @usage + * ```tsx + * <VendorSelectorDialogMulti + * triggerLabel="벤더 선택 (다중)" + * selectedVendors={selectedVendors} + * onVendorsSelect={(vendors) => { + * setSelectedVendors(vendors); + * console.log('선택된 벤더들:', vendors); + * }} + * maxSelections={5} + * placeholder="벤더를 검색하세요..." + * /> + * ``` + */ + +interface VendorSelectorDialogMultiProps { + /** Dialog를 여는 트리거 버튼 텍스트 */ + triggerLabel?: string + /** 현재 선택된 벤더들 */ + selectedVendors?: VendorSearchItem[] + /** 벤더 선택 완료 시 호출되는 콜백 */ + onVendorsSelect?: (vendors: VendorSearchItem[]) => void + /** 최대 선택 가능한 벤더 개수 */ + maxSelections?: number + /** 검색 입력창 placeholder */ + placeholder?: string + /** Dialog 제목 */ + title?: string + /** Dialog 설명 */ + description?: string + /** 트리거 버튼 비활성화 여부 */ + disabled?: boolean + /** 트리거 버튼 variant */ + triggerVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" + /** 제외할 벤더 ID들 */ + excludeVendorIds?: Set<number> + /** 초기 데이터 표시 여부 */ + showInitialData?: boolean + /** 트리거 버튼에서 선택된 벤더들을 표시할지 여부 */ + showSelectedInTrigger?: boolean + /** 벤더 상태 필터 */ + statusFilter?: string +} + +export function VendorSelectorDialogMulti({ + triggerLabel = "벤더 선택", + selectedVendors = [], + onVendorsSelect, + maxSelections, + placeholder = "벤더를 검색하세요...", + title = "벤더 선택 (다중)", + description, + disabled = false, + triggerVariant = "outline", + excludeVendorIds, + showInitialData = true, + showSelectedInTrigger = true, + statusFilter +}: VendorSelectorDialogMultiProps) { + // Dialog 열림/닫힘 상태 + const [open, setOpen] = useState(false) + + // Dialog 내에서 임시로 선택된 벤더들 (확인 버튼 클릭 전까지) + const [tempSelectedVendors, setTempSelectedVendors] = useState<VendorSearchItem[]>([]) + + // Dialog 설명 동적 생성 + const dialogDescription = description || + (maxSelections + ? `원하는 벤더를 검색하고 선택해주세요. (최대 ${maxSelections}개)` + : "원하는 벤더를 검색하고 선택해주세요." + ) + + // Dialog 열림 시 현재 선택된 벤더들로 임시 선택 초기화 + const handleOpenChange = useCallback((newOpen: boolean) => { + setOpen(newOpen) + if (newOpen) { + setTempSelectedVendors([...selectedVendors]) + } + }, [selectedVendors]) + + // 벤더 선택 처리 (Dialog 내에서) + const handleVendorsChange = useCallback((vendors: VendorSearchItem[]) => { + setTempSelectedVendors(vendors) + }, []) + + // 확인 버튼 클릭 시 선택 완료 + const handleConfirm = useCallback(() => { + onVendorsSelect?.(tempSelectedVendors) + setOpen(false) + }, [tempSelectedVendors, onVendorsSelect]) + + // 취소 버튼 클릭 시 + const handleCancel = useCallback(() => { + setTempSelectedVendors([...selectedVendors]) + setOpen(false) + }, [selectedVendors]) + + // 전체 선택 해제 + const handleClearAll = useCallback(() => { + setTempSelectedVendors([]) + }, []) + + // 개별 벤더 제거 (트리거 버튼에서) + const handleRemoveVendor = useCallback((vendorToRemove: VendorSearchItem, e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + const newVendors = selectedVendors.filter( + (vendor) => vendor.id !== vendorToRemove.id + ) + onVendorsSelect?.(newVendors) + }, [selectedVendors, onVendorsSelect]) + + // 벤더 상태별 색상 + const getStatusColor = (status: string) => { + switch (status) { + case 'ACTIVE': return 'bg-green-100 text-green-800' + case 'APPROVED': return 'bg-blue-100 text-blue-800' + case 'PENDING_REVIEW': return 'bg-yellow-100 text-yellow-800' + case 'INACTIVE': return 'bg-gray-100 text-gray-800' + default: return 'bg-gray-100 text-gray-800' + } + } + + // 트리거 버튼 렌더링 + const renderTriggerContent = () => { + if (selectedVendors.length === 0) { + return triggerLabel + } + + if (!showSelectedInTrigger) { + return `${triggerLabel} (${selectedVendors.length}개)` + } + + return ( + <div className="flex flex-wrap gap-1 items-center max-w-full"> + <span className="shrink-0">{triggerLabel}:</span> + {selectedVendors.slice(0, 2).map((vendor) => ( + <Badge + key={vendor.id} + variant="secondary" + className="gap-1 pr-1 max-w-[120px]" + > + <span className="truncate text-xs"> + {vendor.vendorName} + </span> + <Badge className={`${getStatusColor(vendor.status)} ml-1 text-xs`}> + {vendor.status} + </Badge> + {!disabled && ( + <button + type="button" + className="ml-1 h-3 w-3 rounded-sm hover:bg-red-100 flex items-center justify-center" + onClick={(e) => handleRemoveVendor(vendor, e)} + > + <X className="h-3 w-3 hover:text-red-500" /> + </button> + )} + </Badge> + ))} + {selectedVendors.length > 2 && ( + <Badge variant="outline" className="text-xs"> + +{selectedVendors.length - 2}개 + </Badge> + )} + </div> + ) + } + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogTrigger asChild> + <Button + variant={triggerVariant} + disabled={disabled} + className="min-h-[2.5rem] h-auto justify-start" + > + {renderTriggerContent()} + </Button> + </DialogTrigger> + + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle>{title}</DialogTitle> + <DialogDescription>{dialogDescription}</DialogDescription> + </DialogHeader> + + <div className="py-4"> + <VendorSelector + selectedVendors={tempSelectedVendors} + onVendorsChange={handleVendorsChange} + singleSelect={false} + maxSelections={maxSelections} + placeholder={placeholder} + noValuePlaceHolder="벤더를 선택해주세요" + closeOnSelect={false} + excludeVendorIds={excludeVendorIds} + showInitialData={showInitialData} + statusFilter={statusFilter} + /> + + {/* 선택된 벤더 개수 표시 */} + <div className="mt-2 text-sm text-muted-foreground"> + {maxSelections ? ( + `선택됨: ${tempSelectedVendors.length}/${maxSelections}개` + ) : ( + `선택됨: ${tempSelectedVendors.length}개` + )} + </div> + </div> + + <DialogFooter className="gap-2"> + <Button variant="outline" onClick={handleCancel}> + 취소 + </Button> + {tempSelectedVendors.length > 0 && ( + <Button variant="ghost" onClick={handleClearAll}> + 전체 해제 + </Button> + )} + <Button onClick={handleConfirm}> + 확인 ({tempSelectedVendors.length}개) + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} + +/** + * 사용 예시: + * + * ```tsx + * const [selectedVendors, setSelectedVendors] = useState<VendorSearchItem[]>([]); + * + * return ( + * <VendorSelectorDialogMulti + * triggerLabel="벤더 선택" + * selectedVendors={selectedVendors} + * onVendorsSelect={(vendors) => { + * setSelectedVendors(vendors); + * console.log('선택된 벤더들:', vendors.map(v => ({ + * id: v.id, + * name: v.vendorName, + * code: v.vendorCode, + * status: v.status + * }))); + * }} + * maxSelections={5} + * title="협력업체 선택" + * description="프로젝트에 참여할 협력업체들을 선택해주세요." + * showSelectedInTrigger={true} + * statusFilter="ACTIVE" + * /> + * ); + * ``` + * + * @advanced_usage + * ```tsx + * // 제외할 벤더가 있는 경우 + * const excludedVendorIds = new Set([1, 2, 3]); + * + * <VendorSelectorDialogMulti + * selectedVendors={selectedVendors} + * onVendorsSelect={setSelectedVendors} + * excludeVendorIds={excludedVendorIds} + * maxSelections={3} + * triggerVariant="default" + * showSelectedInTrigger={false} + * statusFilter="APPROVED" + * /> + * ``` + */ diff --git a/components/common/vendor/vendor-selector-dialog-single.tsx b/components/common/vendor/vendor-selector-dialog-single.tsx new file mode 100644 index 00000000..da9a9a74 --- /dev/null +++ b/components/common/vendor/vendor-selector-dialog-single.tsx @@ -0,0 +1,215 @@ +"use client" + +import React, { useState, useCallback } from "react" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { VendorSelector } from "./vendor-selector" +import { VendorSearchItem } from "./vendor-service" + +/** + * 벤더 단일 선택 Dialog 컴포넌트 + * + * @description + * - VendorSelector를 Dialog로 래핑한 단일 선택 컴포넌트 + * - 버튼 클릭 시 Dialog가 열리고, 벤더를 선택하면 Dialog가 닫히며 결과를 반환 + * + * @VendorSearchItem_Structure + * 상태에서 관리되는 벤더 객체의 형태: + * ```typescript + * interface VendorSearchItem { + * id: number; // 벤더 ID + * vendorName: string; // 벤더명 + * vendorCode: string | null; // 벤더코드 (없을 수 있음) + * status: string; // 벤더 상태 (ACTIVE, PENDING_REVIEW 등) + * displayText: string; // 표시용 텍스트 (vendorName + vendorCode) + * } + * ``` + * + * @state + * - open: Dialog 열림/닫힘 상태 + * - selectedVendor: 현재 선택된 벤더 (단일) + * - tempSelectedVendor: Dialog 내에서 임시로 선택된 벤더 (확인 버튼 클릭 전까지) + * + * @callback + * - onVendorSelect: 벤더 선택 완료 시 호출되는 콜백 + * - 매개변수: VendorSearchItem | null + * - 선택된 벤더 정보 또는 null (선택 해제 시) + * + * @usage + * ```tsx + * <VendorSelectorDialogSingle + * triggerLabel="벤더 선택" + * selectedVendor={selectedVendor} + * onVendorSelect={(vendor) => { + * setSelectedVendor(vendor); + * console.log('선택된 벤더:', vendor); + * }} + * placeholder="벤더를 검색하세요..." + * /> + * ``` + */ + +interface VendorSelectorDialogSingleProps { + /** Dialog를 여는 트리거 버튼 텍스트 */ + triggerLabel?: string + /** 현재 선택된 벤더 */ + selectedVendor?: VendorSearchItem | null + /** 벤더 선택 완료 시 호출되는 콜백 */ + onVendorSelect?: (vendor: VendorSearchItem | null) => void + /** 검색 입력창 placeholder */ + placeholder?: string + /** Dialog 제목 */ + title?: string + /** Dialog 설명 */ + description?: string + /** 트리거 버튼 비활성화 여부 */ + disabled?: boolean + /** 트리거 버튼 variant */ + triggerVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" + /** 제외할 벤더 ID들 */ + excludeVendorIds?: Set<number> + /** 초기 데이터 표시 여부 */ + showInitialData?: boolean + /** 벤더 상태 필터 */ + statusFilter?: string +} + +export function VendorSelectorDialogSingle({ + triggerLabel = "벤더 선택", + selectedVendor = null, + onVendorSelect, + placeholder = "벤더를 검색하세요...", + title = "벤더 선택", + description = "원하는 벤더를 검색하고 선택해주세요.", + disabled = false, + triggerVariant = "outline", + excludeVendorIds, + showInitialData = true, + statusFilter +}: VendorSelectorDialogSingleProps) { + // Dialog 열림/닫힘 상태 + const [open, setOpen] = useState(false) + + // Dialog 내에서 임시로 선택된 벤더 (확인 버튼 클릭 전까지) + const [tempSelectedVendor, setTempSelectedVendor] = useState<VendorSearchItem | null>(null) + + // Dialog 열림 시 현재 선택된 벤더로 임시 선택 초기화 + const handleOpenChange = useCallback((newOpen: boolean) => { + setOpen(newOpen) + if (newOpen) { + setTempSelectedVendor(selectedVendor || null) + } + }, [selectedVendor]) + + // 벤더 선택 처리 (Dialog 내에서) + const handleVendorChange = useCallback((vendors: VendorSearchItem[]) => { + setTempSelectedVendor(vendors.length > 0 ? vendors[0] : null) + }, []) + + // 확인 버튼 클릭 시 선택 완료 + const handleConfirm = useCallback(() => { + onVendorSelect?.(tempSelectedVendor) + setOpen(false) + }, [tempSelectedVendor, onVendorSelect]) + + // 취소 버튼 클릭 시 + const handleCancel = useCallback(() => { + setTempSelectedVendor(selectedVendor || null) + setOpen(false) + }, [selectedVendor]) + + // 선택 해제 + const handleClear = useCallback(() => { + setTempSelectedVendor(null) + }, []) + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogTrigger asChild> + <Button variant={triggerVariant} disabled={disabled}> + {selectedVendor ? ( + <span className="truncate"> + {selectedVendor.displayText} + </span> + ) : ( + triggerLabel + )} + </Button> + </DialogTrigger> + + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle>{title}</DialogTitle> + <DialogDescription>{description}</DialogDescription> + </DialogHeader> + + <div className="py-4"> + <VendorSelector + selectedVendors={tempSelectedVendor ? [tempSelectedVendor] : []} + onVendorsChange={handleVendorChange} + singleSelect={true} + placeholder={placeholder} + noValuePlaceHolder="벤더를 선택해주세요" + closeOnSelect={false} + excludeVendorIds={excludeVendorIds} + showInitialData={showInitialData} + statusFilter={statusFilter} + /> + </div> + + <DialogFooter className="gap-2"> + <Button variant="outline" onClick={handleCancel}> + 취소 + </Button> + {tempSelectedVendor && ( + <Button variant="ghost" onClick={handleClear}> + 선택 해제 + </Button> + )} + <Button onClick={handleConfirm}> + 확인 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} + +/** + * 사용 예시: + * + * ```tsx + * const [selectedVendor, setSelectedVendor] = useState<VendorSearchItem | null>(null); + * + * return ( + * <VendorSelectorDialogSingle + * triggerLabel="벤더 선택" + * selectedVendor={selectedVendor} + * onVendorSelect={(vendor) => { + * setSelectedVendor(vendor); + * if (vendor) { + * console.log('선택된 벤더:', { + * id: vendor.id, + * name: vendor.vendorName, + * code: vendor.vendorCode, + * status: vendor.status + * }); + * } else { + * console.log('벤더 선택이 해제되었습니다.'); + * } + * }} + * title="벤더 선택" + * description="협력업체를 검색하고 선택해주세요." + * statusFilter="ACTIVE" // ACTIVE 상태의 벤더만 표시 + * /> + * ); + * ``` + */ diff --git a/components/common/vendor/vendor-selector.tsx b/components/common/vendor/vendor-selector.tsx new file mode 100644 index 00000000..aa79943a --- /dev/null +++ b/components/common/vendor/vendor-selector.tsx @@ -0,0 +1,415 @@ +"use client" + +import React, { useState, useCallback } from "react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { + Command, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, +} from "@/components/ui/command" +import { Check, ChevronsUpDown, X, Search, ChevronLeft, ChevronRight } from "lucide-react" +import { cn } from "@/lib/utils" +import { useDebounce } from "@/hooks/use-debounce" +import { searchVendorsForSelector, VendorSearchItem } from "./vendor-service" + +interface VendorSelectorProps { + selectedVendors?: VendorSearchItem[] + onVendorsChange?: (vendors: VendorSearchItem[]) => void + singleSelect?: boolean + placeholder?: string + noValuePlaceHolder?: string + disabled?: boolean + className?: string + closeOnSelect?: boolean + excludeVendorIds?: Set<number> // 제외할 벤더 ID들 + showInitialData?: boolean // 초기 클릭시 벤더들을 로드할지 여부 + maxSelections?: number // 최대 선택 가능한 벤더 개수 + statusFilter?: string // 특정 상태의 벤더만 표시 +} + +export function VendorSelector({ + selectedVendors = [], + onVendorsChange, + singleSelect = false, + placeholder = "벤더를 검색하세요...", + noValuePlaceHolder = "벤더를 검색해주세요", + disabled = false, + className, + closeOnSelect = true, + excludeVendorIds, + showInitialData = true, + maxSelections, + statusFilter +}: VendorSelectorProps) { + + const [open, setOpen] = useState(false) + const [searchQuery, setSearchQuery] = useState("") + const [searchResults, setSearchResults] = useState<VendorSearchItem[]>([]) + const [isSearching, setIsSearching] = useState(false) + const [searchError, setSearchError] = useState<string | null>(null) + const [currentPage, setCurrentPage] = useState(1) + const [initialDataLoaded, setInitialDataLoaded] = useState(false) + const [pagination, setPagination] = useState({ + page: 1, + perPage: 10, + total: 0, + pageCount: 0, + hasNextPage: false, + hasPrevPage: false, + }) + + // Debounce 적용된 검색어 + const debouncedSearchQuery = useDebounce(searchQuery, 300) + + // 검색 실행 - useCallback으로 메모이제이션 + const performSearch = useCallback(async (query: string, page: number = 1) => { + setIsSearching(true) + setSearchError(null) + + try { + const result = await searchVendorsForSelector(query, page, 10, { + statusFilter, + sortBy: 'vendorName', + sortOrder: 'asc' + }) + + if (result.success) { + setSearchResults(result.data) + setPagination(result.pagination) + setCurrentPage(page) + } else { + setSearchResults([]) + setSearchError("검색 중 오류가 발생했습니다.") + setPagination({ + page: 1, + perPage: 10, + total: 0, + pageCount: 0, + hasNextPage: false, + hasPrevPage: false, + }) + } + } catch (err) { + console.error("벤더 검색 실패:", err) + setSearchResults([]) + setSearchError("검색 중 오류가 발생했습니다.") + setPagination({ + page: 1, + perPage: 10, + total: 0, + pageCount: 0, + hasNextPage: false, + hasPrevPage: false, + }) + } finally { + setIsSearching(false) + } + }, [statusFilter]) + + // Popover 열림시 초기 데이터 로드 + React.useEffect(() => { + if (open && showInitialData && !initialDataLoaded && !searchQuery.trim()) { + setInitialDataLoaded(true) + performSearch("", 1) // 빈 쿼리로 초기 데이터 로드 + } + }, [open, showInitialData, initialDataLoaded, searchQuery, performSearch]) + + // Debounced 검색어 변경 시 검색 실행 (검색어가 있을 때만) + React.useEffect(() => { + if (debouncedSearchQuery.trim()) { + setCurrentPage(1) + performSearch(debouncedSearchQuery, 1) + } else if (showInitialData && initialDataLoaded) { + // 검색어가 없고 초기 데이터를 보여주는 경우 초기 데이터 유지 + // 아무것도 하지 않음 (기존 데이터 유지) + } else { + // 검색어가 없으면 결과 초기화 + setSearchResults([]) + setPagination({ + page: 1, + perPage: 10, + total: 0, + pageCount: 0, + hasNextPage: false, + hasPrevPage: false, + }) + } + }, [debouncedSearchQuery, performSearch, showInitialData, initialDataLoaded]) + + // 페이지 변경 처리 - useCallback으로 메모이제이션 + const handlePageChange = useCallback((newPage: number) => { + if (newPage >= 1 && newPage <= pagination.pageCount) { + const query = debouncedSearchQuery.trim() || (showInitialData && initialDataLoaded ? "" : debouncedSearchQuery) + performSearch(query, newPage) + } + }, [pagination.pageCount, performSearch, debouncedSearchQuery, showInitialData, initialDataLoaded]) + + // 벤더 선택 처리 - useCallback으로 메모이제이션 + const handleVendorSelect = useCallback((vendor: VendorSearchItem) => { + if (disabled) return + + let newSelectedVendors: VendorSearchItem[] + + // maxSelections가 1이면 단일 선택 모드로 동작 + const isSingleSelectMode = singleSelect || maxSelections === 1 + + if (isSingleSelectMode) { + newSelectedVendors = [vendor] + } else { + const isAlreadySelected = selectedVendors.some( + (selected) => selected.id === vendor.id + ) + + if (isAlreadySelected) { + newSelectedVendors = selectedVendors.filter( + (selected) => selected.id !== vendor.id + ) + } else { + // 최대 선택 개수 확인 + if (maxSelections && selectedVendors.length >= maxSelections) { + // 최대 개수에 도달한 경우 선택하지 않음 + return + } + newSelectedVendors = [...selectedVendors, vendor] + } + } + + onVendorsChange?.(newSelectedVendors) + + if (closeOnSelect && isSingleSelectMode) { + setOpen(false) + } + }, [disabled, singleSelect, maxSelections, selectedVendors, onVendorsChange, closeOnSelect]) + + // 개별 벤더 제거 + const handleRemoveVendor = useCallback((vendorToRemove: VendorSearchItem) => { + if (disabled) return + + const newSelectedVendors = selectedVendors.filter( + (vendor) => vendor.id !== vendorToRemove.id + ) + onVendorsChange?.(newSelectedVendors) + }, [disabled, selectedVendors, onVendorsChange]) + + // 선택된 벤더가 있는지 확인 + const isVendorSelected = useCallback((vendor: VendorSearchItem) => { + return selectedVendors.some((selected) => selected.id === vendor.id) + }, [selectedVendors]) + + // 벤더 상태별 색상 + const getStatusColor = (status: string) => { + switch (status) { + case 'ACTIVE': return 'bg-green-100 text-green-800' + case 'APPROVED': return 'bg-blue-100 text-blue-800' + case 'PENDING_REVIEW': return 'bg-yellow-100 text-yellow-800' + case 'INACTIVE': return 'bg-gray-100 text-gray-800' + default: return 'bg-gray-100 text-gray-800' + } + } + + return ( + <div className={cn("w-full", className)}> + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={open} + className="w-full justify-between min-h-[2.5rem] h-auto" + disabled={disabled} + > + <div className="flex flex-wrap gap-1 flex-1 text-left"> + {selectedVendors.length === 0 ? ( + <span className="text-muted-foreground">{noValuePlaceHolder}</span> + ) : ( + selectedVendors.map((vendor) => ( + <Badge + key={vendor.id} + variant="secondary" + className="gap-1 pr-1" + > + <span className=""> + {vendor.displayText} + </span> + {!disabled && ( + <button + type="button" + className="ml-1 h-3 w-3 rounded-sm hover:bg-red-100 flex items-center justify-center" + onClick={(e) => { + e.preventDefault() + e.stopPropagation() + handleRemoveVendor(vendor) + }} + > + <X className="h-3 w-3 hover:text-red-500" /> + </button> + )} + </Badge> + )) + )} + </div> + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + + <PopoverContent className="w-full p-0" align="start"> + <Command> + <div className="flex items-center border-b px-3"> + <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" /> + <Input + placeholder={placeholder} + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + className="flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none border-0 focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50" + /> + </div> + + {/* 스크롤 컨테이너 + 고정 페이지네이션 */} + <div className="max-h-[50vh] flex flex-col"> + <CommandList + className="flex-1 overflow-y-auto overflow-x-hidden min-h-0" + // shadcn CommandList 버그 처리 - 스크롤 이벤트 전파 차단 + onWheel={(e) => { + e.stopPropagation() // 이벤트 전파 차단 + const target = e.currentTarget + target.scrollTop += e.deltaY // 직접 스크롤 처리 + }} + > + {!searchQuery.trim() && !showInitialData ? ( + <div className="p-4 text-center text-sm text-muted-foreground"> + 벤더를 검색하려면 검색어를 입력해주세요. + </div> + ) : !searchQuery.trim() && showInitialData && !initialDataLoaded ? ( + <div className="p-4 text-center text-sm text-muted-foreground"> + 벤더 목록을 로드하려면 클릭해주세요. + </div> + ) : isSearching ? ( + <div className="p-4 text-center text-sm text-muted-foreground"> + 검색 중... + </div> + ) : searchError ? ( + <div className="p-4 text-center text-sm text-red-500"> + {searchError} + </div> + ) : searchResults.length === 0 ? ( + <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> + ) : ( + <CommandGroup className="overflow-visible"> + {searchResults.map((vendor) => { + const isExcluded = excludeVendorIds?.has(vendor.id) + const isSelected = isVendorSelected(vendor) + const isMaxReached = maxSelections && selectedVendors.length >= maxSelections && !isSelected + const isDisabled = isExcluded || isMaxReached + + return ( + <CommandItem + key={vendor.id} + onSelect={() => { + if (!isDisabled) { + handleVendorSelect(vendor) + } + }} + className={cn( + "cursor-pointer", + isDisabled && "opacity-50 cursor-not-allowed bg-muted" + )} + > + <div className="mr-2 h-4 w-4 flex items-center justify-center"> + {isExcluded ? ( + <span className="text-xs text-muted-foreground">✓</span> + ) : isMaxReached ? ( + <span className="text-xs text-muted-foreground">-</span> + ) : ( + <Check + className={cn( + "h-4 w-4", + isSelected ? "opacity-100" : "opacity-0" + )} + /> + )} + </div> + <div className="flex-1"> + <div className="flex items-center gap-2"> + <div className={cn( + "font-medium", + isDisabled && "text-muted-foreground" + )}> + {vendor.vendorName} + {isExcluded && ( + <span className="ml-2 text-xs bg-red-100 text-red-600 px-2 py-1 rounded"> + 제외됨 + </span> + )} + {isMaxReached && ( + <span className="ml-2 text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded"> + 선택 제한 ({maxSelections}개) + </span> + )} + </div> + <Badge className={getStatusColor(vendor.status)}> + {vendor.status} + </Badge> + </div> + <div className="text-xs text-muted-foreground"> + {vendor.vendorCode ? ( + <span>벤더코드: {vendor.vendorCode}</span> + ) : ( + <span>벤더코드: -</span> + )} + </div> + </div> + </CommandItem> + ) + })} + </CommandGroup> + )} + </CommandList> + + {/* 고정 페이지네이션 - 항상 밑에 표시 */} + {searchResults.length > 0 && pagination.pageCount > 1 && ( + <div className="flex items-center justify-between border-t px-3 py-2 flex-shrink-0"> + <div className="text-xs text-muted-foreground"> + 총 {pagination.total}개 중 {((pagination.page - 1) * pagination.perPage) + 1}- + {Math.min(pagination.page * pagination.perPage, pagination.total)}개 표시 + </div> + <div className="flex items-center gap-2"> + <Button + variant="ghost" + size="sm" + onClick={() => handlePageChange(currentPage - 1)} + disabled={!pagination.hasPrevPage} + className="h-6 w-6 p-0" + > + <ChevronLeft className="h-3 w-3" /> + </Button> + <span className="text-xs"> + {pagination.page} / {pagination.pageCount} + </span> + <Button + variant="ghost" + size="sm" + onClick={() => handlePageChange(currentPage + 1)} + disabled={!pagination.hasNextPage} + className="h-6 w-6 p-0" + > + <ChevronRight className="h-3 w-3" /> + </Button> + </div> + </div> + )} + </div> + </Command> + </PopoverContent> + </Popover> + </div> + ) +} diff --git a/components/common/vendor/vendor-service.ts b/components/common/vendor/vendor-service.ts new file mode 100644 index 00000000..83a63cae --- /dev/null +++ b/components/common/vendor/vendor-service.ts @@ -0,0 +1,263 @@ +"use server" + +import db from '@/db/db' +import { vendors } from '@/db/schema/vendors' +import { eq, or, ilike, and, asc, desc } from 'drizzle-orm' + +// 벤더 타입 정의 +export interface VendorSearchItem { + id: number + vendorName: string + vendorCode: string | null + status: string + displayText: string // vendorName + vendorCode로 구성된 표시용 텍스트 +} + +// 벤더 검색 옵션 +export interface VendorSearchOptions { + searchTerm?: string + statusFilter?: string // 특정 상태로 필터링 + limit?: number + offset?: number + sortBy?: 'vendorName' | 'vendorCode' | 'status' + sortOrder?: 'asc' | 'desc' +} + +// 페이지네이션 정보 +export interface VendorPagination { + page: number + perPage: number + total: number + pageCount: number + hasNextPage: boolean + hasPrevPage: boolean +} + +/** + * 벤더 검색 (페이지네이션 지원) + * 벤더명, 벤더코드로 검색 가능 + */ +export async function searchVendorsForSelector( + searchTerm: string = "", + page: number = 1, + perPage: number = 10, + options: Omit<VendorSearchOptions, 'searchTerm' | 'limit' | 'offset'> = {} +): Promise<{ + success: boolean + data: VendorSearchItem[] + pagination: VendorPagination + error?: string +}> { + try { + const { statusFilter, sortBy = 'vendorName', sortOrder = 'asc' } = options + const offset = (page - 1) * perPage + + // WHERE 조건 구성 + let whereClause + + // 검색어 조건 + const searchCondition = searchTerm && searchTerm.trim() + ? or( + ilike(vendors.vendorName, `%${searchTerm.trim()}%`), + ilike(vendors.vendorCode, `%${searchTerm.trim()}%`) + ) + : undefined + + // 상태 필터 조건 - 타입 안전하게 처리 + const statusCondition = statusFilter + ? eq(vendors.status, statusFilter as string) + : undefined + + // 조건들을 결합 + if (searchCondition && statusCondition) { + whereClause = and(searchCondition, statusCondition) + } else if (searchCondition) { + whereClause = searchCondition + } else if (statusCondition) { + whereClause = statusCondition + } + + // 정렬 옵션 + const orderBy = sortOrder === 'desc' + ? desc(vendors[sortBy]) + : asc(vendors[sortBy]) + + // 전체 개수 조회 + let totalCountQuery = db + .select({ count: vendors.id }) + .from(vendors) + + if (whereClause) { + totalCountQuery = totalCountQuery.where(whereClause) + } + + const totalCountResult = await totalCountQuery + const total = totalCountResult.length + + // 데이터 조회 + let dataQuery = db + .select({ + id: vendors.id, + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, + status: vendors.status, + }) + .from(vendors) + + if (whereClause) { + dataQuery = dataQuery.where(whereClause) + } + + const result = await dataQuery + .orderBy(orderBy) + .limit(perPage) + .offset(offset) + + // displayText 추가 + const vendorItems: VendorSearchItem[] = result.map(vendor => ({ + ...vendor, + displayText: vendor.vendorCode + ? `${vendor.vendorName} (${vendor.vendorCode})` + : vendor.vendorName + })) + + // 페이지네이션 정보 계산 + const pageCount = Math.ceil(total / perPage) + const pagination: VendorPagination = { + page, + perPage, + total, + pageCount, + hasNextPage: page < pageCount, + hasPrevPage: page > 1, + } + + return { + success: true, + data: vendorItems, + pagination + } + } catch (error) { + console.error('Error searching vendors:', error) + return { + success: false, + data: [], + pagination: { + page: 1, + perPage: 10, + total: 0, + pageCount: 0, + hasNextPage: false, + hasPrevPage: false, + }, + error: '벤더 검색 중 오류가 발생했습니다.' + } + } +} + +/** + * 모든 벤더 조회 (필터링 없음) + */ +export async function getAllVendors(): Promise<{ + success: boolean + data: VendorSearchItem[] + error?: string +}> { + try { + const result = await db + .select({ + id: vendors.id, + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, + status: vendors.status, + }) + .from(vendors) + .orderBy(asc(vendors.vendorName)) + + const vendorItems: VendorSearchItem[] = result.map(vendor => ({ + ...vendor, + displayText: vendor.vendorCode + ? `${vendor.vendorName} (${vendor.vendorCode})` + : vendor.vendorName + })) + + return { + success: true, + data: vendorItems + } + } catch (error) { + console.error('Error fetching all vendors:', error) + return { + success: false, + data: [], + error: '벤더 목록을 조회하는 중 오류가 발생했습니다.' + } + } +} + +/** + * 특정 벤더 조회 (ID로) + */ +export async function getVendorById(vendorId: number): Promise<VendorSearchItem | null> { + if (!vendorId) { + return null + } + + try { + const result = await db + .select({ + id: vendors.id, + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, + status: vendors.status, + }) + .from(vendors) + .where(eq(vendors.id, vendorId)) + .limit(1) + + if (result.length === 0) { + return null + } + + const vendor = result[0] + return { + ...vendor, + displayText: vendor.vendorCode + ? `${vendor.vendorName} (${vendor.vendorCode})` + : vendor.vendorName + } + } catch (error) { + console.error('Error fetching vendor by ID:', error) + return null + } +} + +/** + * 벤더 상태 목록 조회 + */ +export async function getVendorStatuses(): Promise<{ + success: boolean + data: string[] + error?: string +}> { + try { + const result = await db + .selectDistinct({ status: vendors.status }) + .from(vendors) + .orderBy(asc(vendors.status)) + + const statuses = result.map(row => row.status).filter(Boolean) + + return { + success: true, + data: statuses + } + } catch (error) { + console.error('Error fetching vendor statuses:', error) + return { + success: false, + data: [], + error: '벤더 상태 목록을 조회하는 중 오류가 발생했습니다.' + } + } +} diff --git a/db/schema/NONSAP/nonsap.ts b/db/schema/NONSAP/nonsap.ts index 322e9647..838ebf29 100644 --- a/db/schema/NONSAP/nonsap.ts +++ b/db/schema/NONSAP/nonsap.ts @@ -54,21 +54,21 @@ export const cmctbCd = nonsapSchema.table('cmctb_cd', { // 사용자정의문자열255 USR_DF_CHAR_20: text(), // 사용자정의체크1 - USR_DF_CHK_1: varchar({ length: 1 }), + USR_DF_CHK_1: varchar({ length: 10 }), // 사용자정의체크2 - USR_DF_CHK_2: varchar({ length: 1 }), + USR_DF_CHK_2: varchar({ length: 10 }), // 사용자정의체크3 - USR_DF_CHK_3: varchar({ length: 1 }), + USR_DF_CHK_3: varchar({ length: 10 }), // 사용자정의체크4 - USR_DF_CHK_4: varchar({ length: 1 }), + USR_DF_CHK_4: varchar({ length: 10 }), // 사용자정의체크5 - USR_DF_CHK_5: varchar({ length: 1 }), + USR_DF_CHK_5: varchar({ length: 10 }), // 사용자정의체크6 - USR_DF_CHK_6: varchar({ length: 1 }), + USR_DF_CHK_6: varchar({ length: 10 }), // 사용자정의체크7 - USR_DF_CHK_7: varchar({ length: 1 }), + USR_DF_CHK_7: varchar({ length: 10 }), // 사용자정의체크8 - USR_DF_CHK_8: varchar({ length: 1 }), + USR_DF_CHK_8: varchar({ length: 10 }), // 사용자정의일자1 USR_DF_DT_1: varchar({ length: 8 }), // 사용자정의일자2 diff --git a/db/schema/avl/avl.ts b/db/schema/avl/avl.ts index 0b983168..30c82ced 100644 --- a/db/schema/avl/avl.ts +++ b/db/schema/avl/avl.ts @@ -1,4 +1,4 @@ -import { pgTable, boolean, integer, timestamp, varchar, decimal, json, pgView } from "drizzle-orm/pg-core"; +import { pgTable, boolean, integer, timestamp, varchar, decimal, json, pgView, uniqueIndex } from "drizzle-orm/pg-core"; import { createInsertSchema, createSelectSchema } from "drizzle-zod"; import { sql } from "drizzle-orm"; @@ -118,7 +118,30 @@ export const avlVendorInfo = pgTable("avl_vendor_info", { // 타임스탬프 createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), -}); +}, (table) => ({ + // 표준 AVL용 unique 제약조건 (isTemplate = true) + // 표준AVL용 필드들 + 자재그룹코드 + 협력업체명이 unique해야 함 + uniqueStandardAvl: uniqueIndex("unique_standard_avl_material_vendor") + .on( + table.constructionSector, + table.shipType, + table.avlKind, + table.htDivision, + table.materialGroupCode, + table.vendorName + ) + .where(sql`${table.isTemplate} = true`), + + // 프로젝트 AVL용 unique 제약조건 (isTemplate = false) + // 같은 projectCode 내에서 자재그룹코드 + 협력업체명이 unique해야 함 + uniqueProjectAvl: uniqueIndex("unique_project_avl_material_vendor") + .on( + table.projectCode, + table.materialGroupCode, + table.vendorName + ) + .where(sql`${table.isTemplate} = false`), +})); // Zod 스키마 생성 export const insertAvlListSchema = createInsertSchema(avlList); diff --git a/lib/avl/service.ts b/lib/avl/service.ts index 3d188f85..0340f52c 100644 --- a/lib/avl/service.ts +++ b/lib/avl/service.ts @@ -552,7 +552,7 @@ export async function createAvlList(data: CreateAvlListInput): Promise<AvlListIt debugSuccess('DB INSERT 완료', { table: 'avl_list', result: result[0], - savedSnapshotLength: result[0].vendorInfoSnapshot?.length + savedSnapshotLength: Array.isArray((result[0] as any).vendorInfoSnapshot) ? (result[0] as any).vendorInfoSnapshot.length : 0 }); const createdItem = result[0]; @@ -800,6 +800,27 @@ export async function createAvlVendorInfo(data: AvlVendorInfoInput): Promise<Avl return transformedData; } catch (err) { debugError('AVL Vendor Info 생성 실패', { error: err, inputData: data }); + + // unique 제약조건 위반 에러 처리 + if (err instanceof Error) { + if (err.message.includes('unique_standard_avl_material_vendor')) { + console.error("Unique constraint violation (standard AVL):", err.message); + // 사용자에게는 친화적인 메시지를 보여주기 위해 null 반환 + // 실제 애플리케이션에서는 이 에러를 catch해서 적절한 메시지 표시 + return null; + } + + if (err.message.includes('unique_project_avl_material_vendor')) { + console.error("Unique constraint violation (project AVL):", err.message); + return null; + } + + if (err.message.includes('duplicate key value violates unique constraint')) { + console.error("Unique constraint violation:", err.message); + return null; + } + } + console.error("Error in createAvlVendorInfo:", err); return null; } @@ -1559,6 +1580,24 @@ export const copyToProjectAvl = async ( } catch (error) { debugError('선종별표준AVL → 프로젝트AVL 복사 실패', { error, selectedIds, targetProjectCode }); + + // unique 제약조건 위반 에러 처리 + if (error instanceof Error) { + if (error.message.includes('unique_project_avl_material_vendor')) { + return { + success: false, + message: "선택한 항목 중 이미 존재하는 [자재그룹코드 + 협력업체명] 조합이 있어 복사할 수 없습니다. 중복되지 않는 항목만 선택해주세요." + }; + } + + if (error.message.includes('duplicate key value violates unique constraint')) { + return { + success: false, + message: "중복 데이터로 인해 복사할 수 없습니다. 이미 존재하는 데이터와 중복되지 않는 항목만 선택해주세요." + }; + } + } + return { success: false, message: "프로젝트 AVL로 복사 중 오류가 발생했습니다." @@ -1646,6 +1685,24 @@ export const copyToStandardAvl = async ( } catch (error) { debugError('프로젝트AVL → 선종별표준AVL 복사 실패', { error, selectedIds, targetStandardInfo }); + + // unique 제약조건 위반 에러 처리 + if (error instanceof Error) { + if (error.message.includes('unique_standard_avl_material_vendor')) { + return { + success: false, + message: "선택한 항목 중 해당 표준AVL에 이미 존재하는 [자재그룹코드 + 협력업체명] 조합이 있어 복사할 수 없습니다. 중복되지 않는 항목만 선택해주세요." + }; + } + + if (error.message.includes('duplicate key value violates unique constraint')) { + return { + success: false, + message: "중복 데이터로 인해 복사할 수 없습니다. 이미 존재하는 데이터와 중복되지 않는 항목만 선택해주세요." + }; + } + } + return { success: false, message: "선종별표준AVL로 복사 중 오류가 발생했습니다." @@ -1812,6 +1869,17 @@ export const copyToVendorPool = async ( } catch (error) { debugError('프로젝트AVL → 벤더풀 복사 실패', { error, selectedIds }); + + // unique 제약조건 위반 에러 처리 (벤더풀에는 다른 unique 제약조건이 있을 수 있음) + if (error instanceof Error) { + if (error.message.includes('duplicate key value violates unique constraint')) { + return { + success: false, + message: "중복 데이터로 인해 벤더풀로 복사할 수 없습니다. 이미 존재하는 데이터와 중복되지 않는 항목만 선택해주세요." + }; + } + } + return { success: false, message: "벤더풀로 복사 중 오류가 발생했습니다." @@ -1931,6 +1999,24 @@ export const copyFromVendorPoolToProjectAvl = async ( } catch (error) { debugError('벤더풀 → 프로젝트AVL 복사 실패', { error, selectedIds, targetProjectCode }); + + // unique 제약조건 위반 에러 처리 + if (error instanceof Error) { + if (error.message.includes('unique_project_avl_material_vendor')) { + return { + success: false, + message: "선택한 항목 중 해당 프로젝트에 이미 존재하는 [자재그룹코드 + 협력업체명] 조합이 있어 복사할 수 없습니다. 중복되지 않는 항목만 선택해주세요." + }; + } + + if (error.message.includes('duplicate key value violates unique constraint')) { + return { + success: false, + message: "중복 데이터로 인해 복사할 수 없습니다. 이미 존재하는 데이터와 중복되지 않는 항목만 선택해주세요." + }; + } + } + return { success: false, message: "프로젝트 AVL로 복사 중 오류가 발생했습니다." @@ -2055,6 +2141,24 @@ export const copyFromVendorPoolToStandardAvl = async ( } catch (error) { debugError('벤더풀 → 선종별표준AVL 복사 실패', { error, selectedIds, targetStandardInfo }); + + // unique 제약조건 위반 에러 처리 + if (error instanceof Error) { + if (error.message.includes('unique_standard_avl_material_vendor')) { + return { + success: false, + message: "선택한 항목 중 해당 표준AVL에 이미 존재하는 [자재그룹코드 + 협력업체명] 조합이 있어 복사할 수 없습니다. 중복되지 않는 항목만 선택해주세요." + }; + } + + if (error.message.includes('duplicate key value violates unique constraint')) { + return { + success: false, + message: "중복 데이터로 인해 복사할 수 없습니다. 이미 존재하는 데이터와 중복되지 않는 항목만 선택해주세요." + }; + } + } + return { success: false, message: "선종별표준AVL로 복사 중 오류가 발생했습니다." @@ -2213,6 +2317,17 @@ export const copyFromStandardAvlToVendorPool = async ( } catch (error) { debugError('선종별표준AVL → 벤더풀 복사 실패', { error, selectedIds }); + + // unique 제약조건 위반 에러 처리 (벤더풀에는 다른 unique 제약조건이 있을 수 있음) + if (error instanceof Error) { + if (error.message.includes('duplicate key value violates unique constraint')) { + return { + success: false, + message: "중복 데이터로 인해 벤더풀로 복사할 수 없습니다. 이미 존재하는 데이터와 중복되지 않는 항목만 선택해주세요." + }; + } + } + return { success: false, message: "벤더풀로 복사 중 오류가 발생했습니다." diff --git a/lib/avl/table/avl-registration-area.tsx b/lib/avl/table/avl-registration-area.tsx index 52912a2c..ba1c76d4 100644 --- a/lib/avl/table/avl-registration-area.tsx +++ b/lib/avl/table/avl-registration-area.tsx @@ -1,7 +1,6 @@ "use client" import * as React from "react" -import { Card } from "@/components/ui/card" import { Button } from "@/components/ui/button" import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react" import { useAtom } from "jotai" @@ -430,9 +429,9 @@ export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaPro }, [selectedTable, selectedRowCount, getSelectedIds, session]) return ( - <Card className={`h-full min-w-full overflow-visible ${disabled ? 'opacity-50 pointer-events-none' : ''}`}> + <div className={`min-w-full overflow-hidden rounded-md bg-background ${disabled ? 'opacity-50 pointer-events-none' : ''}`}> {/* 고정 헤더 영역 */} - <div className="sticky top-0 z-10 p-4 border-b"> + <div className="sticky top-0 z-10 p-4 border-b bg-background"> <div className="flex items-center justify-between"> <h3 className="text-lg font-semibold">AVL 등록 {disabled ? "(비활성화)" : ""}</h3> <div className="flex gap-2"> @@ -444,7 +443,7 @@ export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaPro </div> {/* 스크롤되는 콘텐츠 영역 */} - <div className="overflow-x-auto overflow-y-hidden"> + <div className="overflow-x-auto mb-8"> <div className="grid grid-cols-[2.2fr_2fr_2.5fr] gap-0 min-w-[1200px] w-fit"> {/* 프로젝트 AVL 테이블 - 9개 컬럼 */} <div className="p-4 border-r relative"> @@ -563,6 +562,6 @@ export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaPro </div> </div> </div> - </Card> + </div> ) } diff --git a/lib/avl/table/avl-table-columns.tsx b/lib/avl/table/avl-table-columns.tsx index 72c59aa9..d95a29b0 100644 --- a/lib/avl/table/avl-table-columns.tsx +++ b/lib/avl/table/avl-table-columns.tsx @@ -226,7 +226,7 @@ export function getColumns({ selectedRows = [], onRowSelect }: GetColumnsProps): { accessorKey: "createdAt", header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="등재일" /> + <DataTableColumnHeaderSimple column={column} title="등록일" /> ), cell: ({ getValue }) => { const date = getValue() as string @@ -237,7 +237,7 @@ export function getColumns({ selectedRows = [], onRowSelect }: GetColumnsProps): { accessorKey: "createdBy", header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="등재자" /> + <DataTableColumnHeaderSimple column={column} title="등록자" /> ), cell: ({ getValue }) => { const date = getValue() as string diff --git a/lib/avl/table/avl-vendor-add-and-modify-dialog.tsx b/lib/avl/table/avl-vendor-add-and-modify-dialog.tsx index 174982e4..4f0eb404 100644 --- a/lib/avl/table/avl-vendor-add-and-modify-dialog.tsx +++ b/lib/avl/table/avl-vendor-add-and-modify-dialog.tsx @@ -8,7 +8,6 @@ import { DialogFooter, DialogHeader, DialogTitle, - DialogTrigger, } from "@/components/ui/dialog" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -24,6 +23,14 @@ import { } from "@/components/ui/select" import { toast } from "sonner" import type { AvlVendorInfoInput, AvlDetailItem } from "../types" +import { EngineeringDisciplineSelector, type DisciplineCode } from "@/components/common/discipline" +import { MaterialGroupSelectorDialogSingle } from "@/components/common/material/material-group-selector-dialog-single" +import type { MaterialSearchItem } from "@/lib/material/material-group-service" +import { VendorSelectorDialogSingle } from "@/components/common/vendor" +import type { VendorSearchItem } from "@/components/common/vendor" +import { PlaceOfShippingSelector } from "@/components/common/selectors/place-of-shipping" +import { VendorTierSelector } from "@/components/common/selectors/vendor-tier" +import { DatePicker } from "@/components/ui/date-picker" interface AvlVendorAddAndModifyDialogProps { open: boolean @@ -58,6 +65,16 @@ export function AvlVendorAddAndModifyDialog({ initialHtDivision, initialProjectCode }: AvlVendorAddAndModifyDialogProps) { + // 설계공종 선택 상태 + const [selectedDiscipline, setSelectedDiscipline] = React.useState<DisciplineCode | undefined>(undefined) + // 자재그룹 선택 상태 + const [selectedMaterialGroup, setSelectedMaterialGroup] = React.useState<MaterialSearchItem | null>(null) + // 벤더 선택 상태 + const [selectedVendor, setSelectedVendor] = React.useState<VendorSearchItem | null>(null) + // 날짜 상태 (Date 객체로 관리) + const [quoteReceivedDate, setQuoteReceivedDate] = React.useState<Date | undefined>(undefined) + const [recentQuoteDate, setRecentQuoteDate] = React.useState<Date | undefined>(undefined) + const [recentOrderDate, setRecentOrderDate] = React.useState<Date | undefined>(undefined) const [formData, setFormData] = React.useState<Omit<AvlVendorInfoInput, 'avlListId'>>({ // 공통 기본 설정 isTemplate: isTemplate, @@ -137,9 +154,50 @@ export function AvlVendorAddAndModifyDialog({ remarks: "" }) - // 수정 모드일 때 폼 데이터 초기화 + // 수정 모드일 때 설계공종 선택 상태 및 폼 데이터 초기화 React.useEffect(() => { if (editingItem) { + // 설계공종 선택 상태 초기화 + if (editingItem.disciplineCode && editingItem.disciplineName) { + setSelectedDiscipline({ + CD: editingItem.disciplineCode, + USR_DF_CHAR_18: editingItem.disciplineName + }) + } else { + setSelectedDiscipline(undefined) + } + + // 자재그룹 선택 상태 초기화 + if (editingItem.materialGroupCode && editingItem.materialGroupName) { + setSelectedMaterialGroup({ + materialGroupCode: editingItem.materialGroupCode, + materialGroupDescription: editingItem.materialGroupName, + displayText: `${editingItem.materialGroupCode} - ${editingItem.materialGroupName}` + }) + } else { + setSelectedMaterialGroup(null) + } + + // 벤더 선택 상태 초기화 (기존 데이터가 있으면 가상의 벤더 객체 생성) + if (editingItem.vendorCode || editingItem.vendorName) { + setSelectedVendor({ + id: -1, // 임시 ID (실제 벤더 ID는 알 수 없음) + vendorName: editingItem.vendorName || "", + vendorCode: editingItem.vendorCode || null, + status: "UNKNOWN", // 상태 정보 없음 + displayText: editingItem.vendorCode + ? `${editingItem.vendorName} (${editingItem.vendorCode})` + : editingItem.vendorName || "" + }) + } else { + setSelectedVendor(null) + } + + // 날짜 상태 초기화 + setQuoteReceivedDate(editingItem.quoteReceivedDate ? new Date(editingItem.quoteReceivedDate) : undefined) + setRecentQuoteDate(editingItem.recentQuoteDate ? new Date(editingItem.recentQuoteDate) : undefined) + setRecentOrderDate(editingItem.recentOrderDate ? new Date(editingItem.recentOrderDate) : undefined) + setFormData({ // 공통 기본 설정 isTemplate: editingItem.isTemplate ?? isTemplate, @@ -224,6 +282,17 @@ export function AvlVendorAddAndModifyDialog({ // 다이얼로그가 열릴 때 초기값 재설정 (수정 모드가 아닐 때만) React.useEffect(() => { if (open && !editingItem) { + // 설계공종 선택 상태 초기화 + setSelectedDiscipline(undefined) + // 자재그룹 선택 상태 초기화 + setSelectedMaterialGroup(null) + // 벤더 선택 상태 초기화 + setSelectedVendor(null) + // 날짜 상태 초기화 + setQuoteReceivedDate(undefined) + setRecentQuoteDate(undefined) + setRecentOrderDate(undefined) + setFormData(prev => ({ ...prev, isTemplate: isTemplate, @@ -236,6 +305,40 @@ export function AvlVendorAddAndModifyDialog({ } }, [open, editingItem, isTemplate, initialProjectCode, initialConstructionSector, initialShipType, initialAvlKind, initialHtDivision]) + // 설계공종 선택 핸들러 + const handleDisciplineSelect = React.useCallback((discipline: DisciplineCode) => { + setSelectedDiscipline(discipline) + setFormData(prev => ({ + ...prev, + disciplineCode: discipline.CD, + disciplineName: discipline.USR_DF_CHAR_18 + })) + }, []) + + // 자재그룹 선택 핸들러 + const handleMaterialGroupSelect = React.useCallback((materialGroup: MaterialSearchItem | null) => { + setSelectedMaterialGroup(materialGroup) + setFormData(prev => ({ + ...prev, + materialGroupCode: materialGroup?.materialGroupCode || "", + materialGroupName: materialGroup?.materialGroupDescription || "" + })) + }, []) + + // 벤더 선택 핸들러 (선택기에서 벤더를 선택했을 때 Input 필드에 자동 입력) + const handleVendorSelect = React.useCallback((vendor: VendorSearchItem | null) => { + setSelectedVendor(vendor) + if (vendor) { + setFormData(prev => ({ + ...prev, + vendorCode: vendor.vendorCode || "", + vendorName: vendor.vendorName || "", + // AVL 등재업체명도 기본적으로 벤더명으로 설정 (사용자가 수정 가능) + avlVendorName: vendor.vendorName || "" + })) + } + }, []) + const handleSubmit = async () => { // 공통 필수 필드 검증 if (!formData.disciplineName || !formData.materialNameCustomerSide) { @@ -259,15 +362,34 @@ export function AvlVendorAddAndModifyDialog({ } try { + // 날짜 필드들을 문자열로 변환 + const formatDate = (date: Date | undefined): string => { + if (!date) return "" + return date.toISOString().split('T')[0] // YYYY-MM-DD 형식 + } + + const submissionData = { + ...formData, + quoteReceivedDate: formatDate(quoteReceivedDate), + recentQuoteDate: formatDate(recentQuoteDate), + recentOrderDate: formatDate(recentOrderDate), + } + if (editingItem && onUpdateItem) { // 수정 모드 - await onUpdateItem(editingItem.id, formData) + await onUpdateItem(editingItem.id, submissionData) } else { // 추가 모드 - await onAddItem(formData) + await onAddItem(submissionData) } - // 폼 초기화 + // 폼 및 선택 상태 초기화 + setSelectedDiscipline(undefined) + setSelectedMaterialGroup(null) + setSelectedVendor(null) + setQuoteReceivedDate(undefined) + setRecentQuoteDate(undefined) + setRecentOrderDate(undefined) setFormData({ isTemplate: isTemplate, projectCode: initialProjectCode || "", @@ -313,12 +435,18 @@ export function AvlVendorAddAndModifyDialog({ } as Omit<AvlVendorInfoInput, 'avlListId'>) onOpenChange(false) - } catch (error) { + } catch { // 에러 처리는 호출하는 쪽에서 담당 } } const handleCancel = () => { + setSelectedDiscipline(undefined) + setSelectedMaterialGroup(null) + setSelectedVendor(null) + setQuoteReceivedDate(undefined) + setRecentQuoteDate(undefined) + setRecentOrderDate(undefined) setFormData({ isTemplate: isTemplate, projectCode: initialProjectCode || "", @@ -365,31 +493,6 @@ export function AvlVendorAddAndModifyDialog({ onOpenChange(false) } - // 선종 옵션들 (공사부문에 따라 다름) - const getShipTypeOptions = (constructionSector: string) => { - if (constructionSector === "조선") { - return [ - { value: "A-max", label: "A-max" }, - { value: "S-max", label: "S-max" }, - { value: "VLCC", label: "VLCC" }, - { value: "LNGC", label: "LNGC" }, - { value: "CONT", label: "CONT" }, - ] - } else if (constructionSector === "해양") { - return [ - { value: "FPSO", label: "FPSO" }, - { value: "FLNG", label: "FLNG" }, - { value: "FPU", label: "FPU" }, - { value: "Platform", label: "Platform" }, - { value: "WTIV", label: "WTIV" }, - { value: "GOM", label: "GOM" }, - ] - } else { - return [] - } - } - - const shipTypeOptions = getShipTypeOptions(formData.constructionSector) return ( <Dialog open={open} onOpenChange={onOpenChange}> @@ -419,6 +522,8 @@ export function AvlVendorAddAndModifyDialog({ value={formData.projectCode} onChange={(e) => setFormData(prev => ({ ...prev, projectCode: e.target.value }))} placeholder="프로젝트 코드를 입력하세요" + readOnly + className="bg-muted" /> </div> </div> @@ -430,82 +535,39 @@ export function AvlVendorAddAndModifyDialog({ <div className="grid grid-cols-2 gap-4"> <div className="space-y-2"> <Label htmlFor="constructionSector">공사부문 *</Label> - <Select + <Input value={formData.constructionSector} - onValueChange={(value) => { - setFormData(prev => ({ - ...prev, - constructionSector: value, - shipType: "" // 공사부문 변경 시 선종 초기화 - })) - }} - > - <SelectTrigger> - <SelectValue placeholder="공사부문을 선택하세요" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="조선">조선</SelectItem> - <SelectItem value="해양">해양</SelectItem> - </SelectContent> - </Select> + readOnly + className="bg-muted" + placeholder="공사부문이 설정되지 않았습니다" + /> </div> <div className="space-y-2"> <Label htmlFor="shipType">선종 *</Label> - <Select + <Input value={formData.shipType} - onValueChange={(value) => - setFormData(prev => ({ ...prev, shipType: value })) - } - disabled={!formData.constructionSector} - > - <SelectTrigger> - <SelectValue placeholder="선종을 선택하세요" /> - </SelectTrigger> - <SelectContent> - {shipTypeOptions.map((option) => ( - <SelectItem key={option.value} value={option.value}> - {option.label} - </SelectItem> - ))} - </SelectContent> - </Select> + readOnly + className="bg-muted" + placeholder="선종이 설정되지 않았습니다" + /> </div> <div className="space-y-2"> <Label htmlFor="avlKind">AVL종류 *</Label> - <Select + <Input value={formData.avlKind} - onValueChange={(value) => - setFormData(prev => ({ ...prev, avlKind: value })) - } - > - <SelectTrigger> - <SelectValue placeholder="AVL종류를 선택하세요" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="Nearshore">Nearshore</SelectItem> - <SelectItem value="Offshore">Offshore</SelectItem> - <SelectItem value="IOC">IOC</SelectItem> - <SelectItem value="NOC">NOC</SelectItem> - </SelectContent> - </Select> + readOnly + className="bg-muted" + placeholder="AVL종류가 설정되지 않았습니다" + /> </div> <div className="space-y-2"> <Label htmlFor="htDivision">H/T 구분 *</Label> - <Select + <Input value={formData.htDivision} - onValueChange={(value) => - setFormData(prev => ({ ...prev, htDivision: value })) - } - > - <SelectTrigger> - <SelectValue placeholder="H/T 구분을 선택하세요" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="공통">공통</SelectItem> - <SelectItem value="H">Hull (H)</SelectItem> - <SelectItem value="T">Topside (T)</SelectItem> - </SelectContent> - </Select> + readOnly + className="bg-muted" + placeholder="H/T 구분이 설정되지 않았습니다" + /> </div> </div> </div> @@ -533,23 +595,19 @@ export function AvlVendorAddAndModifyDialog({ </SelectContent> </Select> </div> - <div className="space-y-2"> - <Label htmlFor="disciplineCode">설계공종코드</Label> - <Input - id="disciplineCode" - value={formData.disciplineCode} - onChange={(e) => setFormData(prev => ({ ...prev, disciplineCode: e.target.value }))} - placeholder="설계공종코드를 입력하세요" - /> - </div> <div className="space-y-2 col-span-2"> - <Label htmlFor="disciplineName">설계공종명 *</Label> - <Input - id="disciplineName" - value={formData.disciplineName} - onChange={(e) => setFormData(prev => ({ ...prev, disciplineName: e.target.value }))} - placeholder="설계공종명을 입력하세요" - /> + <Label htmlFor="discipline">설계공종 *</Label> + <EngineeringDisciplineSelector + selectedDiscipline={selectedDiscipline} + onDisciplineSelect={handleDisciplineSelect} + placeholder="설계공종을 선택하세요" + className="h-9" + /> + <div className="text-xs text-muted-foreground"> + {selectedDiscipline && ( + <span>선택됨: [{selectedDiscipline.CD}] {selectedDiscipline.USR_DF_CHAR_18}</span> + )} + </div> </div> <div className="space-y-2 col-span-2"> <Label htmlFor="materialNameCustomerSide">고객사 AVL 자재명 *</Label> @@ -591,31 +649,51 @@ export function AvlVendorAddAndModifyDialog({ {/* 자재그룹 정보 */} <div className="space-y-4"> <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">자재그룹 정보</h4> - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-2"> - <Label htmlFor="materialGroupCode">자재그룹 코드</Label> - <Input - id="materialGroupCode" - value={formData.materialGroupCode} - onChange={(e) => setFormData(prev => ({ ...prev, materialGroupCode: e.target.value }))} - placeholder="자재그룹 코드를 입력하세요" - /> - </div> + <div className="space-y-4"> <div className="space-y-2"> - <Label htmlFor="materialGroupName">자재그룹 명</Label> - <Input - id="materialGroupName" - value={formData.materialGroupName} - onChange={(e) => setFormData(prev => ({ ...prev, materialGroupName: e.target.value }))} - placeholder="자재그룹 명을 입력하세요" - /> + {/* <Label htmlFor="materialGroup">자재그룹 선택</Label> */} + <MaterialGroupSelectorDialogSingle + triggerLabel="자재그룹 선택" + selectedMaterial={selectedMaterialGroup} + onMaterialSelect={handleMaterialGroupSelect} + placeholder="자재그룹을 검색하세요..." + title="자재그룹 선택" + description="AVL에 등록할 자재그룹을 선택해주세요." + triggerVariant="outline" + /> + <div className="text-xs text-muted-foreground"> + {selectedMaterialGroup && ( + <> + <span>자재그룹코드: {selectedMaterialGroup.materialGroupCode}</span> + <br /> + <span>자재그룹명: {selectedMaterialGroup.materialGroupDescription}</span> + </> + )} + </div> </div> </div> </div> {/* 협력업체 정보 */} <div className="space-y-4"> - <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">협력업체 정보</h4> + <div className="flex items-center justify-between border-b pb-2"> + <h4 className="text-sm font-semibold text-muted-foreground">협력업체 정보</h4> + <VendorSelectorDialogSingle + triggerLabel="협력업체 로드" + selectedVendor={selectedVendor} + onVendorSelect={handleVendorSelect} + title="협력업체 선택" + description="등록된 협력업체 목록에서 선택하여 정보를 자동으로 입력할 수 있습니다." + triggerVariant="outline" + statusFilter="ACTIVE" + /> + </div> + {selectedVendor && ( + <div className="text-xs text-muted-foreground bg-blue-50 p-2 rounded"> + <span>선택된 협력업체: [{selectedVendor.vendorCode || '-'}] {selectedVendor.vendorName}</span> + <span className="ml-2 text-blue-600">({selectedVendor.status})</span> + </div> + )} <div className="grid grid-cols-2 gap-4"> <div className="space-y-2"> <Label htmlFor="vendorCode">협력업체 코드</Label> @@ -646,11 +724,11 @@ export function AvlVendorAddAndModifyDialog({ </div> <div className="space-y-2"> <Label htmlFor="tier">등급 (Tier)</Label> - <Input - id="tier" + <VendorTierSelector value={formData.tier} - onChange={(e) => setFormData(prev => ({ ...prev, tier: e.target.value }))} - placeholder="등급을 입력하세요" + onValueChange={(value) => setFormData(prev => ({ ...prev, tier: value }))} + placeholder="등급을 선택하세요" + className="h-9" /> </div> </div> @@ -698,11 +776,11 @@ export function AvlVendorAddAndModifyDialog({ </div> <div className="space-y-2"> <Label htmlFor="manufacturingLocation">제작/선적지 (국가)</Label> - <Input - id="manufacturingLocation" + <PlaceOfShippingSelector value={formData.manufacturingLocation} - onChange={(e) => setFormData(prev => ({ ...prev, manufacturingLocation: e.target.value }))} - placeholder="제작/선적지를 입력하세요" + onValueChange={(value) => setFormData(prev => ({ ...prev, manufacturingLocation: value }))} + placeholder="제작/선적지를 선택하세요" + className="h-9" /> </div> </div> @@ -862,12 +940,12 @@ export function AvlVendorAddAndModifyDialog({ /> </div> <div className="space-y-2"> - <Label htmlFor="quoteReceivedDate">견적접수일 (YYYY-MM-DD)</Label> - <Input - id="quoteReceivedDate" - value={formData.quoteReceivedDate} - onChange={(e) => setFormData(prev => ({ ...prev, quoteReceivedDate: e.target.value }))} - placeholder="YYYY-MM-DD" + <Label htmlFor="quoteReceivedDate">견적접수일</Label> + <DatePicker + date={quoteReceivedDate} + onSelect={setQuoteReceivedDate} + placeholder="견적접수일 선택" + className="h-9" /> </div> </div> @@ -887,12 +965,12 @@ export function AvlVendorAddAndModifyDialog({ /> </div> <div className="space-y-2"> - <Label htmlFor="recentQuoteDate">최근견적일 (YYYY-MM-DD)</Label> - <Input - id="recentQuoteDate" - value={formData.recentQuoteDate} - onChange={(e) => setFormData(prev => ({ ...prev, recentQuoteDate: e.target.value }))} - placeholder="YYYY-MM-DD" + <Label htmlFor="recentQuoteDate">최근견적일</Label> + <DatePicker + date={recentQuoteDate} + onSelect={setRecentQuoteDate} + placeholder="최근견적일 선택" + className="h-9" /> </div> <div className="space-y-2"> @@ -905,12 +983,12 @@ export function AvlVendorAddAndModifyDialog({ /> </div> <div className="space-y-2"> - <Label htmlFor="recentOrderDate">최근발주일 (YYYY-MM-DD)</Label> - <Input - id="recentOrderDate" - value={formData.recentOrderDate} - onChange={(e) => setFormData(prev => ({ ...prev, recentOrderDate: e.target.value }))} - placeholder="YYYY-MM-DD" + <Label htmlFor="recentOrderDate">최근발주일</Label> + <DatePicker + date={recentOrderDate} + onSelect={setRecentOrderDate} + placeholder="최근발주일 선택" + className="h-9" /> </div> </div> diff --git a/lib/avl/table/project-avl-add-dialog.tsx b/lib/avl/table/project-avl-add-dialog.tsx deleted file mode 100644 index 509e4258..00000000 --- a/lib/avl/table/project-avl-add-dialog.tsx +++ /dev/null @@ -1,779 +0,0 @@ -"use client" - -import * as React from "react" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { Checkbox } from "@/components/ui/checkbox" -import { Textarea } from "@/components/ui/textarea" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { toast } from "sonner" -import type { AvlVendorInfoInput, AvlDetailItem } from "../types" - -interface ProjectAvlAddDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - onAddItem: (item: Omit<AvlVendorInfoInput, 'avlListId'>) => Promise<void> - editingItem?: AvlDetailItem // 수정할 항목 (없으면 추가 모드) - onUpdateItem?: (id: number, item: Omit<AvlVendorInfoInput, 'avlListId'>) => Promise<void> // 수정 핸들러 -} - -export function ProjectAvlAddDialog({ open, onOpenChange, onAddItem, editingItem, onUpdateItem }: ProjectAvlAddDialogProps) { - const [formData, setFormData] = React.useState<Omit<AvlVendorInfoInput, 'avlListId'>>({ - // 설계 정보 - equipBulkDivision: "EQUIP", - disciplineCode: "", - disciplineName: "", - - // 자재 정보 - materialNameCustomerSide: "", - - // 패키지 정보 - packageCode: "", - packageName: "", - - // 자재그룹 정보 - materialGroupCode: "", - materialGroupName: "", - - // 협력업체 정보 - vendorName: "", - vendorCode: "", - - // AVL 정보 - avlVendorName: "", - tier: "", - - // 제안방향 - ownerSuggestion: false, - shiSuggestion: false, - - // 위치 정보 - headquarterLocation: "", - manufacturingLocation: "", - - // FA 정보 - faTarget: false, - faStatus: "", - - // Agent 정보 - isAgent: false, - - // 계약 서명주체 - contractSignerName: "", - contractSignerCode: "", - - // SHI Qualification - shiAvl: false, - shiBlacklist: false, - shiBcc: false, - - // 기술영업 견적결과 - salesQuoteNumber: "", - quoteCode: "", - salesVendorInfo: "", - salesCountry: "", - totalAmount: "", - quoteReceivedDate: "", - - // 업체 실적 현황 - recentQuoteDate: "", - recentQuoteNumber: "", - recentOrderDate: "", - recentOrderNumber: "", - - // 기타 - remarks: "" - }) - - // 수정 모드일 때 폼 데이터 초기화 - React.useEffect(() => { - if (editingItem) { - setFormData({ - // 설계 정보 - equipBulkDivision: editingItem.equipBulkDivision === "EQUIP" ? "EQUIP" : "BULK", - disciplineCode: editingItem.disciplineCode || "", - disciplineName: editingItem.disciplineName || "", - - // 자재 정보 - materialNameCustomerSide: editingItem.materialNameCustomerSide || "", - - // 패키지 정보 - packageCode: editingItem.packageCode || "", - packageName: editingItem.packageName || "", - - // 자재그룹 정보 - materialGroupCode: editingItem.materialGroupCode || "", - materialGroupName: editingItem.materialGroupName || "", - - // 협력업체 정보 - vendorName: editingItem.vendorName || "", - vendorCode: editingItem.vendorCode || "", - - // AVL 정보 - avlVendorName: editingItem.avlVendorName || "", - tier: editingItem.tier || "", - - // 제안방향 - ownerSuggestion: editingItem.ownerSuggestion || false, - shiSuggestion: editingItem.shiSuggestion || false, - - // 위치 정보 - headquarterLocation: editingItem.headquarterLocation || "", - manufacturingLocation: editingItem.manufacturingLocation || "", - - // FA 정보 - faTarget: editingItem.faTarget || false, - faStatus: editingItem.faStatus || "", - - // Agent 정보 - isAgent: editingItem.isAgent || false, - - // 계약 서명주체 - contractSignerName: editingItem.contractSignerName || "", - contractSignerCode: editingItem.contractSignerCode || "", - - // SHI Qualification - shiAvl: editingItem.shiAvl || false, - shiBlacklist: editingItem.shiBlacklist || false, - shiBcc: editingItem.shiBcc || false, - - // 기술영업 견적결과 - salesQuoteNumber: editingItem.salesQuoteNumber || "", - quoteCode: editingItem.quoteCode || "", - salesVendorInfo: editingItem.salesVendorInfo || "", - salesCountry: editingItem.salesCountry || "", - totalAmount: editingItem.totalAmount || "", - quoteReceivedDate: editingItem.quoteReceivedDate || "", - - // 업체 실적 현황 - recentQuoteDate: editingItem.recentQuoteDate || "", - recentQuoteNumber: editingItem.recentQuoteNumber || "", - recentOrderDate: editingItem.recentOrderDate || "", - recentOrderNumber: editingItem.recentOrderNumber || "", - - // 기타 - remarks: editingItem.remarks || "" - }) - } - }, [editingItem]) - - const handleSubmit = async () => { - // 필수 필드 검증 - if (!formData.disciplineName || !formData.materialNameCustomerSide) { - toast.error("설계공종과 고객사 AVL 자재명은 필수 입력 항목입니다.") - return - } - - try { - if (editingItem && onUpdateItem) { - // 수정 모드 - await onUpdateItem(editingItem.id, formData) - } else { - // 추가 모드 - await onAddItem(formData) - } - - // 폼 초기화 (onAddItem에서 성공적으로 처리된 경우에만) - setFormData({ - // 설계 정보 - equipBulkDivision: "EQUIP", - disciplineCode: "", - disciplineName: "", - - // 자재 정보 - materialNameCustomerSide: "", - - // 패키지 정보 - packageCode: "", - packageName: "", - - // 자재그룹 정보 - materialGroupCode: "", - materialGroupName: "", - - // 협력업체 정보 - vendorName: "", - vendorCode: "", - - // AVL 정보 - avlVendorName: "", - tier: "", - - // 제안방향 - ownerSuggestion: false, - shiSuggestion: false, - - // 위치 정보 - headquarterLocation: "", - manufacturingLocation: "", - - // FA 정보 - faTarget: false, - faStatus: "", - - // Agent 정보 - isAgent: false, - - // 계약 서명주체 - contractSignerName: "", - contractSignerCode: "", - - // SHI Qualification - shiAvl: false, - shiBlacklist: false, - shiBcc: false, - - // 기술영업 견적결과 - salesQuoteNumber: "", - quoteCode: "", - salesVendorInfo: "", - salesCountry: "", - totalAmount: "", - quoteReceivedDate: "", - - // 업체 실적 현황 - recentQuoteDate: "", - recentQuoteNumber: "", - recentOrderDate: "", - recentOrderNumber: "", - - // 기타 - remarks: "" - } as Omit<AvlVendorInfoInput, 'avlListId'>) - - onOpenChange(false) - } catch (error) { - // 에러 처리는 onAddItem에서 담당하므로 여기서는 아무것도 하지 않음 - } - } - - const handleCancel = () => { - setFormData({ - // 설계 정보 - equipBulkDivision: "EQUIP", - disciplineCode: "", - disciplineName: "", - - // 자재 정보 - materialNameCustomerSide: "", - - // 패키지 정보 - packageCode: "", - packageName: "", - - // 자재그룹 정보 - materialGroupCode: "", - materialGroupName: "", - - // 협력업체 정보 - vendorName: "", - vendorCode: "", - - // AVL 정보 - avlVendorName: "", - tier: "", - - // 제안방향 - ownerSuggestion: false, - shiSuggestion: false, - - // 위치 정보 - headquarterLocation: "", - manufacturingLocation: "", - - // FA 정보 - faTarget: false, - faStatus: "", - - // Agent 정보 - isAgent: false, - - // 계약 서명주체 - contractSignerName: "", - contractSignerCode: "", - - // SHI Qualification - shiAvl: false, - shiBlacklist: false, - shiBcc: false, - - // 기술영업 견적결과 - salesQuoteNumber: "", - quoteCode: "", - salesVendorInfo: "", - salesCountry: "", - totalAmount: "", - quoteReceivedDate: "", - - // 업체 실적 현황 - recentQuoteDate: "", - recentQuoteNumber: "", - recentOrderDate: "", - recentOrderNumber: "", - - // 기타 - remarks: "" - } as Omit<AvlVendorInfoInput, 'avlListId'>) - onOpenChange(false) - } - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto"> - <DialogHeader> - <DialogTitle>{editingItem ? "프로젝트 AVL 항목 수정" : "프로젝트 AVL 항목 추가"}</DialogTitle> - <DialogDescription> - {editingItem - ? "AVL 항목을 수정합니다. 필수 항목을 입력해주세요." - : "새로운 AVL 항목을 추가합니다. 필수 항목을 입력해주세요." - } * 표시된 항목은 필수 입력사항입니다. - </DialogDescription> - </DialogHeader> - <div className="space-y-6 py-4"> - {/* 기본 정보 */} - <div className="space-y-4"> - <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">기본 정보</h4> - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-2"> - <Label htmlFor="equipBulkDivision">EQUIP/BULK 구분</Label> - <Select - value={formData.equipBulkDivision} - onValueChange={(value: "EQUIP" | "BULK") => - setFormData(prev => ({ ...prev, equipBulkDivision: value })) - } - > - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="EQUIP">EQUIP</SelectItem> - <SelectItem value="BULK">BULK</SelectItem> - </SelectContent> - </Select> - </div> - <div className="space-y-2"> - <Label htmlFor="disciplineCode">설계공종코드</Label> - <Input - id="disciplineCode" - value={formData.disciplineCode} - onChange={(e) => setFormData(prev => ({ ...prev, disciplineCode: e.target.value }))} - placeholder="설계공종코드를 입력하세요" - /> - </div> - <div className="space-y-2 col-span-2"> - <Label htmlFor="disciplineName">설계공종명 *</Label> - <Input - id="disciplineName" - value={formData.disciplineName} - onChange={(e) => setFormData(prev => ({ ...prev, disciplineName: e.target.value }))} - placeholder="설계공종명을 입력하세요" - /> - </div> - <div className="space-y-2 col-span-2"> - <Label htmlFor="materialNameCustomerSide">고객사 AVL 자재명 *</Label> - <Input - id="materialNameCustomerSide" - value={formData.materialNameCustomerSide} - onChange={(e) => setFormData(prev => ({ ...prev, materialNameCustomerSide: e.target.value }))} - placeholder="고객사 AVL 자재명을 입력하세요" - /> - </div> - </div> - </div> - - {/* 패키지 정보 */} - <div className="space-y-4"> - <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">패키지 정보</h4> - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-2"> - <Label htmlFor="packageCode">패키지 코드</Label> - <Input - id="packageCode" - value={formData.packageCode} - onChange={(e) => setFormData(prev => ({ ...prev, packageCode: e.target.value }))} - placeholder="패키지 코드를 입력하세요" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="packageName">패키지 명</Label> - <Input - id="packageName" - value={formData.packageName} - onChange={(e) => setFormData(prev => ({ ...prev, packageName: e.target.value }))} - placeholder="패키지 명을 입력하세요" - /> - </div> - </div> - </div> - - {/* 자재그룹 정보 */} - <div className="space-y-4"> - <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">자재그룹 정보</h4> - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-2"> - <Label htmlFor="materialGroupCode">자재그룹 코드</Label> - <Input - id="materialGroupCode" - value={formData.materialGroupCode} - onChange={(e) => setFormData(prev => ({ ...prev, materialGroupCode: e.target.value }))} - placeholder="자재그룹 코드를 입력하세요" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="materialGroupName">자재그룹 명</Label> - <Input - id="materialGroupName" - value={formData.materialGroupName} - onChange={(e) => setFormData(prev => ({ ...prev, materialGroupName: e.target.value }))} - placeholder="자재그룹 명을 입력하세요" - /> - </div> - </div> - </div> - - {/* 협력업체 정보 */} - <div className="space-y-4"> - <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">협력업체 정보</h4> - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-2"> - <Label htmlFor="vendorCode">협력업체 코드</Label> - <Input - id="vendorCode" - value={formData.vendorCode} - onChange={(e) => setFormData(prev => ({ ...prev, vendorCode: e.target.value }))} - placeholder="협력업체 코드를 입력하세요" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="vendorName">협력업체 명</Label> - <Input - id="vendorName" - value={formData.vendorName} - onChange={(e) => setFormData(prev => ({ ...prev, vendorName: e.target.value }))} - placeholder="협력업체 명을 입력하세요" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="avlVendorName">AVL 등재업체명</Label> - <Input - id="avlVendorName" - value={formData.avlVendorName} - onChange={(e) => setFormData(prev => ({ ...prev, avlVendorName: e.target.value }))} - placeholder="AVL 등재업체명을 입력하세요" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="tier">등급 (Tier)</Label> - <Input - id="tier" - value={formData.tier} - onChange={(e) => setFormData(prev => ({ ...prev, tier: e.target.value }))} - placeholder="등급을 입력하세요" - /> - </div> - </div> - </div> - - {/* 제안방향 */} - <div className="space-y-4"> - <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">제안방향</h4> - <div className="flex gap-6"> - <div className="flex items-center space-x-2"> - <Checkbox - id="ownerSuggestion" - checked={formData.ownerSuggestion} - onCheckedChange={(checked) => - setFormData(prev => ({ ...prev, ownerSuggestion: !!checked })) - } - /> - <Label htmlFor="ownerSuggestion">선주제안</Label> - </div> - <div className="flex items-center space-x-2"> - <Checkbox - id="shiSuggestion" - checked={formData.shiSuggestion} - onCheckedChange={(checked) => - setFormData(prev => ({ ...prev, shiSuggestion: !!checked })) - } - /> - <Label htmlFor="shiSuggestion">SHI 제안</Label> - </div> - </div> - </div> - - {/* 위치 정보 */} - <div className="space-y-4"> - <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">위치 정보</h4> - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-2"> - <Label htmlFor="headquarterLocation">본사 위치 (국가)</Label> - <Input - id="headquarterLocation" - value={formData.headquarterLocation} - onChange={(e) => setFormData(prev => ({ ...prev, headquarterLocation: e.target.value }))} - placeholder="본사 위치를 입력하세요" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="manufacturingLocation">제작/선적지 (국가)</Label> - <Input - id="manufacturingLocation" - value={formData.manufacturingLocation} - onChange={(e) => setFormData(prev => ({ ...prev, manufacturingLocation: e.target.value }))} - placeholder="제작/선적지를 입력하세요" - /> - </div> - </div> - </div> - - {/* FA 정보 */} - <div className="space-y-4"> - <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">FA 정보</h4> - <div className="grid grid-cols-2 gap-4"> - <div className="flex items-center space-x-2"> - <Checkbox - id="faTarget" - checked={formData.faTarget} - onCheckedChange={(checked) => - setFormData(prev => ({ ...prev, faTarget: !!checked })) - } - /> - <Label htmlFor="faTarget">FA 대상</Label> - </div> - <div className="space-y-2"> - <Label htmlFor="faStatus">FA 현황</Label> - <Input - id="faStatus" - value={formData.faStatus} - onChange={(e) => setFormData(prev => ({ ...prev, faStatus: e.target.value }))} - placeholder="FA 현황을 입력하세요" - /> - </div> - </div> - </div> - - {/* Agent 정보 */} - <div className="space-y-4"> - <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">Agent 정보</h4> - <div className="flex items-center space-x-2"> - <Checkbox - id="isAgent" - checked={formData.isAgent} - onCheckedChange={(checked) => - setFormData(prev => ({ ...prev, isAgent: !!checked })) - } - /> - <Label htmlFor="isAgent">Agent 여부</Label> - </div> - </div> - - {/* 계약 서명주체 */} - <div className="space-y-4"> - <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">계약 서명주체</h4> - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-2"> - <Label htmlFor="contractSignerCode">계약서명주체 코드</Label> - <Input - id="contractSignerCode" - value={formData.contractSignerCode} - onChange={(e) => setFormData(prev => ({ ...prev, contractSignerCode: e.target.value }))} - placeholder="계약서명주체 코드를 입력하세요" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="contractSignerName">계약서명주체 명</Label> - <Input - id="contractSignerName" - value={formData.contractSignerName} - onChange={(e) => setFormData(prev => ({ ...prev, contractSignerName: e.target.value }))} - placeholder="계약서명주체 명을 입력하세요" - /> - </div> - </div> - </div> - - {/* SHI Qualification */} - <div className="space-y-4"> - <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">SHI Qualification</h4> - <div className="flex gap-6"> - <div className="flex items-center space-x-2"> - <Checkbox - id="shiAvl" - checked={formData.shiAvl} - onCheckedChange={(checked) => - setFormData(prev => ({ ...prev, shiAvl: !!checked })) - } - /> - <Label htmlFor="shiAvl">AVL</Label> - </div> - <div className="flex items-center space-x-2"> - <Checkbox - id="shiBlacklist" - checked={formData.shiBlacklist} - onCheckedChange={(checked) => - setFormData(prev => ({ ...prev, shiBlacklist: !!checked })) - } - /> - <Label htmlFor="shiBlacklist">Blacklist</Label> - </div> - <div className="flex items-center space-x-2"> - <Checkbox - id="shiBcc" - checked={formData.shiBcc} - onCheckedChange={(checked) => - setFormData(prev => ({ ...prev, shiBcc: !!checked })) - } - /> - <Label htmlFor="shiBcc">BCC</Label> - </div> - </div> - </div> - - {/* 기술영업 견적결과 */} - <div className="space-y-4"> - <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">기술영업 견적결과</h4> - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-2"> - <Label htmlFor="salesQuoteNumber">기술영업 견적번호</Label> - <Input - id="salesQuoteNumber" - value={formData.salesQuoteNumber} - onChange={(e) => setFormData(prev => ({ ...prev, salesQuoteNumber: e.target.value }))} - placeholder="기술영업 견적번호를 입력하세요" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="quoteCode">견적서 Code</Label> - <Input - id="quoteCode" - value={formData.quoteCode} - onChange={(e) => setFormData(prev => ({ ...prev, quoteCode: e.target.value }))} - placeholder="견적서 Code를 입력하세요" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="salesVendorInfo">견적 협력업체 명</Label> - <Input - id="salesVendorInfo" - value={formData.salesVendorInfo} - onChange={(e) => setFormData(prev => ({ ...prev, salesVendorInfo: e.target.value }))} - placeholder="견적 협력업체 명을 입력하세요" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="salesCountry">국가</Label> - <Input - id="salesCountry" - value={formData.salesCountry} - onChange={(e) => setFormData(prev => ({ ...prev, salesCountry: e.target.value }))} - placeholder="국가를 입력하세요" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="totalAmount">총 금액</Label> - <Input - id="totalAmount" - type="number" - value={formData.totalAmount} - onChange={(e) => setFormData(prev => ({ ...prev, totalAmount: e.target.value }))} - placeholder="총 금액을 입력하세요" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="quoteReceivedDate">견적접수일 (YYYY-MM-DD)</Label> - <Input - id="quoteReceivedDate" - value={formData.quoteReceivedDate} - onChange={(e) => setFormData(prev => ({ ...prev, quoteReceivedDate: e.target.value }))} - placeholder="YYYY-MM-DD" - /> - </div> - </div> - </div> - - {/* 업체 실적 현황 */} - <div className="space-y-4"> - <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">업체 실적 현황</h4> - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-2"> - <Label htmlFor="recentQuoteNumber">최근견적번호</Label> - <Input - id="recentQuoteNumber" - value={formData.recentQuoteNumber} - onChange={(e) => setFormData(prev => ({ ...prev, recentQuoteNumber: e.target.value }))} - placeholder="최근견적번호를 입력하세요" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="recentQuoteDate">최근견적일 (YYYY-MM-DD)</Label> - <Input - id="recentQuoteDate" - value={formData.recentQuoteDate} - onChange={(e) => setFormData(prev => ({ ...prev, recentQuoteDate: e.target.value }))} - placeholder="YYYY-MM-DD" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="recentOrderNumber">최근발주번호</Label> - <Input - id="recentOrderNumber" - value={formData.recentOrderNumber} - onChange={(e) => setFormData(prev => ({ ...prev, recentOrderNumber: e.target.value }))} - placeholder="최근발주번호를 입력하세요" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="recentOrderDate">최근발주일 (YYYY-MM-DD)</Label> - <Input - id="recentOrderDate" - value={formData.recentOrderDate} - onChange={(e) => setFormData(prev => ({ ...prev, recentOrderDate: e.target.value }))} - placeholder="YYYY-MM-DD" - /> - </div> - </div> - </div> - - {/* 기타 */} - <div className="space-y-4"> - <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">기타</h4> - <div className="space-y-2"> - <Label htmlFor="remarks">비고</Label> - <Textarea - id="remarks" - value={formData.remarks} - onChange={(e) => setFormData(prev => ({ ...prev, remarks: e.target.value }))} - placeholder="비고를 입력하세요" - rows={3} - /> - </div> - </div> - </div> - <DialogFooter> - <Button type="button" variant="outline" onClick={handleCancel}> - 취소 - </Button> - <Button type="button" onClick={handleSubmit}> - {editingItem ? "수정" : "추가"} - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) -} diff --git a/lib/avl/table/project-avl-table.tsx b/lib/avl/table/project-avl-table.tsx index fc8f0f5e..7a0fda2e 100644 --- a/lib/avl/table/project-avl-table.tsx +++ b/lib/avl/table/project-avl-table.tsx @@ -4,6 +4,14 @@ import * as React from "react" import { forwardRef, useImperativeHandle, useLayoutEffect, useMemo } from "react" import { DataTable } from "@/components/data-table/data-table" import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" import { AvlVendorAddAndModifyDialog } from "./avl-vendor-add-and-modify-dialog" import { getProjectAvlVendorInfo, createAvlVendorInfo, updateAvlVendorInfo, deleteAvlVendorInfo, finalizeProjectAvl } from "../service" import { useReactTable, getCoreRowModel, getPaginationRowModel, getSortedRowModel, getFilteredRowModel } from "@tanstack/react-table" @@ -384,6 +392,9 @@ export const ProjectAvlTable = forwardRef<ProjectAvlTableRef, ProjectAvlTablePro } }, [table, loadData]) + // 최종 확정 다이얼로그 상태 + const [isConfirmDialogOpen, setIsConfirmDialogOpen] = React.useState(false) + // 최종 확정 핸들러 const handleFinalizeAvl = React.useCallback(async () => { // 1. 필수 조건 검증 @@ -402,27 +413,20 @@ export const ProjectAvlTable = forwardRef<ProjectAvlTableRef, ProjectAvlTablePro return } - // 2. 사용자 확인 - const confirmed = window.confirm( - `현재 프로젝트(${localProjectCode})의 AVL을 최종 확정하시겠습니까?\n\n` + - `- 프로젝트명: ${projectInfo.projectName}\n` + - `- 벤더 정보: ${data.length}개\n` + - `- 공사부문: ${projectInfo.constructionSector}\n` + - `- 선종: ${projectInfo.shipType}\n` + - `- H/T 구분: ${projectInfo.htDivision}\n\n` + - `확정 후에는 수정이 어려울 수 있습니다.` - ) - - if (!confirmed) return + // 2. 확인 다이얼로그 열기 + setIsConfirmDialogOpen(true) + }, [localProjectCode, projectInfo, data.length]) + // 실제 최종 확정 실행 함수 + const executeFinalizeAvl = React.useCallback(async () => { try { - // 3. 현재 데이터의 모든 ID 수집 + // 3. 현재 데이터의 모든 ID 수집 (전체 레코드 기준) const avlVendorInfoIds = data.map(item => item.id) // 4. 최종 확정 실행 const result = await finalizeProjectAvl( localProjectCode, - projectInfo, + projectInfo!, avlVendorInfoIds, sessionData?.user?.name || "" ) @@ -441,6 +445,8 @@ export const ProjectAvlTable = forwardRef<ProjectAvlTableRef, ProjectAvlTablePro } catch (error) { console.error("AVL 최종 확정 실패:", error) toast.error("AVL 최종 확정 중 오류가 발생했습니다.") + } finally { + setIsConfirmDialogOpen(false) } }, [localProjectCode, projectInfo, data, table, loadData, sessionData?.user?.name]) @@ -595,6 +601,37 @@ export const ProjectAvlTable = forwardRef<ProjectAvlTableRef, ProjectAvlTablePro isTemplate={false} // 프로젝트 AVL 모드 initialProjectCode={localProjectCode} /> + + {/* 최종 확정 확인 다이얼로그 */} + <Dialog open={isConfirmDialogOpen} onOpenChange={setIsConfirmDialogOpen}> + <DialogContent> + <DialogHeader> + <DialogTitle>프로젝트 AVL 최종 확정</DialogTitle> + <DialogDescription> + 현재 프로젝트의 AVL을 최종 확정하시겠습니까? + </DialogDescription> + </DialogHeader> + <div className="space-y-2 text-sm"> + <div>• 프로젝트 코드: {localProjectCode}</div> + <div>• 프로젝트명: {projectInfo?.projectName || ""}</div> + <div>• 공사부문: {projectInfo?.constructionSector || ""}</div> + <div>• 선종: {projectInfo?.shipType || ""}</div> + <div>• H/T 구분: {projectInfo?.htDivision || ""}</div> + <div>• 벤더 정보: {data.length}개 (전체 레코드)</div> + {/* <div className="text-amber-600 font-medium mt-4"> + ⚠️ 확정 후 내용 수정을 필요로 하는 경우 동일 건을 다시 최종확정해 revision 처리로 수정해야 합니다. + </div> */} + </div> + <DialogFooter> + <Button variant="outline" onClick={() => setIsConfirmDialogOpen(false)}> + 취소 + </Button> + <Button onClick={executeFinalizeAvl}> + 최종 확정 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> </div> ) }) diff --git a/lib/avl/table/standard-avl-add-dialog.tsx b/lib/avl/table/standard-avl-add-dialog.tsx deleted file mode 100644 index 9e8b016c..00000000 --- a/lib/avl/table/standard-avl-add-dialog.tsx +++ /dev/null @@ -1,960 +0,0 @@ -"use client" - -import * as React from "react" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { Checkbox } from "@/components/ui/checkbox" -import { Textarea } from "@/components/ui/textarea" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { toast } from "sonner" -import type { AvlVendorInfoInput, AvlDetailItem } from "../types" - -interface StandardAvlAddDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - onAddItem: (item: Omit<AvlVendorInfoInput, 'avlListId'>) => Promise<void> - editingItem?: AvlDetailItem // 수정할 항목 (없으면 추가 모드) - onUpdateItem?: (id: number, item: Omit<AvlVendorInfoInput, 'avlListId'>) => Promise<void> // 수정 핸들러 - // 검색 조건에서 선택한 값들을 초기값으로 사용 - initialConstructionSector?: string - initialShipType?: string - initialAvlKind?: string - initialHtDivision?: string -} - -export function StandardAvlAddDialog({ - open, - onOpenChange, - onAddItem, - editingItem, - onUpdateItem, - initialConstructionSector, - initialShipType, - initialAvlKind, - initialHtDivision -}: StandardAvlAddDialogProps) { - const [formData, setFormData] = React.useState<Omit<AvlVendorInfoInput, 'avlListId'>>({ - // 표준 AVL용 기본 설정 - isTemplate: true, - - // 표준 AVL 필수 필드들 (검색 조건에서 선택한 값들로 초기화) - constructionSector: initialConstructionSector || "", - shipType: initialShipType || "", - avlKind: initialAvlKind || "", - htDivision: initialHtDivision || "", - - // 설계 정보 - equipBulkDivision: "EQUIP", - disciplineCode: "", - disciplineName: "", - - // 자재 정보 - materialNameCustomerSide: "", - - // 패키지 정보 - packageCode: "", - packageName: "", - - // 자재그룹 정보 - materialGroupCode: "", - materialGroupName: "", - - // 협력업체 정보 - vendorName: "", - vendorCode: "", - - // AVL 정보 - avlVendorName: "", - tier: "", - - // 제안방향 - ownerSuggestion: false, - shiSuggestion: false, - - // 위치 정보 - headquarterLocation: "", - manufacturingLocation: "", - - // FA 정보 - faTarget: false, - faStatus: "", - - // Agent 정보 - isAgent: false, - - // 계약 서명주체 - contractSignerName: "", - contractSignerCode: "", - - // SHI Qualification - shiAvl: false, - shiBlacklist: false, - shiBcc: false, - - // 기술영업 견적결과 - salesQuoteNumber: "", - quoteCode: "", - salesVendorInfo: "", - salesCountry: "", - totalAmount: "", - quoteReceivedDate: "", - - // 업체 실적 현황 - recentQuoteDate: "", - recentQuoteNumber: "", - recentOrderDate: "", - recentOrderNumber: "", - - // 기타 - remarks: "" - }) - - // 수정 모드일 때 폼 데이터 초기화 - React.useEffect(() => { - if (editingItem) { - setFormData({ - // 표준 AVL용 기본 설정 - isTemplate: true, - - // 표준 AVL 필수 필드들 (기존 값 우선, 없으면 검색 조건 값 사용) - constructionSector: editingItem.constructionSector || initialConstructionSector || "", - shipType: editingItem.shipType || initialShipType || "", - avlKind: editingItem.avlKind || initialAvlKind || "", - htDivision: editingItem.htDivision || initialHtDivision || "", - - // 설계 정보 - equipBulkDivision: editingItem.equipBulkDivision === "EQUIP" ? "EQUIP" : "BULK", - disciplineCode: editingItem.disciplineCode || "", - disciplineName: editingItem.disciplineName || "", - - // 자재 정보 - materialNameCustomerSide: editingItem.materialNameCustomerSide || "", - - // 패키지 정보 - packageCode: editingItem.packageCode || "", - packageName: editingItem.packageName || "", - - // 자재그룹 정보 - materialGroupCode: editingItem.materialGroupCode || "", - materialGroupName: editingItem.materialGroupName || "", - - // 협력업체 정보 - vendorName: editingItem.vendorName || "", - vendorCode: editingItem.vendorCode || "", - - // AVL 정보 - avlVendorName: editingItem.avlVendorName || "", - tier: editingItem.tier || "", - - // 제안방향 - ownerSuggestion: editingItem.ownerSuggestion || false, - shiSuggestion: editingItem.shiSuggestion || false, - - // 위치 정보 - headquarterLocation: editingItem.headquarterLocation || "", - manufacturingLocation: editingItem.manufacturingLocation || "", - - // FA 정보 - faTarget: editingItem.faTarget || false, - faStatus: editingItem.faStatus || "", - - // Agent 정보 - isAgent: editingItem.isAgent || false, - - // 계약 서명주체 - contractSignerName: editingItem.contractSignerName || "", - contractSignerCode: editingItem.contractSignerCode || "", - - // SHI Qualification - shiAvl: editingItem.shiAvl || false, - shiBlacklist: editingItem.shiBlacklist || false, - shiBcc: editingItem.shiBcc || false, - - // 기술영업 견적결과 - salesQuoteNumber: editingItem.salesQuoteNumber || "", - quoteCode: editingItem.quoteCode || "", - salesVendorInfo: editingItem.salesVendorInfo || "", - salesCountry: editingItem.salesCountry || "", - totalAmount: editingItem.totalAmount || "", - quoteReceivedDate: editingItem.quoteReceivedDate || "", - - // 업체 실적 현황 - recentQuoteDate: editingItem.recentQuoteDate || "", - recentQuoteNumber: editingItem.recentQuoteNumber || "", - recentOrderDate: editingItem.recentOrderDate || "", - recentOrderNumber: editingItem.recentOrderNumber || "", - - // 기타 - remarks: editingItem.remarks || "" - }) - } - }, [editingItem, initialConstructionSector, initialShipType, initialAvlKind, initialHtDivision]) - - // 다이얼로그가 열릴 때 초기값 재설정 (수정 모드가 아닐 때만) - React.useEffect(() => { - if (open && !editingItem) { - setFormData(prev => ({ - ...prev, - constructionSector: initialConstructionSector || "", - shipType: initialShipType || "", - avlKind: initialAvlKind || "", - htDivision: initialHtDivision || "", - })) - } - }, [open, editingItem, initialConstructionSector, initialShipType, initialAvlKind, initialHtDivision]) - - const handleSubmit = async () => { - // 필수 필드 검증 (표준 AVL용) - if (!formData.constructionSector || !formData.shipType || !formData.avlKind || !formData.htDivision) { - toast.error("공사부문, 선종, AVL종류, H/T 구분은 필수 입력 항목입니다.") - return - } - - if (!formData.disciplineName || !formData.materialNameCustomerSide) { - toast.error("설계공종과 고객사 AVL 자재명은 필수 입력 항목입니다.") - return - } - - try { - if (editingItem && onUpdateItem) { - // 수정 모드 - await onUpdateItem(editingItem.id, formData) - } else { - // 추가 모드 - await onAddItem(formData) - } - - // 폼 초기화 (onAddItem에서 성공적으로 처리된 경우에만) - setFormData({ - // 표준 AVL용 기본 설정 - isTemplate: true, - - // 표준 AVL 필수 필드들 - constructionSector: "", - shipType: "", - avlKind: "", - htDivision: "", - - // 설계 정보 - equipBulkDivision: "EQUIP", - disciplineCode: "", - disciplineName: "", - - // 자재 정보 - materialNameCustomerSide: "", - - // 패키지 정보 - packageCode: "", - packageName: "", - - // 자재그룹 정보 - materialGroupCode: "", - materialGroupName: "", - - // 협력업체 정보 - vendorName: "", - vendorCode: "", - - // AVL 정보 - avlVendorName: "", - tier: "", - - // 제안방향 - ownerSuggestion: false, - shiSuggestion: false, - - // 위치 정보 - headquarterLocation: "", - manufacturingLocation: "", - - // FA 정보 - faTarget: false, - faStatus: "", - - // Agent 정보 - isAgent: false, - - // 계약 서명주체 - contractSignerName: "", - contractSignerCode: "", - - // SHI Qualification - shiAvl: false, - shiBlacklist: false, - shiBcc: false, - - // 기술영업 견적결과 - salesQuoteNumber: "", - quoteCode: "", - salesVendorInfo: "", - salesCountry: "", - totalAmount: "", - quoteReceivedDate: "", - - // 업체 실적 현황 - recentQuoteDate: "", - recentQuoteNumber: "", - recentOrderDate: "", - recentOrderNumber: "", - - // 기타 - remarks: "" - } as Omit<AvlVendorInfoInput, 'avlListId'>) - - onOpenChange(false) - } catch (error) { - // 에러 처리는 onAddItem에서 담당하므로 여기서는 아무것도 하지 않음 - } - } - - const handleCancel = () => { - setFormData({ - // 표준 AVL용 기본 설정 - isTemplate: true, - - // 표준 AVL 필수 필드들 - constructionSector: "", - shipType: "", - avlKind: "", - htDivision: "", - - // 설계 정보 - equipBulkDivision: "EQUIP", - disciplineCode: "", - disciplineName: "", - - // 자재 정보 - materialNameCustomerSide: "", - - // 패키지 정보 - packageCode: "", - packageName: "", - - // 자재그룹 정보 - materialGroupCode: "", - materialGroupName: "", - - // 협력업체 정보 - vendorName: "", - vendorCode: "", - - // AVL 정보 - avlVendorName: "", - tier: "", - - // 제안방향 - ownerSuggestion: false, - shiSuggestion: false, - - // 위치 정보 - headquarterLocation: "", - manufacturingLocation: "", - - // FA 정보 - faTarget: false, - faStatus: "", - - // Agent 정보 - isAgent: false, - - // 계약 서명주체 - contractSignerName: "", - contractSignerCode: "", - - // SHI Qualification - shiAvl: false, - shiBlacklist: false, - shiBcc: false, - - // 기술영업 견적결과 - salesQuoteNumber: "", - quoteCode: "", - salesVendorInfo: "", - salesCountry: "", - totalAmount: "", - quoteReceivedDate: "", - - // 업체 실적 현황 - recentQuoteDate: "", - recentQuoteNumber: "", - recentOrderDate: "", - recentOrderNumber: "", - - // 기타 - remarks: "" - } as Omit<AvlVendorInfoInput, 'avlListId'>) - onOpenChange(false) - } - - // 선종 옵션들 (공사부문에 따라 다름) - const getShipTypeOptions = (constructionSector: string) => { - if (constructionSector === "조선") { - return [ - { value: "A-max", label: "A-max" }, - { value: "S-max", label: "S-max" }, - { value: "VLCC", label: "VLCC" }, - { value: "LNGC", label: "LNGC" }, - { value: "CONT", label: "CONT" }, - ] - } else if (constructionSector === "해양") { - return [ - { value: "FPSO", label: "FPSO" }, - { value: "FLNG", label: "FLNG" }, - { value: "FPU", label: "FPU" }, - { value: "Platform", label: "Platform" }, - { value: "WTIV", label: "WTIV" }, - { value: "GOM", label: "GOM" }, - ] - } else { - return [] - } - } - - const shipTypeOptions = getShipTypeOptions(formData.constructionSector) - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto"> - <DialogHeader> - <DialogTitle>{editingItem ? "표준 AVL 항목 수정" : "표준 AVL 항목 추가"}</DialogTitle> - <DialogDescription> - {editingItem - ? "표준 AVL 항목을 수정합니다. 필수 항목을 입력해주세요." - : "새로운 표준 AVL 항목을 추가합니다. 필수 항목을 입력해주세요." - } * 표시된 항목은 필수 입력사항입니다. - </DialogDescription> - </DialogHeader> - <div className="space-y-6 py-4"> - {/* 표준 AVL 기본 정보 */} - <div className="space-y-4"> - <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">표준 AVL 기본 정보 *</h4> - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-2"> - <Label htmlFor="constructionSector">공사부문 *</Label> - <Select - value={formData.constructionSector} - onValueChange={(value) => { - setFormData(prev => ({ - ...prev, - constructionSector: value, - shipType: "" // 공사부문 변경 시 선종 초기화 - })) - }} - > - <SelectTrigger> - <SelectValue placeholder="공사부문을 선택하세요" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="조선">조선</SelectItem> - <SelectItem value="해양">해양</SelectItem> - </SelectContent> - </Select> - </div> - <div className="space-y-2"> - <Label htmlFor="shipType">선종 *</Label> - <Select - value={formData.shipType} - onValueChange={(value) => - setFormData(prev => ({ ...prev, shipType: value })) - } - disabled={!formData.constructionSector} - > - <SelectTrigger> - <SelectValue placeholder="선종을 선택하세요" /> - </SelectTrigger> - <SelectContent> - {shipTypeOptions.map((option) => ( - <SelectItem key={option.value} value={option.value}> - {option.label} - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - <div className="space-y-2"> - <Label htmlFor="avlKind">AVL종류 *</Label> - <Select - value={formData.avlKind} - onValueChange={(value) => - setFormData(prev => ({ ...prev, avlKind: value })) - } - > - <SelectTrigger> - <SelectValue placeholder="AVL종류를 선택하세요" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="Nearshore">Nearshore</SelectItem> - <SelectItem value="Offshore">Offshore</SelectItem> - <SelectItem value="IOC">IOC</SelectItem> - <SelectItem value="NOC">NOC</SelectItem> - </SelectContent> - </Select> - </div> - <div className="space-y-2"> - <Label htmlFor="htDivision">H/T 구분 *</Label> - <Select - value={formData.htDivision} - onValueChange={(value) => - setFormData(prev => ({ ...prev, htDivision: value })) - } - > - <SelectTrigger> - <SelectValue placeholder="H/T 구분을 선택하세요" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="공통">공통</SelectItem> - <SelectItem value="H">Hull (H)</SelectItem> - <SelectItem value="T">Topside (T)</SelectItem> - </SelectContent> - </Select> - </div> - </div> - </div> - - {/* 기본 정보 */} - <div className="space-y-4"> - <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">기본 정보</h4> - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-2"> - <Label htmlFor="equipBulkDivision">EQUIP/BULK 구분</Label> - <Select - value={formData.equipBulkDivision} - onValueChange={(value: "EQUIP" | "BULK") => - setFormData(prev => ({ ...prev, equipBulkDivision: value })) - } - > - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="EQUIP">EQUIP</SelectItem> - <SelectItem value="BULK">BULK</SelectItem> - </SelectContent> - </Select> - </div> - <div className="space-y-2"> - <Label htmlFor="disciplineCode">설계공종코드</Label> - <Input - id="disciplineCode" - value={formData.disciplineCode} - onChange={(e) => setFormData(prev => ({ ...prev, disciplineCode: e.target.value }))} - placeholder="설계공종코드를 입력하세요" - /> - </div> - <div className="space-y-2 col-span-2"> - <Label htmlFor="disciplineName">설계공종명 *</Label> - <Input - id="disciplineName" - value={formData.disciplineName} - onChange={(e) => setFormData(prev => ({ ...prev, disciplineName: e.target.value }))} - placeholder="설계공종명을 입력하세요" - /> - </div> - <div className="space-y-2 col-span-2"> - <Label htmlFor="materialNameCustomerSide">고객사 AVL 자재명 *</Label> - <Input - id="materialNameCustomerSide" - value={formData.materialNameCustomerSide} - onChange={(e) => setFormData(prev => ({ ...prev, materialNameCustomerSide: e.target.value }))} - placeholder="고객사 AVL 자재명을 입력하세요" - /> - </div> - </div> - </div> - - {/* 패키지 정보 */} - <div className="space-y-4"> - <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">패키지 정보</h4> - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-2"> - <Label htmlFor="packageCode">패키지 코드</Label> - <Input - id="packageCode" - value={formData.packageCode} - onChange={(e) => setFormData(prev => ({ ...prev, packageCode: e.target.value }))} - placeholder="패키지 코드를 입력하세요" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="packageName">패키지 명</Label> - <Input - id="packageName" - value={formData.packageName} - onChange={(e) => setFormData(prev => ({ ...prev, packageName: e.target.value }))} - placeholder="패키지 명을 입력하세요" - /> - </div> - </div> - </div> - - {/* 자재그룹 정보 */} - <div className="space-y-4"> - <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">자재그룹 정보</h4> - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-2"> - <Label htmlFor="materialGroupCode">자재그룹 코드</Label> - <Input - id="materialGroupCode" - value={formData.materialGroupCode} - onChange={(e) => setFormData(prev => ({ ...prev, materialGroupCode: e.target.value }))} - placeholder="자재그룹 코드를 입력하세요" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="materialGroupName">자재그룹 명</Label> - <Input - id="materialGroupName" - value={formData.materialGroupName} - onChange={(e) => setFormData(prev => ({ ...prev, materialGroupName: e.target.value }))} - placeholder="자재그룹 명을 입력하세요" - /> - </div> - </div> - </div> - - {/* 협력업체 정보 */} - <div className="space-y-4"> - <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">협력업체 정보</h4> - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-2"> - <Label htmlFor="vendorCode">협력업체 코드</Label> - <Input - id="vendorCode" - value={formData.vendorCode} - onChange={(e) => setFormData(prev => ({ ...prev, vendorCode: e.target.value }))} - placeholder="협력업체 코드를 입력하세요" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="vendorName">협력업체 명</Label> - <Input - id="vendorName" - value={formData.vendorName} - onChange={(e) => setFormData(prev => ({ ...prev, vendorName: e.target.value }))} - placeholder="협력업체 명을 입력하세요" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="avlVendorName">AVL 등재업체명</Label> - <Input - id="avlVendorName" - value={formData.avlVendorName} - onChange={(e) => setFormData(prev => ({ ...prev, avlVendorName: e.target.value }))} - placeholder="AVL 등재업체명을 입력하세요" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="tier">등급 (Tier)</Label> - <Input - id="tier" - value={formData.tier} - onChange={(e) => setFormData(prev => ({ ...prev, tier: e.target.value }))} - placeholder="등급을 입력하세요" - /> - </div> - </div> - </div> - - {/* 제안방향 */} - <div className="space-y-4"> - <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">제안방향</h4> - <div className="flex gap-6"> - <div className="flex items-center space-x-2"> - <Checkbox - id="ownerSuggestion" - checked={formData.ownerSuggestion} - onCheckedChange={(checked) => - setFormData(prev => ({ ...prev, ownerSuggestion: !!checked })) - } - /> - <Label htmlFor="ownerSuggestion">선주제안</Label> - </div> - <div className="flex items-center space-x-2"> - <Checkbox - id="shiSuggestion" - checked={formData.shiSuggestion} - onCheckedChange={(checked) => - setFormData(prev => ({ ...prev, shiSuggestion: !!checked })) - } - /> - <Label htmlFor="shiSuggestion">SHI 제안</Label> - </div> - </div> - </div> - - {/* 위치 정보 */} - <div className="space-y-4"> - <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">위치 정보</h4> - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-2"> - <Label htmlFor="headquarterLocation">본사 위치 (국가)</Label> - <Input - id="headquarterLocation" - value={formData.headquarterLocation} - onChange={(e) => setFormData(prev => ({ ...prev, headquarterLocation: e.target.value }))} - placeholder="본사 위치를 입력하세요" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="manufacturingLocation">제작/선적지 (국가)</Label> - <Input - id="manufacturingLocation" - value={formData.manufacturingLocation} - onChange={(e) => setFormData(prev => ({ ...prev, manufacturingLocation: e.target.value }))} - placeholder="제작/선적지를 입력하세요" - /> - </div> - </div> - </div> - - {/* FA 정보 */} - <div className="space-y-4"> - <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">FA 정보</h4> - <div className="grid grid-cols-2 gap-4"> - <div className="flex items-center space-x-2"> - <Checkbox - id="faTarget" - checked={formData.faTarget} - onCheckedChange={(checked) => - setFormData(prev => ({ ...prev, faTarget: !!checked })) - } - /> - <Label htmlFor="faTarget">FA 대상</Label> - </div> - <div className="space-y-2"> - <Label htmlFor="faStatus">FA 현황</Label> - <Input - id="faStatus" - value={formData.faStatus} - onChange={(e) => setFormData(prev => ({ ...prev, faStatus: e.target.value }))} - placeholder="FA 현황을 입력하세요" - /> - </div> - </div> - </div> - - {/* Agent 정보 */} - <div className="space-y-4"> - <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">Agent 정보</h4> - <div className="flex items-center space-x-2"> - <Checkbox - id="isAgent" - checked={formData.isAgent} - onCheckedChange={(checked) => - setFormData(prev => ({ ...prev, isAgent: !!checked })) - } - /> - <Label htmlFor="isAgent">Agent 여부</Label> - </div> - </div> - - {/* 계약 서명주체 */} - <div className="space-y-4"> - <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">계약 서명주체</h4> - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-2"> - <Label htmlFor="contractSignerCode">계약서명주체 코드</Label> - <Input - id="contractSignerCode" - value={formData.contractSignerCode} - onChange={(e) => setFormData(prev => ({ ...prev, contractSignerCode: e.target.value }))} - placeholder="계약서명주체 코드를 입력하세요" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="contractSignerName">계약서명주체 명</Label> - <Input - id="contractSignerName" - value={formData.contractSignerName} - onChange={(e) => setFormData(prev => ({ ...prev, contractSignerName: e.target.value }))} - placeholder="계약서명주체 명을 입력하세요" - /> - </div> - </div> - </div> - - {/* SHI Qualification */} - <div className="space-y-4"> - <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">SHI Qualification</h4> - <div className="flex gap-6"> - <div className="flex items-center space-x-2"> - <Checkbox - id="shiAvl" - checked={formData.shiAvl} - onCheckedChange={(checked) => - setFormData(prev => ({ ...prev, shiAvl: !!checked })) - } - /> - <Label htmlFor="shiAvl">AVL</Label> - </div> - <div className="flex items-center space-x-2"> - <Checkbox - id="shiBlacklist" - checked={formData.shiBlacklist} - onCheckedChange={(checked) => - setFormData(prev => ({ ...prev, shiBlacklist: !!checked })) - } - /> - <Label htmlFor="shiBlacklist">Blacklist</Label> - </div> - <div className="flex items-center space-x-2"> - <Checkbox - id="shiBcc" - checked={formData.shiBcc} - onCheckedChange={(checked) => - setFormData(prev => ({ ...prev, shiBcc: !!checked })) - } - /> - <Label htmlFor="shiBcc">BCC</Label> - </div> - </div> - </div> - - {/* 기술영업 견적결과 */} - <div className="space-y-4"> - <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">기술영업 견적결과</h4> - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-2"> - <Label htmlFor="salesQuoteNumber">기술영업 견적번호</Label> - <Input - id="salesQuoteNumber" - value={formData.salesQuoteNumber} - onChange={(e) => setFormData(prev => ({ ...prev, salesQuoteNumber: e.target.value }))} - placeholder="기술영업 견적번호를 입력하세요" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="quoteCode">견적서 Code</Label> - <Input - id="quoteCode" - value={formData.quoteCode} - onChange={(e) => setFormData(prev => ({ ...prev, quoteCode: e.target.value }))} - placeholder="견적서 Code를 입력하세요" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="salesVendorInfo">견적 협력업체 명</Label> - <Input - id="salesVendorInfo" - value={formData.salesVendorInfo} - onChange={(e) => setFormData(prev => ({ ...prev, salesVendorInfo: e.target.value }))} - placeholder="견적 협력업체 명을 입력하세요" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="salesCountry">국가</Label> - <Input - id="salesCountry" - value={formData.salesCountry} - onChange={(e) => setFormData(prev => ({ ...prev, salesCountry: e.target.value }))} - placeholder="국가를 입력하세요" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="totalAmount">총 금액</Label> - <Input - id="totalAmount" - type="number" - value={formData.totalAmount} - onChange={(e) => setFormData(prev => ({ ...prev, totalAmount: e.target.value }))} - placeholder="총 금액을 입력하세요" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="quoteReceivedDate">견적접수일 (YYYY-MM-DD)</Label> - <Input - id="quoteReceivedDate" - value={formData.quoteReceivedDate} - onChange={(e) => setFormData(prev => ({ ...prev, quoteReceivedDate: e.target.value }))} - placeholder="YYYY-MM-DD" - /> - </div> - </div> - </div> - - {/* 업체 실적 현황 */} - <div className="space-y-4"> - <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">업체 실적 현황</h4> - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-2"> - <Label htmlFor="recentQuoteNumber">최근견적번호</Label> - <Input - id="recentQuoteNumber" - value={formData.recentQuoteNumber} - onChange={(e) => setFormData(prev => ({ ...prev, recentQuoteNumber: e.target.value }))} - placeholder="최근견적번호를 입력하세요" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="recentQuoteDate">최근견적일 (YYYY-MM-DD)</Label> - <Input - id="recentQuoteDate" - value={formData.recentQuoteDate} - onChange={(e) => setFormData(prev => ({ ...prev, recentQuoteDate: e.target.value }))} - placeholder="YYYY-MM-DD" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="recentOrderNumber">최근발주번호</Label> - <Input - id="recentOrderNumber" - value={formData.recentOrderNumber} - onChange={(e) => setFormData(prev => ({ ...prev, recentOrderNumber: e.target.value }))} - placeholder="최근발주번호를 입력하세요" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="recentOrderDate">최근발주일 (YYYY-MM-DD)</Label> - <Input - id="recentOrderDate" - value={formData.recentOrderDate} - onChange={(e) => setFormData(prev => ({ ...prev, recentOrderDate: e.target.value }))} - placeholder="YYYY-MM-DD" - /> - </div> - </div> - </div> - - {/* 기타 */} - <div className="space-y-4"> - <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">기타</h4> - <div className="space-y-2"> - <Label htmlFor="remarks">비고</Label> - <Textarea - id="remarks" - value={formData.remarks} - onChange={(e) => setFormData(prev => ({ ...prev, remarks: e.target.value }))} - placeholder="비고를 입력하세요" - rows={3} - /> - </div> - </div> - </div> - <DialogFooter> - <Button type="button" variant="outline" onClick={handleCancel}> - 취소 - </Button> - <Button type="button" onClick={handleSubmit}> - {editingItem ? "수정" : "추가"} - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) -} diff --git a/lib/avl/table/standard-avl-table.tsx b/lib/avl/table/standard-avl-table.tsx index cc39540b..bacb5812 100644 --- a/lib/avl/table/standard-avl-table.tsx +++ b/lib/avl/table/standard-avl-table.tsx @@ -15,6 +15,14 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" import { Search } from "lucide-react" import { toast } from "sonner" import { standardAvlColumns } from "./standard-avl-table-columns" @@ -22,13 +30,9 @@ import { AvlVendorAddAndModifyDialog } from "./avl-vendor-add-and-modify-dialog" import { createAvlVendorInfo, updateAvlVendorInfo, deleteAvlVendorInfo, finalizeStandardAvl } from "../service" import { AvlVendorInfoInput } from "../types" import { useSession } from "next-auth/react" +import { ShipTypeSelector, ShipTypeItem } from "@/components/common/ship-type" /** - * 조선인 경우, 선종: - * A-max, S-max, VLCC, LNGC, CONT - * 해양인 경우, 선종: - * FPSO, FLNG, FPU, Platform, WTIV, GOM - * * AVL종류: * Nearshore, Offshore, IOC, NOC */ @@ -39,31 +43,6 @@ const constructionSectorOptions = [ { value: "해양", label: "해양" }, ] -// 공사부문에 따른 선종 옵션들 -const getShipTypeOptions = (constructionSector: string) => { - if (constructionSector === "조선") { - return [ - { value: "A-max", label: "A-max" }, - { value: "S-max", label: "S-max" }, - { value: "VLCC", label: "VLCC" }, - { value: "LNGC", label: "LNGC" }, - { value: "CONT", label: "CONT" }, - ] - } else if (constructionSector === "해양") { - return [ - { value: "FPSO", label: "FPSO" }, - { value: "FLNG", label: "FLNG" }, - { value: "FPU", label: "FPU" }, - { value: "Platform", label: "Platform" }, - { value: "WTIV", label: "WTIV" }, - { value: "GOM", label: "GOM" }, - ] - } else { - // 공사부문이 선택되지 않은 경우 빈 배열 - return [] - } -} - const avlKindOptions = [ { value: "Nearshore", label: "Nearshore" }, { value: "Offshore", label: "Offshore" }, @@ -123,7 +102,9 @@ export const StandardAvlTable = forwardRef<StandardAvlTableRef, StandardAvlTable // 검색 상태 const [searchConstructionSector, setSearchConstructionSector] = React.useState(initialConstructionSector || "") - const [searchShipType, setSearchShipType] = React.useState(initialShipType || "") + const [selectedShipType, setSelectedShipType] = React.useState<ShipTypeItem | undefined>( + initialShipType ? { CD: initialShipType, CDNM: initialShipType, displayText: initialShipType } : undefined + ) const [searchAvlKind, setSearchAvlKind] = React.useState(initialAvlKind || "") const [searchHtDivision, setSearchHtDivision] = React.useState(initialHtDivision || "") @@ -173,34 +154,29 @@ export const StandardAvlTable = forwardRef<StandardAvlTableRef, StandardAvlTable const handleConstructionSectorChange = React.useCallback((value: string) => { setSearchConstructionSector(value) // 공사부문이 변경되면 선종을 빈 값으로 초기화 - setSearchShipType("") + setSelectedShipType(undefined) }, []) // 검색 상태 변경 시 부모 컴포넌트에 전달 React.useEffect(() => { onSearchConditionsChange?.({ constructionSector: searchConstructionSector, - shipType: searchShipType, + shipType: selectedShipType?.CD || "", avlKind: searchAvlKind, htDivision: searchHtDivision }) - }, [searchConstructionSector, searchShipType, searchAvlKind, searchHtDivision, onSearchConditionsChange]) + }, [searchConstructionSector, selectedShipType, searchAvlKind, searchHtDivision, onSearchConditionsChange]) - // 현재 공사부문에 따른 선종 옵션들 - const currentShipTypeOptions = React.useMemo(() => - getShipTypeOptions(searchConstructionSector), - [searchConstructionSector] - ) // 모든 검색 조건이 선택되었는지 확인 const isAllSearchConditionsSelected = React.useMemo(() => { return ( searchConstructionSector.trim() !== "" && - searchShipType.trim() !== "" && + selectedShipType?.CD?.trim() !== "" && searchAvlKind.trim() !== "" && searchHtDivision.trim() !== "" ) - }, [searchConstructionSector, searchShipType, searchAvlKind, searchHtDivision]) + }, [searchConstructionSector, selectedShipType, searchAvlKind, searchHtDivision]) // 데이터 로드 함수 const loadData = React.useCallback(async (searchParams: Partial<GetStandardAvlSchema> = {}) => { @@ -213,7 +189,7 @@ export const StandardAvlTable = forwardRef<StandardAvlTableRef, StandardAvlTable sort: searchParams.sort ?? [{ id: "no", desc: false }], flags: searchParams.flags ?? [], constructionSector: searchConstructionSector, - shipType: searchShipType, + shipType: selectedShipType?.CD || "", avlKind: searchAvlKind, htDivision: searchHtDivision as "공통" | "H" | "T" | "", equipBulkDivision: searchParams.equipBulkDivision || "", @@ -249,7 +225,7 @@ export const StandardAvlTable = forwardRef<StandardAvlTableRef, StandardAvlTable } finally { setLoading(false) } - }, [searchConstructionSector, searchShipType, searchAvlKind, searchHtDivision]) + }, [searchConstructionSector, selectedShipType, searchAvlKind, searchHtDivision]) // reloadTrigger가 변경될 때마다 데이터 리로드 React.useEffect(() => { @@ -262,7 +238,7 @@ export const StandardAvlTable = forwardRef<StandardAvlTableRef, StandardAvlTable // 검색 초기화 핸들러 const handleResetSearch = React.useCallback(() => { setSearchConstructionSector("") - setSearchShipType("") + setSelectedShipType(undefined) setSearchAvlKind("") setSearchHtDivision("") // 초기화 시 빈 데이터로 설정 @@ -365,6 +341,9 @@ export const StandardAvlTable = forwardRef<StandardAvlTableRef, StandardAvlTable } }, [table, loadData]) + // 최종 확정 다이얼로그 상태 + const [isConfirmDialogOpen, setIsConfirmDialogOpen] = React.useState(false) + // 최종 확정 핸들러 (표준 AVL) const handleFinalizeStandardAvl = React.useCallback(async () => { // 1. 필수 조건 검증 @@ -378,27 +357,20 @@ export const StandardAvlTable = forwardRef<StandardAvlTableRef, StandardAvlTable return } - // 2. 사용자 확인 - const confirmed = window.confirm( - `현재 표준 AVL을 최종 확정하시겠습니까?\n\n` + - `- 공사부문: ${searchConstructionSector}\n` + - `- 선종: ${searchShipType}\n` + - `- AVL종류: ${searchAvlKind}\n` + - `- H/T 구분: ${searchHtDivision}\n` + - `- 벤더 정보: ${data.length}개\n\n` + - `확정 후에는 수정이 어려울 수 있습니다.` - ) - - if (!confirmed) return + // 2. 확인 다이얼로그 열기 + setIsConfirmDialogOpen(true) + }, [isAllSearchConditionsSelected, data.length]) + // 실제 최종 확정 실행 함수 + const executeFinalizeStandardAvl = React.useCallback(async () => { try { - // 3. 현재 데이터의 모든 ID 수집 + // 3. 현재 데이터의 모든 ID 수집 (전체 레코드 기준) const avlVendorInfoIds = data.map(item => item.id) // 4. 최종 확정 실행 const standardAvlInfo = { constructionSector: searchConstructionSector, - shipType: searchShipType, + shipType: selectedShipType?.CD || "", avlKind: searchAvlKind, htDivision: searchHtDivision } @@ -423,8 +395,10 @@ export const StandardAvlTable = forwardRef<StandardAvlTableRef, StandardAvlTable } catch (error) { console.error("표준 AVL 최종 확정 실패:", error) toast.error("표준 AVL 최종 확정 중 오류가 발생했습니다.") + } finally { + setIsConfirmDialogOpen(false) } - }, [searchConstructionSector, searchShipType, searchAvlKind, searchHtDivision, isAllSearchConditionsSelected, data, table, loadData, sessionData?.user?.name]) + }, [searchConstructionSector, selectedShipType, searchAvlKind, searchHtDivision, data, table, loadData, sessionData?.user?.name]) // 초기 데이터 로드 (검색 조건이 모두 입력되었을 때만) React.useEffect(() => { @@ -437,7 +411,8 @@ export const StandardAvlTable = forwardRef<StandardAvlTableRef, StandardAvlTable setData([]) setPageCount(0) } - }, [isAllSearchConditionsSelected, pagination.pageSize, searchConstructionSector, searchShipType, searchAvlKind, searchHtDivision]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isAllSearchConditionsSelected, pagination.pageSize, searchConstructionSector, selectedShipType, searchAvlKind, searchHtDivision]) @@ -547,18 +522,13 @@ export const StandardAvlTable = forwardRef<StandardAvlTableRef, StandardAvlTable {/* 선종 */} <div className="space-y-2"> <label className="text-sm font-medium">선종</label> - <Select value={searchShipType} onValueChange={setSearchShipType}> - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - <SelectContent> - {currentShipTypeOptions.map((option) => ( - <SelectItem key={option.value} value={option.value}> - {option.label} - </SelectItem> - ))} - </SelectContent> - </Select> + <ShipTypeSelector + selectedShipType={selectedShipType} + onShipTypeSelect={setSelectedShipType} + placeholder="선종을 선택하세요" + disabled={!searchConstructionSector} + className="h-10" + /> </div> {/* AVL종류 */} @@ -640,10 +610,40 @@ export const StandardAvlTable = forwardRef<StandardAvlTableRef, StandardAvlTable isTemplate={true} // 표준 AVL 모드 // 검색 조건에서 선택한 값들을 초기값으로 전달 initialConstructionSector={searchConstructionSector} - initialShipType={searchShipType} + initialShipType={selectedShipType?.CD || ""} initialAvlKind={searchAvlKind} initialHtDivision={searchHtDivision} /> + + {/* 최종 확정 확인 다이얼로그 */} + <Dialog open={isConfirmDialogOpen} onOpenChange={setIsConfirmDialogOpen}> + <DialogContent> + <DialogHeader> + <DialogTitle>표준 AVL 최종 확정</DialogTitle> + <DialogDescription> + 현재 표준 AVL을 최종 확정하시겠습니까? + </DialogDescription> + </DialogHeader> + <div className="space-y-2 text-sm"> + <div>• 공사부문: {searchConstructionSector}</div> + <div>• 선종: {selectedShipType?.CD || ""}</div> + <div>• AVL종류: {searchAvlKind}</div> + <div>• H/T 구분: {searchHtDivision}</div> + <div>• 벤더 정보: {data.length}개 (전체 레코드)</div> + {/* <div className="text-amber-600 font-medium mt-4"> + ⚠️ 확정 후 내용 수정을 필요로 하는 경우 동일 건을 다시 최종확정해 revision 처리로 수정해야 합니다. + </div> */} + </div> + <DialogFooter> + <Button variant="outline" onClick={() => setIsConfirmDialogOpen(false)}> + 취소 + </Button> + <Button onClick={executeFinalizeStandardAvl}> + 최종 확정 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> </div> ) }) |
