diff options
Diffstat (limited to 'components/common')
16 files changed, 2251 insertions, 0 deletions
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: '벤더 상태 목록을 조회하는 중 오류가 발생했습니다.' + } + } +} |
