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