summaryrefslogtreecommitdiff
path: root/components/common
diff options
context:
space:
mode:
Diffstat (limited to 'components/common')
-rw-r--r--components/common/discipline/discipline-service.ts123
-rw-r--r--components/common/discipline/engineering-discipline-selector.tsx315
-rw-r--r--components/common/discipline/index.ts9
-rw-r--r--components/common/selectors/place-of-shipping/index.ts2
-rw-r--r--components/common/selectors/place-of-shipping/place-of-shipping-selector.tsx75
-rw-r--r--components/common/selectors/place-of-shipping/place-of-shipping-service.tsx17
-rw-r--r--components/common/selectors/vendor-tier/index.ts2
-rw-r--r--components/common/selectors/vendor-tier/vendor-tier-selector.tsx46
-rw-r--r--components/common/ship-type/index.ts2
-rw-r--r--components/common/ship-type/ship-type-selector.tsx308
-rw-r--r--components/common/ship-type/ship-type-service.ts120
-rw-r--r--components/common/vendor/index.ts19
-rw-r--r--components/common/vendor/vendor-selector-dialog-multi.tsx320
-rw-r--r--components/common/vendor/vendor-selector-dialog-single.tsx215
-rw-r--r--components/common/vendor/vendor-selector.tsx415
-rw-r--r--components/common/vendor/vendor-service.ts263
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: '벤더 상태 목록을 조회하는 중 오류가 발생했습니다.'
+ }
+ }
+}