diff options
19 files changed, 2779 insertions, 2012 deletions
diff --git a/app/[lng]/evcp/(evcp)/vendor-pool/page.tsx b/app/[lng]/evcp/(evcp)/vendor-pool/page.tsx index 6708e674..7426e069 100644 --- a/app/[lng]/evcp/(evcp)/vendor-pool/page.tsx +++ b/app/[lng]/evcp/(evcp)/vendor-pool/page.tsx @@ -1,3 +1,5 @@ +"use client" + import * as React from "react" import { type SearchParams } from "@/types/table" @@ -9,24 +11,13 @@ import { getVendorPools } from "@/lib/vendor-pool/service" import { vendorPoolSearchParamsCache } from "@/lib/vendor-pool/validations" import { VendorPoolTable } from "@/lib/vendor-pool/table/vendor-pool-table" import { InformationButton } from "@/components/information/information-button" +import { useSearchParams } from "next/navigation" interface VendorPoolPageProps { searchParams: Promise<SearchParams> } -export default async function VendorPoolPage(props: VendorPoolPageProps) { - const searchParams = await props.searchParams - const search = vendorPoolSearchParamsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getVendorPools({ - ...search, - filters: validFilters, - }), - ]) - +export default function VendorPoolPage({ searchParams }: VendorPoolPageProps) { return ( <Shell className="gap-2"> <div className="flex items-center justify-between space-y-2"> @@ -60,20 +51,118 @@ export default async function VendorPoolPage(props: VendorPoolPageProps) { /> } > - <VendorPoolTableWrapper promises={promises} /> + <VendorPoolTableWrapperClient searchParamsPromise={searchParams} /> </React.Suspense> </Shell> ) } -// 실제 데이터를 받아서 VendorPoolTable에 전달하는 컴포넌트 -function VendorPoolTableWrapper({ promises }: { promises: Promise<any> }) { - const [{ data, pageCount }] = React.use(promises) +// 클라이언트 컴포넌트: 필터 변경을 감시하여 데이터 재조회 +function VendorPoolTableWrapperClient({ searchParamsPromise }: { searchParamsPromise: Promise<SearchParams> }) { + const searchParams = useSearchParams() + const [initialData, setInitialData] = React.useState<{ data: any[], pageCount: number } | null>(null) + const [data, setData] = React.useState<any[]>([]) + const [pageCount, setPageCount] = React.useState(0) + const [isLoading, setIsLoading] = React.useState(false) + + // 초기 데이터 로딩 + React.useEffect(() => { + const loadInitialData = async () => { + try { + const searchParamsData = await searchParamsPromise + const search = vendorPoolSearchParamsCache.parse(searchParamsData) + const validFilters = getValidFilters(search.filters) + + const result = await getVendorPools({ + ...search, + filters: validFilters, + }) + + setInitialData(result) + setData(result.data) + setPageCount(result.pageCount) + } catch (error) { + console.error('Failed to load initial data:', error) + } + } + loadInitialData() + }, [searchParamsPromise]) + + // 필터 상태 변경 감시 및 데이터 재조회 + React.useEffect(() => { + if (!initialData) return // 초기 데이터가 로드되기 전까지는 실행하지 않음 + + const refreshData = async () => { + setIsLoading(true) + try { + const currentParams = Object.fromEntries(searchParams.entries()) + const search = vendorPoolSearchParamsCache.parse(currentParams) + const validFilters = getValidFilters(search.filters) + + const result = await getVendorPools({ + ...search, + filters: validFilters, + }) + + setData(result.data) + setPageCount(result.pageCount) + } catch (error) { + console.error('Failed to refresh vendor pool data:', error) + } finally { + setIsLoading(false) + } + } + + // 필터 파라미터가 변경될 때마다 데이터 재조회 + const currentFilters = searchParams.get('filters') + const currentJoinOperator = searchParams.get('joinOperator') + const currentSearch = searchParams.get('search') + const currentPage = searchParams.get('page') + const currentPerPage = searchParams.get('perPage') + const currentSort = searchParams.get('sort') + + // 필터 관련 파라미터가 변경되면 재조회 + if (currentFilters !== '[]' || currentJoinOperator || currentSearch || currentPage || currentPerPage || currentSort) { + refreshData() + } else { + // 필터가 초기 상태면 초기 데이터로 복원 + setData(initialData.data) + setPageCount(initialData.pageCount) + } + }, [searchParams, initialData]) + + const handleRefresh = React.useCallback(async () => { + if (!initialData) return + + setIsLoading(true) + try { + const currentParams = Object.fromEntries(searchParams.entries()) + const search = vendorPoolSearchParamsCache.parse(currentParams) + const validFilters = getValidFilters(search.filters) + + const result = await getVendorPools({ + ...search, + filters: validFilters, + }) + + setData(result.data) + setPageCount(result.pageCount) + } catch (error) { + console.error('Failed to refresh vendor pool data:', error) + } finally { + setIsLoading(false) + } + }, [searchParams, initialData]) + + if (!initialData) { + return null // 초기 데이터 로딩 중 + } return ( <VendorPoolTable data={data} pageCount={pageCount} + onRefresh={handleRefresh} /> ) } diff --git a/components/common/project/project-service.ts b/components/common/project/project-service.ts index 6c103c6f..510d7527 100644 --- a/components/common/project/project-service.ts +++ b/components/common/project/project-service.ts @@ -28,6 +28,10 @@ export interface ProjectInfo { shipType?: string | null projectMsrm?: string | null projectHtDivision?: string | null + type?: string | null // projects 테이블의 type 필드 + sector?: string | null // biddingProjects 테이블의 sector 필드 + typeMdg?: string | null // projects 테이블의 TYPE_MDG 필드 + pjtType?: string | null // biddingProjects 테이블의 pjtType 필드 source: 'projects' | 'biddingProjects' } @@ -145,13 +149,15 @@ export async function getProjectInfoByCode(projectCode: string, searchFrom: 'bot if (searchFrom === 'both' || searchFrom === 'projects') { try { const projectInfo = await db.select().from(projects).where(eq(projects.code, projectCode)).limit(1) - + if (projectInfo && projectInfo.length > 0) { return { projectCode: projectInfo[0].code, projectName: projectInfo[0].name, shipType: projectInfo[0].SKND || undefined, projectHtDivision: projectInfo[0].type || undefined, + type: projectInfo[0].type || undefined, + typeMdg: projectInfo[0].TYPE_MDG || undefined, source: 'projects' } } @@ -180,6 +186,8 @@ export async function getProjectInfoByCode(projectCode: string, searchFrom: 'bot projectName: projectInfo[0].projNm, projectMsrm: projectInfo[0].ptypeNm, projectHtDivision, + sector: projectInfo[0].sector || undefined, + pjtType: projectInfo[0].pjtType || undefined, source: 'biddingProjects' } } 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 index 63532365..0a9916cd 100644 --- a/components/common/selectors/place-of-shipping/place-of-shipping-selector.tsx +++ b/components/common/selectors/place-of-shipping/place-of-shipping-selector.tsx @@ -13,8 +13,27 @@ import { Select, SelectItem, SelectContent } from "@/components/ui/select" import { SelectTrigger } from "@/components/ui/select" import { SelectValue } from "@/components/ui/select" import { Input } from "@/components/ui/input" -import { useState, useEffect, useMemo } from "react" +import { useState, useEffect, useMemo, useCallback } from "react" import { getPlaceOfShippingForSelection } from "./place-of-shipping-service" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Search, Check } from "lucide-react" interface PlaceOfShippingData { code: string @@ -96,4 +115,233 @@ export function PlaceOfShippingSelector({ </Select> </div> ) +} + +/** + * 선적지/하역지 단일 선택 Dialog 컴포넌트 + * + * @description + * - PlaceOfShippingSelector를 Dialog로 래핑한 단일 선택 컴포넌트 + * - 버튼 클릭 시 Dialog가 열리고, 장소를 선택하면 Dialog가 닫히며 결과를 반환 + * + * @PlaceOfShippingData_Structure + * 선택된 장소 객체의 형태: + * ```typescript + * interface PlaceOfShippingData { + * code: string; // 장소코드 + * description: string; // 장소명 + * } + * ``` + * + * @state + * - open: Dialog 열림/닷힘 상태 + * - selectedPlace: 현재 선택된 장소 (단일) + * - tempSelectedPlace: Dialog 내에서 임시로 선택된 장소 (확인 버튼 클릭 전까지) + * + * @callback + * - onPlaceSelect: 장소 선택 완료 시 호출되는 콜백 + * - 매개변수: PlaceOfShippingData | null + * - 선택된 장소 정보 또는 null (선택 해제 시) + * + * @usage + * ```tsx + * <PlaceOfShippingSelectorDialogSingle + * triggerLabel="장소 선택" + * selectedPlace={selectedPlace} + * onPlaceSelect={(place) => { + * setSelectedPlace(place); + * console.log('선택된 장소:', place); + * }} + * placeholder="장소를 검색하세요..." + * /> + * ``` + */ + +interface PlaceOfShippingSelectorDialogSingleProps { + /** Dialog를 여는 트리거 버튼 텍스트 */ + triggerLabel?: string + /** 현재 선택된 장소 */ + selectedPlace?: PlaceOfShippingData | null + /** 장소 선택 완료 시 호출되는 콜백 */ + onPlaceSelect?: (place: PlaceOfShippingData | null) => void + /** 검색 입력창 placeholder */ + placeholder?: string + /** Dialog 제목 */ + title?: string + /** Dialog 설명 */ + description?: string + /** 트리거 버튼 비활성화 여부 */ + disabled?: boolean + /** 트리거 버튼 variant */ + triggerVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" +} + +export function PlaceOfShippingSelectorDialogSingle({ + triggerLabel = "장소 선택", + selectedPlace = null, + onPlaceSelect, + placeholder = "장소를 검색하세요...", + title = "장소 선택", + description = "원하는 장소를 검색하고 선택해주세요.", + disabled = false, + triggerVariant = "outline", +}: PlaceOfShippingSelectorDialogSingleProps) { + // Dialog 열림/닫힘 상태 + const [open, setOpen] = useState(false) + + // Dialog 내에서 임시로 선택된 장소 (확인 버튼 클릭 전까지) + const [tempSelectedPlace, setTempSelectedPlace] = useState<PlaceOfShippingData | null>(null) + + // 장소 데이터 + const [placeOfShippingData, setPlaceOfShippingData] = useState<PlaceOfShippingData[]>([]) + const [isLoading, setIsLoading] = useState(true) + const [searchTerm, setSearchTerm] = useState("") + + const filteredData = useMemo(() => { + if (!searchTerm) return placeOfShippingData + return placeOfShippingData.filter(item => + item.code.toLowerCase().includes(searchTerm.toLowerCase()) || + item.description.toLowerCase().includes(searchTerm.toLowerCase()) + ) + }, [placeOfShippingData, searchTerm]) + + // Dialog 열림 시 현재 선택된 장소로 임시 선택 초기화 + const handleOpenChange = useCallback((newOpen: boolean) => { + setOpen(newOpen) + if (newOpen) { + setTempSelectedPlace(selectedPlace || null) + } + }, [selectedPlace]) + + // 장소 선택 처리 (Dialog 내에서) + const handlePlaceChange = useCallback((place: PlaceOfShippingData) => { + setTempSelectedPlace(place) + }, []) + + // 확인 버튼 클릭 시 선택 완료 + const handleConfirm = useCallback(() => { + onPlaceSelect?.(tempSelectedPlace) + setOpen(false) + }, [tempSelectedPlace, onPlaceSelect]) + + // 취소 버튼 클릭 시 + const handleCancel = useCallback(() => { + setTempSelectedPlace(selectedPlace || null) + setOpen(false) + }, [selectedPlace]) + + // 선택 해제 + const handleClear = useCallback(() => { + setTempSelectedPlace(null) + }, []) + + useEffect(() => { + const loadData = async () => { + try { + const data = await getPlaceOfShippingForSelection() + setPlaceOfShippingData(data) + } catch (error) { + console.error('선적지/하역지 데이터 로드 실패:', error) + setPlaceOfShippingData([]) + } finally { + setIsLoading(false) + } + } + + loadData() + }, []) + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogTrigger asChild> + <Button variant={triggerVariant} disabled={disabled}> + {selectedPlace ? ( + <span className="truncate"> + {selectedPlace.code} - {selectedPlace.description} + </span> + ) : ( + triggerLabel + )} + </Button> + </DialogTrigger> + + <DialogContent className="max-w-4xl max-h-[80vh]"> + <DialogHeader> + <DialogTitle>{title}</DialogTitle> + <DialogDescription>{description}</DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + <div className="flex items-center space-x-2"> + <Search className="h-4 w-4" /> + <Input + placeholder={placeholder} + value={searchTerm} + onChange={(e) => setSearchTerm(e.target.value)} + className="flex-1" + /> + </div> + + {isLoading ? ( + <div className="flex justify-center py-8"> + <div className="text-sm text-muted-foreground">장소 데이터를 불러오는 중...</div> + </div> + ) : ( + <div className="border rounded-md max-h-96 overflow-auto"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-12"></TableHead> + <TableHead>장소코드</TableHead> + <TableHead>장소명</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {filteredData.length === 0 ? ( + <TableRow> + <TableCell colSpan={3} className="text-center py-8 text-muted-foreground"> + {searchTerm ? "검색 결과가 없습니다" : "데이터가 없습니다"} + </TableCell> + </TableRow> + ) : ( + filteredData.map((item) => ( + <TableRow + key={item.code} + className={`cursor-pointer hover:bg-muted/50 ${ + tempSelectedPlace?.code === item.code ? "bg-muted" : "" + }`} + onClick={() => handlePlaceChange(item)} + > + <TableCell> + {tempSelectedPlace?.code === item.code && ( + <Check className="h-4 w-4 text-primary" /> + )} + </TableCell> + <TableCell className="font-mono">{item.code}</TableCell> + <TableCell>{item.description}</TableCell> + </TableRow> + )) + )} + </TableBody> + </Table> + </div> + )} + </div> + + <DialogFooter className="gap-2"> + <Button variant="outline" onClick={handleCancel}> + 취소 + </Button> + {tempSelectedPlace && ( + <Button variant="ghost" onClick={handleClear}> + 선택 해제 + </Button> + )} + <Button onClick={handleConfirm}> + 확인 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) }
\ No newline at end of file diff --git a/components/common/vendor/vendor-selector-dialog-single.tsx b/components/common/vendor/vendor-selector-dialog-single.tsx index da9a9a74..7bb4b14c 100644 --- a/components/common/vendor/vendor-selector-dialog-single.tsx +++ b/components/common/vendor/vendor-selector-dialog-single.tsx @@ -28,6 +28,7 @@ import { VendorSearchItem } from "./vendor-service" * id: number; // 벤더 ID * vendorName: string; // 벤더명 * vendorCode: string | null; // 벤더코드 (없을 수 있음) + * taxId: string | null; // 사업자번호 * status: string; // 벤더 상태 (ACTIVE, PENDING_REVIEW 등) * displayText: string; // 표시용 텍스트 (vendorName + vendorCode) * } diff --git a/components/common/vendor/vendor-service.ts b/components/common/vendor/vendor-service.ts index 83a63cae..1c59843c 100644 --- a/components/common/vendor/vendor-service.ts +++ b/components/common/vendor/vendor-service.ts @@ -9,6 +9,7 @@ export interface VendorSearchItem { id: number vendorName: string vendorCode: string | null + taxId: string | null // 사업자번호 status: string displayText: string // vendorName + vendorCode로 구성된 표시용 텍스트 } @@ -100,6 +101,7 @@ export async function searchVendorsForSelector( id: vendors.id, vendorName: vendors.vendorName, vendorCode: vendors.vendorCode, + taxId: vendors.taxId, status: vendors.status, }) .from(vendors) @@ -169,6 +171,7 @@ export async function getAllVendors(): Promise<{ id: vendors.id, vendorName: vendors.vendorName, vendorCode: vendors.vendorCode, + taxId: vendors.taxId, status: vendors.status, }) .from(vendors) @@ -209,6 +212,7 @@ export async function getVendorById(vendorId: number): Promise<VendorSearchItem id: vendors.id, vendorName: vendors.vendorName, vendorCode: vendors.vendorCode, + taxId: vendors.taxId, status: vendors.status, }) .from(vendors) diff --git a/lib/avl/avl-itb-rfq-service.ts b/lib/avl/avl-itb-rfq-service.ts deleted file mode 100644 index f7662c2e..00000000 --- a/lib/avl/avl-itb-rfq-service.ts +++ /dev/null @@ -1,289 +0,0 @@ -// AVL 기반 RFQ/ITB 생성 서비스 -'use server' - -import { getServerSession } from 'next-auth/next' -import { authOptions } from '@/app/api/auth/[...nextauth]/route' -import db from '@/db/db' -import { users, rfqsLast, rfqPrItems } from '@/db/schema' -import { eq, desc, sql, and } from 'drizzle-orm' -import type { AvlDetailItem } from './types' - -// RFQ/ITB 코드 생성 헬퍼 함수 -async function generateAvlRfqItbCode(userCode: string, type: 'RFQ' | 'ITB'): Promise<string> { - try { - // 동일한 userCode를 가진 마지막 RFQ/ITB 번호 조회 - const lastRfq = await db - .select({ rfqCode: rfqsLast.rfqCode }) - .from(rfqsLast) - .where( - and( - eq(rfqsLast.picCode, userCode), - type === 'RFQ' - ? sql`${rfqsLast.prNumber} IS NOT NULL AND ${rfqsLast.prNumber} != ''` - : sql`${rfqsLast.projectCompany} IS NOT NULL AND ${rfqsLast.projectCompany} != ''` - ) - ) - .orderBy(desc(rfqsLast.createdAt)) - .limit(1); - - let nextNumber = 1; - - if (lastRfq.length > 0 && lastRfq[0].rfqCode) { - // 마지막 코드에서 숫자 부분 추출 (ex: "RFQ001001" -> "001") - const codeMatch = lastRfq[0].rfqCode.match(/([A-Z]{3})(\d{3})(\d{3})/); - if (codeMatch) { - const currentNumber = parseInt(codeMatch[3]); - nextNumber = currentNumber + 1; - } - } - - // 코드 형식: RFQ/ITB + userCode(3자리) + 일련번호(3자리) - const prefix = type === 'RFQ' ? 'RFQ' : 'ITB'; - const paddedNumber = nextNumber.toString().padStart(3, '0'); - - return `${prefix}${userCode}${paddedNumber}`; - } catch (error) { - console.error('RFQ/ITB 코드 생성 오류:', error); - // 오류 발생 시 기본 코드 생성 - const prefix = type === 'RFQ' ? 'RFQ' : 'ITB'; - return `${prefix}${userCode}001`; - } -} - -// AVL 기반 RFQ/ITB 생성을 위한 입력 타입 -export interface CreateAvlRfqItbInput { - // AVL 정보 - avlItems: AvlDetailItem[] - businessType: '조선' | '해양' // 조선: RFQ, 해양: ITB - - // RFQ/ITB 공통 정보 - rfqTitle: string - dueDate: Date - remark?: string - - // 담당자 정보 - picUserId: number - - // 추가 정보 (ITB용) - projectCompany?: string - projectFlag?: string - projectSite?: string - smCode?: string - - // PR 정보 (RFQ용) - prNumber?: string - prIssueDate?: Date - series?: string -} - -// RFQ/ITB 생성 결과 타입 -export interface CreateAvlRfqItbResult { - success: boolean - message?: string - data?: { - id: number - rfqCode: string - type: 'RFQ' | 'ITB' - } - error?: string -} - -/** - * AVL 기반 RFQ/ITB 생성 서비스 - * - 조선 사업: RFQ 생성 - * - 해양 사업: ITB 생성 - * - rfqLast 테이블에 직접 데이터 삽입 - */ -export async function createAvlRfqItbAction(input: CreateAvlRfqItbInput): Promise<CreateAvlRfqItbResult> { - try { - // 세션 확인 - const session = await getServerSession(authOptions) - if (!session?.user?.id) { - return { - success: false, - error: '로그인이 필요합니다.' - } - } - - // 입력 검증 - if (!input.avlItems || input.avlItems.length === 0) { - return { - success: false, - error: '견적 요청할 AVL 아이템이 없습니다.' - } - } - - if (!input.businessType || !['조선', '해양'].includes(input.businessType)) { - return { - success: false, - error: '올바른 사업 유형을 선택해주세요.' - } - } - - // 담당자 정보 확인 - const picUser = await db - .select({ - id: users.id, - name: users.name, - userCode: users.userCode - }) - .from(users) - .where(eq(users.id, input.picUserId)) - .limit(1) - - if (!picUser || picUser.length === 0) { - return { - success: false, - error: '담당자를 찾을 수 없습니다.' - } - } - - const userCode = picUser[0].userCode; - if (!userCode || userCode.length !== 3) { - return { - success: false, - error: '담당자의 userCode가 올바르지 않습니다 (3자리 필요)' - } - } - - // 사업 유형에 따른 RFQ/ITB 구분 및 데이터 준비 - const rfqType = input.businessType === '조선' ? 'RFQ' : 'ITB' - const rfqTypeLabel = rfqType - - // RFQ/ITB 코드 생성 - const rfqCode = await generateAvlRfqItbCode(userCode, rfqType) - - // 대표 아이템 정보 추출 (첫 번째 아이템) - const representativeItem = input.avlItems[0] - - // 트랜잭션으로 RFQ/ITB 생성 - const result = await db.transaction(async (tx) => { - // 1. rfqsLast 테이블에 기본 정보 삽입 - const [newRfq] = await tx - .insert(rfqsLast) - .values({ - rfqCode, - status: "RFQ 생성", - dueDate: input.dueDate, - - // 대표 아이템 정보 - itemCode: representativeItem.materialGroupCode || `AVL-${representativeItem.id}`, - itemName: representativeItem.materialNameCustomerSide || representativeItem.materialGroupName || 'AVL 아이템', - - // 담당자 정보 - pic: input.picUserId, - picCode: userCode, - picName: picUser[0].name || '', - - // 기타 정보 - remark: input.remark || null, - createdBy: Number(session.user.id), - updatedBy: Number(session.user.id), - createdAt: new Date(), - updatedAt: new Date(), - - // 사업 유형별 추가 필드 - ...(input.businessType === '조선' && { - // RFQ 필드 - prNumber: input.prNumber || rfqCode, // PR 번호가 없으면 RFQ 코드 사용 - prIssueDate: input.prIssueDate || new Date(), - series: input.series || null - }), - - ...(input.businessType === '해양' && { - // ITB 필드 - projectCompany: input.projectCompany || 'AVL 기반 프로젝트', - projectFlag: input.projectFlag || null, - projectSite: input.projectSite || null, - smCode: input.smCode || null - }) - }) - .returning() - - // 2. rfqPrItems 테이블에 AVL 아이템들 삽입 - const prItemsData = input.avlItems.map((item, index) => ({ - rfqsLastId: newRfq.id, - rfqItem: `${index + 1}`.padStart(3, '0'), // 001, 002, ... - prItem: `${index + 1}`.padStart(3, '0'), - prNo: rfqCode, - - materialCode: item.materialGroupCode || `AVL-${item.id}`, - materialDescription: item.materialNameCustomerSide || item.materialGroupName || `AVL 아이템 ${index + 1}`, - materialCategory: item.materialGroupCode || null, - - quantity: 1, // AVL에서는 수량 정보가 없으므로 1로 설정 - uom: 'EA', // 기본 단위 - - majorYn: index === 0, // 첫 번째 아이템을 주요 아이템으로 설정 - - deliveryDate: input.dueDate, // 납기일은 RFQ 마감일과 동일하게 설정 - })) - - await tx.insert(rfqPrItems).values(prItemsData) - - return newRfq - }) - - // 성공 결과 반환 - return { - success: true, - message: `${rfqTypeLabel}가 성공적으로 생성되었습니다.`, - data: { - id: result.id, - rfqCode: result.rfqCode!, - type: rfqTypeLabel as 'RFQ' | 'ITB' - } - } - - } catch (error) { - console.error('AVL RFQ/ITB 생성 오류:', error) - - if (error instanceof Error) { - return { - success: false, - error: error.message - } - } - - return { - success: false, - error: '알 수 없는 오류가 발생했습니다.' - } - } -} - -/** - * AVL 데이터에서 RFQ/ITB 생성을 위한 기본값 설정 헬퍼 함수 - */ -export async function prepareAvlRfqItbInput( - selectedItems: AvlDetailItem[], - businessType: '조선' | '해양', - defaultValues?: Partial<CreateAvlRfqItbInput> -): Promise<CreateAvlRfqItbInput> { - const now = new Date() - const dueDate = new Date(now.getTime() + (30 * 24 * 60 * 60 * 1000)) // 30일 후 - - // 선택된 아이템들의 대표 정보를 추출하여 제목 생성 - const representativeItem = selectedItems[0] - const itemCount = selectedItems.length - const titleSuffix = itemCount > 1 ? ` 외 ${itemCount - 1}건` : '' - const defaultTitle = `${representativeItem?.materialNameCustomerSide || 'AVL 자재'}${titleSuffix}` - - return { - avlItems: selectedItems, - businessType, - rfqTitle: defaultValues?.rfqTitle || `${businessType} - ${defaultTitle}`, - dueDate: defaultValues?.dueDate || dueDate, - remark: defaultValues?.remark || `AVL 기반 ${businessType} 견적 요청`, - picUserId: defaultValues?.picUserId || 0, // 호출 측에서 설정 필요 - // ITB용 필드들 - projectCompany: defaultValues?.projectCompany, - projectFlag: defaultValues?.projectFlag, - projectSite: defaultValues?.projectSite, - smCode: defaultValues?.smCode, - // RFQ용 필드들 - prNumber: defaultValues?.prNumber, - prIssueDate: defaultValues?.prIssueDate, - series: defaultValues?.series - } -} diff --git a/lib/avl/service.ts b/lib/avl/service.ts index 0340f52c..1f781486 100644 --- a/lib/avl/service.ts +++ b/lib/avl/service.ts @@ -438,14 +438,8 @@ export async function handleAvlAction( ): Promise<ActionResult> { try { switch (action) { - case "new-registration": - return { success: true, message: "신규 AVL 등록 모드" }; - - case "standard-registration": - return { success: true, message: "표준 AVL 등재 모드" }; - - case "project-registration": - return { success: true, message: "프로젝트 AVL 등재 모드" }; + case "avl-registration": + return { success: true, message: "AVL 등록 패널을 활성화했습니다." }; case "bulk-import": if (!data?.file) { diff --git a/lib/avl/table/avl-detail-table.tsx b/lib/avl/table/avl-detail-table.tsx index 4408340a..22c503ff 100644 --- a/lib/avl/table/avl-detail-table.tsx +++ b/lib/avl/table/avl-detail-table.tsx @@ -6,12 +6,10 @@ import { useDataTable } from "@/hooks/use-data-table" import { DataTable } from "@/components/data-table/data-table" import { Button } from "@/components/ui/button" import { toast } from "sonner" -import { createAvlRfqItbAction, prepareAvlRfqItbInput } from "../avl-itb-rfq-service" import { columns } from "./columns-detail" import type { AvlDetailItem } from "../types" import { BackButton } from "@/components/ui/back-button" -import { useSession } from "next-auth/react" interface AvlDetailTableProps { data: AvlDetailItem[] @@ -33,63 +31,11 @@ export function AvlDetailTable({ projectInfo, businessType, }: AvlDetailTableProps) { - // 견적요청 처리 상태 관리 - const [isProcessingQuote, setIsProcessingQuote] = React.useState(false) - const { data: session } = useSession() - - // 견적요청 처리 함수 - const handleQuoteRequest = React.useCallback(async () => { - if (!businessType || !['조선', '해양'].includes(businessType)) { - toast.error("공사구분이 올바르지 않습니다. 견적요청 처리 불가.") - return - } - if (data.length === 0) { - toast.error("견적요청할 AVL 데이터가 없습니다.") - return - } - - setIsProcessingQuote(true) - - try { - // 현재 사용자 세션에서 ID 가져오기 - const currentUserId = session?.user?.id ? Number(session.user.id) : undefined - - // 견적요청 입력 데이터 준비 (전체 데이터를 사용) - const quoteInput = await prepareAvlRfqItbInput( - data, // 전체 데이터를 사용 - businessType as '조선' | '해양', - { - picUserId: currentUserId, - rfqTitle: `${businessType} AVL 견적요청 - ${data[0]?.materialNameCustomerSide || 'AVL 아이템'}${data.length > 1 ? ` 외 ${data.length - 1}건` : ''}` - } - ) - - // 견적요청 실행 - const result = await createAvlRfqItbAction(quoteInput) - - if (result.success) { - toast.success(`${result.data?.type}가 성공적으로 생성되었습니다. (코드: ${result.data?.rfqCode})`) - } else { - toast.error(result.error || "견적요청 처리 중 오류가 발생했습니다.") - } - - } catch (error) { - console.error('견적요청 처리 오류:', error) - toast.error("견적요청 처리 중 오류가 발생했습니다.") - } finally { - setIsProcessingQuote(false) - } - }, [businessType, data, session?.user?.id]) // 액션 핸들러 const handleAction = React.useCallback(async (action: string) => { switch (action) { - - case 'quote-request': - await handleQuoteRequest() - break - case 'vendor-pool': window.open('/evcp/vendor-pool', '_blank') break @@ -107,7 +53,7 @@ export function AvlDetailTable({ default: toast.error(`알 수 없는 액션: ${action}`) } - }, [handleQuoteRequest]) + }, []) // 테이블 메타 설정 @@ -153,24 +99,10 @@ export function AvlDetailTable({ {/* 상단 버튼 영역 */} <div className="flex items-center gap-2 ml-auto justify-end"> - { - // 표준AVL로는 견적요청하지 않으며, 프로젝트 AVL로만 견적요청처리 - avlType === '프로젝트AVL' && businessType && ['조선', '해양'].includes(businessType) && - <Button - variant="outline" - size="sm" - onClick={() => handleAction('quote-request')} - disabled={data.length === 0 || isProcessingQuote} - > - {isProcessingQuote ? '처리중...' : `${businessType} 견적요청 (${businessType === '조선' ? 'RFQ' : 'ITB'})`} - </Button> - } - {/* 단순 이동 버튼 */} <Button variant="outline" size="sm" onClick={() => handleAction('vendor-pool')}> Vendor Pool </Button> - </div> {/* 데이터 테이블 */} diff --git a/lib/avl/table/avl-registration-area.tsx b/lib/avl/table/avl-registration-area.tsx index ba1c76d4..6c7eba9d 100644 --- a/lib/avl/table/avl-registration-area.tsx +++ b/lib/avl/table/avl-registration-area.tsx @@ -15,16 +15,6 @@ import { toast } from "sonner" // 선택된 테이블 타입 type SelectedTable = 'project' | 'standard' | 'vendor' | null -// TODO: 나머지 테이블들도 ref 지원 추가 시 인터페이스 추가 필요 -// interface StandardAvlTableRef { -// getSelectedIds?: () => number[] -// } -// -// interface VendorPoolTableRef { -// getSelectedIds?: () => number[] -// } - - // 선택 상태 액션 타입 type SelectionAction = | { type: 'SELECT_PROJECT'; count: number } diff --git a/lib/avl/table/avl-table-columns.tsx b/lib/avl/table/avl-table-columns.tsx index 6ec2c3db..06005d3d 100644 --- a/lib/avl/table/avl-table-columns.tsx +++ b/lib/avl/table/avl-table-columns.tsx @@ -45,9 +45,7 @@ export function getColumns({ selectedRows = [], onRowSelect }: GetColumnsProps): enableSorting: false, enableHiding: false, enableResizing: false, - size: 10, - minSize: 10, - maxSize: 10, + size: 50, }, // No 컬럼 { @@ -57,7 +55,7 @@ export function getColumns({ selectedRows = [], onRowSelect }: GetColumnsProps): ), cell: ({ getValue }) => <div className="text-center">{getValue() as number}</div>, enableResizing: true, - size: 60, + size: 100, }, // AVL 분류 컬럼 { @@ -148,7 +146,7 @@ export function getColumns({ selectedRows = [], onRowSelect }: GetColumnsProps): { accessorKey: "htDivision", header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="H/T 구분" /> + <DataTableColumnHeaderSimple column={column} title="H/T" /> ), cell: ({ getValue }) => { const value = getValue() as string @@ -205,7 +203,7 @@ export function getColumns({ selectedRows = [], onRowSelect }: GetColumnsProps): <DataTableColumnHeaderSimple column={column} title="자재그룹" /> ), enableResizing: true, - size: 120, + size: 100, }, // 협력업체 컬럼 { @@ -214,7 +212,7 @@ export function getColumns({ selectedRows = [], onRowSelect }: GetColumnsProps): <DataTableColumnHeaderSimple column={column} title="협력업체" /> ), enableResizing: true, - size: 150, + size: 100, }, // Tier 컬럼 { @@ -280,7 +278,7 @@ export function getColumns({ selectedRows = [], onRowSelect }: GetColumnsProps): return <div className="text-center text-sm">{date}</div> }, enableResizing: true, - size: 100, + size: 120, }, // 최종변경자 컬럼 { @@ -293,7 +291,7 @@ export function getColumns({ selectedRows = [], onRowSelect }: GetColumnsProps): return <div className="text-center text-sm">{value}</div> }, enableResizing: true, - size: 100, + size: 120, }, // 액션 컬럼 { diff --git a/lib/avl/table/avl-table.tsx b/lib/avl/table/avl-table.tsx index 61db658d..9b6ac90b 100644 --- a/lib/avl/table/avl-table.tsx +++ b/lib/avl/table/avl-table.tsx @@ -100,9 +100,9 @@ export function AvlTable({ data, pageCount, isLoading, onRegistrationModeChange, const handleAction = React.useCallback(async (action: string, data?: Partial<AvlListItem>) => { try { switch (action) { - case 'standard-registration': - // 표준 AVL 등록 - const result = await handleAvlActionAction('standard-registration') + case 'avl-registration': + // AVL 등록 (통합된 기능) + const result = await handleAvlActionAction('avl-registration') if (result.success) { toast.success(result.message) onRegistrationModeChange?.('standard') // 등록 모드 변경 콜백 호출 @@ -112,9 +112,10 @@ export function AvlTable({ data, pageCount, isLoading, onRegistrationModeChange, break case 'view-detail': - // 상세 조회 (페이지 이동) + // 상세 조회 (페이지 이동) - 원래 방식으로 복원 if (data?.id && !String(data.id).startsWith('temp-')) { - window.location.href = `/evcp/avl/${data.id}` + console.log('AVL 상세보기 이동:', data.id) // 디버깅용 + window.location.href = `/ko/evcp/avl/${data.id}` } break @@ -177,6 +178,9 @@ export function AvlTable({ data, pageCount, isLoading, onRegistrationModeChange, columnSizing: {}, }, getRowId: (row) => String(row.id), + meta: { + onAction: handleAction, + }, }) return ( @@ -191,17 +195,9 @@ export function AvlTable({ data, pageCount, isLoading, onRegistrationModeChange, <Button variant="outline" size="sm" - onClick={() => handleAction('standard-registration')} - > - 표준AVL등록 - </Button> - - <Button - variant="outline" - size="sm" - onClick={() => handleAction('project-registration')} + onClick={() => handleAction('avl-registration')} > - 프로젝트AVL등록 + AVL 등록 </Button> </div> diff --git a/lib/avl/table/project-avl-table.tsx b/lib/avl/table/project-avl-table.tsx index 9584c6f9..ad72b221 100644 --- a/lib/avl/table/project-avl-table.tsx +++ b/lib/avl/table/project-avl-table.tsx @@ -177,12 +177,40 @@ export const ProjectAvlTable = forwardRef<ProjectAvlTableRef, ProjectAvlTablePro const projectData = await getProjectInfoByCode(projectCode.trim(), 'both') if (projectData) { + // constructionSector 동적 결정 + let constructionSector = "해양" // 기본값 + if (projectData.source === 'projects') { + // projects 테이블의 경우: type='ship'이면 '조선', 그 외는 '해양' + constructionSector = projectData.type === 'ship' ? "조선" : "해양" + } else if (projectData.source === 'biddingProjects') { + // biddingProjects 테이블의 경우: sector가 'S'이면 '조선', 'M'이면 '해양' + constructionSector = projectData.sector === 'S' ? "조선" : "해양" + } + + // htDivision 동적 결정 + let htDivision = "" // 기본값 + if (constructionSector === "조선") { + // constructionSector가 '조선'인 경우는 항상 H + htDivision = "H" + } else if (projectData.source === 'projects') { + // projects에서는 TYPE_MDG 컬럼이 Top이면 T, Hull이면 H + htDivision = projectData.typeMdg === 'Top' ? "T" : "H" + } else if (projectData.source === 'biddingProjects') { + if (projectData.sector === 'S') { + // biddingProjects에서 sector가 S이면 HtDivision은 항상 H + htDivision = "H" + } else if (projectData.sector === 'M') { + // biddingProjects에서 sector가 M인 경우: pjtType이 TOP이면 'T', HULL이면 'H' + htDivision = projectData.pjtType === 'TOP' ? "T" : "H" + } + } + // 프로젝트 정보 설정 setProjectInfo({ projectName: projectData.projectName || "", - constructionSector: "조선", // 기본값으로 조선 설정 (필요시 로직 변경) + constructionSector: constructionSector, shipType: projectData.shipType || projectData.projectMsrm || "", - htDivision: projectData.projectHtDivision || "" + htDivision: htDivision }) // 검색 상태 설정 @@ -307,6 +335,7 @@ export const ProjectAvlTable = forwardRef<ProjectAvlTableRef, ProjectAvlTablePro getFilteredRowModel: getFilteredRowModel(), manualPagination: true, pageCount, + columnResizeMode: "onChange", state: { pagination, }, diff --git a/lib/avl/table/standard-avl-table.tsx b/lib/avl/table/standard-avl-table.tsx index bacb5812..06fa6931 100644 --- a/lib/avl/table/standard-avl-table.tsx +++ b/lib/avl/table/standard-avl-table.tsx @@ -123,6 +123,7 @@ export const StandardAvlTable = forwardRef<StandardAvlTableRef, StandardAvlTable getFilteredRowModel: getFilteredRowModel(), manualPagination: true, pageCount, + columnResizeMode: "onChange", state: { pagination, }, @@ -150,11 +151,8 @@ export const StandardAvlTable = forwardRef<StandardAvlTableRef, StandardAvlTable }, }) - // 공사부문 변경 시 선종 초기화 const handleConstructionSectorChange = React.useCallback((value: string) => { setSearchConstructionSector(value) - // 공사부문이 변경되면 선종을 빈 값으로 초기화 - setSelectedShipType(undefined) }, []) // 검색 상태 변경 시 부모 컴포넌트에 전달 @@ -506,7 +504,7 @@ export const StandardAvlTable = forwardRef<StandardAvlTableRef, StandardAvlTable <div className="space-y-2"> <label className="text-sm font-medium">공사부문</label> <Select value={searchConstructionSector} onValueChange={handleConstructionSectorChange}> - <SelectTrigger> + <SelectTrigger className="bg-background"> <SelectValue /> </SelectTrigger> <SelectContent> @@ -526,7 +524,6 @@ export const StandardAvlTable = forwardRef<StandardAvlTableRef, StandardAvlTable selectedShipType={selectedShipType} onShipTypeSelect={setSelectedShipType} placeholder="선종을 선택하세요" - disabled={!searchConstructionSector} className="h-10" /> </div> @@ -535,7 +532,7 @@ export const StandardAvlTable = forwardRef<StandardAvlTableRef, StandardAvlTable <div className="space-y-2"> <label className="text-sm font-medium">AVL종류</label> <Select value={searchAvlKind} onValueChange={setSearchAvlKind}> - <SelectTrigger> + <SelectTrigger className="bg-background"> <SelectValue /> </SelectTrigger> <SelectContent> @@ -552,7 +549,7 @@ export const StandardAvlTable = forwardRef<StandardAvlTableRef, StandardAvlTable <div className="space-y-2"> <label className="text-sm font-medium">H/T 구분</label> <Select value={searchHtDivision} onValueChange={setSearchHtDivision}> - <SelectTrigger> + <SelectTrigger className="bg-background"> <SelectValue /> </SelectTrigger> <SelectContent> diff --git a/lib/avl/table/vendor-pool-table.tsx b/lib/avl/table/vendor-pool-table.tsx index 7ad9eb56..3ac67928 100644 --- a/lib/avl/table/vendor-pool-table.tsx +++ b/lib/avl/table/vendor-pool-table.tsx @@ -174,6 +174,7 @@ export const VendorPoolTable = forwardRef<VendorPoolTableRef, VendorPoolTablePro getFilteredRowModel: getFilteredRowModel(), manualPagination: !showAll, // 전체보기 시에는 수동 페이징 비활성화 pageCount: showAll ? 1 : pageCount, // 전체보기 시 1페이지, 일반 모드에서는 API에서 받은 pageCount 사용 + columnResizeMode: "onChange", state: { pagination: showAll ? { pageIndex: 0, pageSize: data.length } : pagination, }, @@ -262,7 +263,7 @@ export const VendorPoolTable = forwardRef<VendorPoolTableRef, VendorPoolTablePro placeholder="설계공종, 업체명, 자재그룹 등으로 검색..." value={searchText} onChange={(e) => setSearchText(e.target.value)} - className="flex-1" + className="flex-1 bg-background" onKeyPress={(e) => { if (e.key === 'Enter') { handleSearch() diff --git a/lib/vendor-pool/excel-utils.ts b/lib/vendor-pool/excel-utils.ts new file mode 100644 index 00000000..8dd8743c --- /dev/null +++ b/lib/vendor-pool/excel-utils.ts @@ -0,0 +1,310 @@ +"use client" + +import ExcelJS from 'exceljs' +import { saveAs } from 'file-saver' +import type { VendorPoolItem } from './table/vendor-pool-table-columns' + +// Excel 컬럼 정의 +export interface ExcelColumnConfig { + accessorKey: string + header: string + width?: number + type?: 'text' | 'number' | 'boolean' | 'date' +} + +// vendor-pool Excel 컬럼 매핑 +export const vendorPoolExcelColumns: ExcelColumnConfig[] = [ + { accessorKey: 'constructionSector', header: '조선/해양', width: 15 }, + { accessorKey: 'htDivision', header: 'H/T구분', width: 15 }, + { accessorKey: 'designCategoryCode', header: '설계기능코드', width: 20 }, + { accessorKey: 'designCategory', header: '설계기능(공종)', width: 25 }, + { accessorKey: 'equipBulkDivision', header: 'Equip/Bulk', width: 15 }, + { accessorKey: 'packageCode', header: '패키지코드', width: 20 }, + { accessorKey: 'packageName', header: '패키지명', width: 25 }, + { accessorKey: 'materialGroupCode', header: '자재그룹코드', width: 20 }, + { accessorKey: 'materialGroupName', header: '자재그룹명', width: 30 }, + { accessorKey: 'smCode', header: 'SM Code', width: 15 }, + { accessorKey: 'similarMaterialNamePurchase', header: '유사자재명(구매)', width: 25 }, + { accessorKey: 'similarMaterialNameOther', header: '유사자재명(기타)', width: 25 }, + { accessorKey: 'vendorCode', header: '협력업체코드', width: 20 }, + { accessorKey: 'vendorName', header: '협력업체명', width: 25 }, + { accessorKey: 'taxId', header: '사업자번호', width: 20 }, + { accessorKey: 'faTarget', header: 'FA대상', width: 15, type: 'boolean' }, + { accessorKey: 'faStatus', header: 'FA현황', width: 15 }, + { accessorKey: 'tier', header: '등급', width: 15 }, + { accessorKey: 'isAgent', header: 'Agent여부', width: 15, type: 'boolean' }, + { accessorKey: 'contractSignerCode', header: '계약서명주체코드', width: 20 }, + { accessorKey: 'contractSignerName', header: '계약서명주체명', width: 25 }, + { accessorKey: 'headquarterLocation', header: '본사위치', width: 20 }, + { accessorKey: 'manufacturingLocation', header: '제작/선적지', width: 20 }, + { accessorKey: 'avlVendorName', header: 'AVL등재업체명', width: 25 }, + { accessorKey: 'similarVendorName', header: '유사업체명', width: 25 }, + { accessorKey: 'hasAvl', header: 'AVL보유', width: 15, type: 'boolean' }, + { accessorKey: 'isBlacklist', header: '블랙리스트', width: 15, type: 'boolean' }, + { accessorKey: 'isBcc', header: 'BCC', width: 15, type: 'boolean' }, + { accessorKey: 'purchaseOpinion', header: '구매의견', width: 30 }, + // 선종 + { accessorKey: 'shipTypeCommon', header: '선종공통', width: 15, type: 'boolean' }, + { accessorKey: 'shipTypeAmax', header: 'A-MAX', width: 15, type: 'boolean' }, + { accessorKey: 'shipTypeSmax', header: 'S-MAX', width: 15, type: 'boolean' }, + { accessorKey: 'shipTypeVlcc', header: 'VLCC', width: 15, type: 'boolean' }, + { accessorKey: 'shipTypeLngc', header: 'LNGC', width: 15, type: 'boolean' }, + { accessorKey: 'shipTypeCont', header: '컨테이너선', width: 15, type: 'boolean' }, + // 해양플랜트 + { accessorKey: 'offshoreTypeCommon', header: '해양플랜트공통', width: 20, type: 'boolean' }, + { accessorKey: 'offshoreTypeFpso', header: 'FPSO', width: 15, type: 'boolean' }, + { accessorKey: 'offshoreTypeFlng', header: 'FLNG', width: 15, type: 'boolean' }, + { accessorKey: 'offshoreTypeFpu', header: 'FPU', width: 15, type: 'boolean' }, + { accessorKey: 'offshoreTypePlatform', header: '플랫폼', width: 15, type: 'boolean' }, + { accessorKey: 'offshoreTypeWtiv', header: 'WTIV', width: 15, type: 'boolean' }, + { accessorKey: 'offshoreTypeGom', header: 'GOM', width: 15, type: 'boolean' }, + // 담당자 정보 + { accessorKey: 'picName', header: '담당자명', width: 20 }, + { accessorKey: 'picEmail', header: '담당자이메일', width: 30 }, + { accessorKey: 'picPhone', header: '담당자연락처', width: 20 }, + // 대행사 정보 + { accessorKey: 'agentName', header: '대행사명', width: 20 }, + { accessorKey: 'agentEmail', header: '대행사이메일', width: 30 }, + { accessorKey: 'agentPhone', header: '대행사연락처', width: 20 }, + // 최근 거래 정보 + { accessorKey: 'recentQuoteDate', header: '최근견적일', width: 20 }, + { accessorKey: 'recentQuoteNumber', header: '최근견적번호', width: 25 }, + { accessorKey: 'recentOrderDate', header: '최근발주일', width: 20 }, + { accessorKey: 'recentOrderNumber', header: '최근발주번호', width: 25 }, +] + +// 값 변환 헬퍼 함수 +const formatCellValue = (value: unknown, type?: string): string => { + if (value === null || value === undefined) return '' + + switch (type) { + case 'boolean': + return value ? 'TRUE' : 'FALSE' + case 'date': + if (value instanceof Date) { + return value.toISOString().split('T')[0] + } + return String(value) + default: + return String(value) + } +} + +// Excel 템플릿 생성 +export async function createVendorPoolTemplate(filename?: string) { + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet('VendorPool Template') + + // 1. 안내 텍스트 추가 (첫 번째 행) + const instructionText = '벤더풀 데이터 입력 템플릿 - 입력 방법은 "입력 가이드" 시트를 참조하세요' + worksheet.getCell(1, 1).value = instructionText + worksheet.getCell(1, 1).font = { bold: true, color: { argb: 'FF0066CC' } } + worksheet.getCell(1, 1).fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFFFCC00' } + } + // 첫 번째 행을 여러 컬럼에 걸쳐 병합 + worksheet.mergeCells(1, 1, 1, Math.min(vendorPoolExcelColumns.length, 10)) + + // 2. 헤더 행 추가 (두 번째 행) + const headerRow = worksheet.addRow(vendorPoolExcelColumns.map(col => col.header)) + headerRow.font = { bold: true } + headerRow.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE0E0E0' } + } + + // 3. 컬럼 너비 설정 + vendorPoolExcelColumns.forEach((col, index) => { + const column = worksheet.getColumn(index + 1) + column.width = col.width || 15 + + // 헤더 셀 스타일 설정 (두 번째 행) + const headerCell = worksheet.getCell(2, index + 1) + headerCell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + } + headerCell.alignment = { vertical: 'middle', horizontal: 'center' } + }) + + // 3. 데이터 입력을 위한 빈 행 한 개 추가 (사용자가 바로 입력할 수 있도록) + worksheet.addRow(vendorPoolExcelColumns.map(() => '')) + + // 5. 가이드 워크시트 추가 + const guideWorksheet = workbook.addWorksheet('입력 가이드') + + // 가이드 제목 + guideWorksheet.getCell(1, 1).value = '벤더풀 데이터 입력 가이드' + guideWorksheet.getCell(1, 1).font = { bold: true, size: 16, color: { argb: 'FF0066CC' } } + + // 가이드 내용 + const guideContent = [ + '', + '■ 필수 입력 필드 (빨간색 * 표시)', + ' - 조선/해양: "조선" 또는 "해양"', + ' - H/T구분: "H", "T", 또는 "공통"', + ' - 설계기능코드: 2자리 이하 영문코드 (예: EL, ME)', + ' - 설계기능(공종): 설계기능 한글명 (예: 전장, 기관)', + ' - Equip/Bulk: "E" 또는 "B" (1자리)', + ' - 협력업체명: 업체 한글명', + ' - 자재그룹코드/명: 해당 자재그룹 정보', + ' - 등급: "Tier 1", "Tier 2", "Tier 3" 등', + ' - 계약서명주체명: 계약 주체 업체명', + ' - 본사위치/제작선적지: 국가명', + ' - AVL등재업체명: AVL에 등재된 업체명', + '', + '■ Boolean (참/거짓) 필드 입력법', + ' - TRUE, true, 1, Y, y, O, o → 참', + ' - FALSE, false, 0, N, n → 거짓', + ' - 빈 값은 기본적으로 거짓(false)으로 처리', + '', + '■ 주의사항', + ' - 첫 번째 행의 안내 텍스트는 삭제하지 마세요', + ' - 헤더 행(2번째 행)은 수정하지 마세요', + ' - 데이터는 3번째 행부터 입력하세요', + '', + '■ 문제 해결', + ' - 필드 길이 초과 오류: 해당 필드의 글자 수를 확인하세요', + ' - 필수 필드 누락: 빨간색 * 표시 필드를 모두 입력했는지 확인하세요', + ' - Boolean 값 오류: TRUE/FALSE 형태로 입력했는지 확인하세요' + ] + + guideContent.forEach((content, index) => { + const cell = guideWorksheet.getCell(index + 2, 1) + cell.value = content + if (content.startsWith('■')) { + cell.font = { bold: true, color: { argb: 'FF333333' } } + } else if (content.startsWith(' -')) { + cell.font = { color: { argb: 'FF666666' } } + } + }) + + // 가이드 워크시트 컬럼 너비 설정 + guideWorksheet.getColumn(1).width = 80 + + // 파일 저장 + const buffer = await workbook.xlsx.writeBuffer() + const blob = new Blob([buffer], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + }) + + const defaultFilename = `vendor-pool-template-${new Date().toISOString().split('T')[0]}.xlsx` + saveAs(blob, filename || defaultFilename) +} + +// Excel 데이터 내보내기 +export async function exportVendorPoolToExcel( + data: VendorPoolItem[], + filename?: string, + includeIds: boolean = true +) { + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet('VendorPool Data') + + // ID 제외할 컬럼들 필터링 + const columnsToExport = includeIds + ? vendorPoolExcelColumns + : vendorPoolExcelColumns.filter(col => col.accessorKey !== 'id') + + // 헤더 행 추가 + const headerRow = worksheet.addRow(columnsToExport.map(col => col.header)) + headerRow.font = { bold: true } + headerRow.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE0E0E0' } + } + + // 데이터 행 추가 + data.forEach(item => { + const rowData = columnsToExport.map(col => { + const value = item[col.accessorKey as keyof VendorPoolItem] + return formatCellValue(value, col.type) + }) + worksheet.addRow(rowData) + }) + + // 컬럼 너비 및 스타일 설정 + columnsToExport.forEach((col, index) => { + const column = worksheet.getColumn(index + 1) + column.width = col.width || 15 + + // 헤더 셀 스타일 설정 + const headerCell = worksheet.getCell(1, index + 1) + headerCell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + } + headerCell.alignment = { vertical: 'middle', horizontal: 'center' } + }) + + // 데이터 셀 테두리 추가 + for (let row = 2; row <= worksheet.rowCount; row++) { + for (let col = 1; col <= columnsToExport.length; col++) { + const cell = worksheet.getCell(row, col) + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + } + } + } + + // 파일 저장 + const buffer = await workbook.xlsx.writeBuffer() + const blob = new Blob([buffer], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + }) + + const defaultFilename = `vendor-pool-${new Date().toISOString().split('T')[0]}.xlsx` + saveAs(blob, filename || defaultFilename) +} + +// Excel 컬럼 헤더로 accessorKey 찾기 +export function getAccessorKeyByHeader(header: string): string | undefined { + const column = vendorPoolExcelColumns.find(col => col.header === header) + return column?.accessorKey +} + +// accessorKey로 Excel 헤더 찾기 +export function getHeaderByAccessorKey(accessorKey: string): string | undefined { + const column = vendorPoolExcelColumns.find(col => col.accessorKey === accessorKey) + return column?.header +} + +// Boolean 값 파싱 +export function parseBoolean(value: string): boolean { + const lowerValue = value.toLowerCase().trim() + return lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes' || + lowerValue === 'o' || lowerValue === 'y' || lowerValue === '참' +} + +// Excel 셀 값을 문자열로 변환 +export function getCellValueAsString(cell: ExcelJS.Cell): string { + if (!cell || cell.value === undefined || cell.value === null) return ''; + + if (typeof cell.value === 'string') return cell.value.trim(); + if (typeof cell.value === 'number') return cell.value.toString(); + + // Handle rich text + if (typeof cell.value === 'object' && cell.value && 'richText' in cell.value) { + const richTextValue = cell.value as { richText: Array<{ text: string }> }; + return richTextValue.richText.map((rt) => rt.text).join(''); + } + + // Handle dates + if (cell.value instanceof Date) { + return cell.value.toISOString().split('T')[0]; + } + + // Fallback + return String(cell.value); +} diff --git a/lib/vendor-pool/service.ts b/lib/vendor-pool/service.ts index 1933c199..3d83e3aa 100644 --- a/lib/vendor-pool/service.ts +++ b/lib/vendor-pool/service.ts @@ -27,8 +27,10 @@ const _getVendorPools = async (input: GetVendorPoolSchema) => { whereConditions.push( or( ilike(vendorPool.constructionSector, searchTerm), + ilike(vendorPool.designCategoryCode, searchTerm), ilike(vendorPool.designCategory, searchTerm), ilike(vendorPool.vendorName, searchTerm), + ilike(vendorPool.materialGroupCode, searchTerm), ilike(vendorPool.materialGroupName, searchTerm), ilike(vendorPool.packageName, searchTerm), ilike(vendorPool.avlVendorName, searchTerm), @@ -37,7 +39,190 @@ const _getVendorPools = async (input: GetVendorPoolSchema) => { ); } - // 필터 조건 추가 + // Advanced filters 처리 (DataTableFilterList에서 생성된 필터들) + if (input.filters && input.filters.length > 0) { + const filterConditions: any[] = []; + + for (const filter of input.filters) { + let condition: any = null; + + switch (filter.id) { + case 'constructionSector': + if (filter.operator === 'eq') { + condition = eq(vendorPool.constructionSector, filter.value as string); + } else if (filter.operator === 'iLike') { + condition = ilike(vendorPool.constructionSector, `%${filter.value}%`); + } + break; + case 'htDivision': + if (filter.operator === 'eq') { + condition = eq(vendorPool.htDivision, filter.value as string); + } else if (filter.operator === 'iLike') { + condition = ilike(vendorPool.htDivision, `%${filter.value}%`); + } + break; + case 'designCategoryCode': + if (filter.operator === 'iLike') { + condition = ilike(vendorPool.designCategoryCode, `%${filter.value}%`); + } else if (filter.operator === 'eq') { + condition = eq(vendorPool.designCategoryCode, filter.value as string); + } + break; + case 'designCategory': + if (filter.operator === 'iLike') { + condition = ilike(vendorPool.designCategory, `%${filter.value}%`); + } else if (filter.operator === 'eq') { + condition = eq(vendorPool.designCategory, filter.value as string); + } + break; + case 'packageCode': + if (filter.operator === 'iLike') { + condition = ilike(vendorPool.packageCode, `%${filter.value}%`); + } else if (filter.operator === 'eq') { + condition = eq(vendorPool.packageCode, filter.value as string); + } + break; + case 'packageName': + if (filter.operator === 'iLike') { + condition = ilike(vendorPool.packageName, `%${filter.value}%`); + } else if (filter.operator === 'eq') { + condition = eq(vendorPool.packageName, filter.value as string); + } + break; + case 'materialGroupCode': + if (filter.operator === 'iLike') { + condition = ilike(vendorPool.materialGroupCode, `%${filter.value}%`); + } else if (filter.operator === 'eq') { + condition = eq(vendorPool.materialGroupCode, filter.value as string); + } + break; + case 'materialGroupName': + if (filter.operator === 'iLike') { + condition = ilike(vendorPool.materialGroupName, `%${filter.value}%`); + } else if (filter.operator === 'eq') { + condition = eq(vendorPool.materialGroupName, filter.value as string); + } + break; + case 'vendorCode': + if (filter.operator === 'iLike') { + condition = ilike(vendorPool.vendorCode, `%${filter.value}%`); + } else if (filter.operator === 'eq') { + condition = eq(vendorPool.vendorCode, filter.value as string); + } + break; + case 'vendorName': + if (filter.operator === 'iLike') { + condition = ilike(vendorPool.vendorName, `%${filter.value}%`); + } else if (filter.operator === 'eq') { + condition = eq(vendorPool.vendorName, filter.value as string); + } + break; + case 'taxId': + if (filter.operator === 'iLike') { + condition = ilike(vendorPool.taxId, `%${filter.value}%`); + } else if (filter.operator === 'eq') { + condition = eq(vendorPool.taxId, filter.value as string); + } + break; + case 'faStatus': + if (filter.operator === 'iLike') { + condition = ilike(vendorPool.faStatus, `%${filter.value}%`); + } else if (filter.operator === 'eq') { + condition = eq(vendorPool.faStatus, filter.value as string); + } + break; + case 'tier': + if (filter.operator === 'iLike') { + condition = ilike(vendorPool.tier, `%${filter.value}%`); + } else if (filter.operator === 'eq') { + condition = eq(vendorPool.tier, filter.value as string); + } + break; + case 'headquarterLocation': + if (filter.operator === 'iLike') { + condition = ilike(vendorPool.headquarterLocation, `%${filter.value}%`); + } else if (filter.operator === 'eq') { + condition = eq(vendorPool.headquarterLocation, filter.value as string); + } + break; + case 'manufacturingLocation': + if (filter.operator === 'iLike') { + condition = ilike(vendorPool.manufacturingLocation, `%${filter.value}%`); + } else if (filter.operator === 'eq') { + condition = eq(vendorPool.manufacturingLocation, filter.value as string); + } + break; + case 'avlVendorName': + if (filter.operator === 'iLike') { + condition = ilike(vendorPool.avlVendorName, `%${filter.value}%`); + } else if (filter.operator === 'eq') { + condition = eq(vendorPool.avlVendorName, filter.value as string); + } + break; + case 'registrant': + if (filter.operator === 'iLike') { + condition = ilike(vendorPool.registrant, `%${filter.value}%`); + } else if (filter.operator === 'eq') { + condition = eq(vendorPool.registrant, filter.value as string); + } + break; + case 'lastModifier': + if (filter.operator === 'iLike') { + condition = ilike(vendorPool.lastModifier, `%${filter.value}%`); + } else if (filter.operator === 'eq') { + condition = eq(vendorPool.lastModifier, filter.value as string); + } + break; + case 'hasAvl': + if (filter.operator === 'eq') { + condition = eq(vendorPool.hasAvl, filter.value === 'true'); + } + break; + case 'isAgent': + if (filter.operator === 'eq') { + condition = eq(vendorPool.isAgent, filter.value === 'true'); + } + break; + case 'isBlacklist': + if (filter.operator === 'eq') { + condition = eq(vendorPool.isBlacklist, filter.value === 'true'); + } + break; + case 'isBcc': + if (filter.operator === 'eq') { + condition = eq(vendorPool.isBcc, filter.value === 'true'); + } + break; + case 'registrationDate': + case 'lastModifiedDate': + // 날짜 필터 처리 (단순화된 버전) + if (filter.operator === 'eq' && filter.value) { + const dateValue = new Date(filter.value as string); + if (filter.id === 'registrationDate') { + condition = eq(vendorPool.registrationDate, dateValue); + } else { + condition = eq(vendorPool.lastModifiedDate, dateValue); + } + } + break; + } + + if (condition) { + filterConditions.push(condition); + } + } + + // 필터 조건들을 AND 또는 OR로 결합 + if (filterConditions.length > 0) { + if (input.joinOperator === 'or') { + whereConditions.push(or(...filterConditions)); + } else { + whereConditions.push(and(...filterConditions)); + } + } + } + + // 기존 필터 조건 추가 (하위 호환성 유지) if (input.constructionSector) { whereConditions.push(eq(vendorPool.constructionSector, input.constructionSector)); } diff --git a/lib/vendor-pool/table/vendor-pool-excel-import-button.tsx b/lib/vendor-pool/table/vendor-pool-excel-import-button.tsx new file mode 100644 index 00000000..704d4aff --- /dev/null +++ b/lib/vendor-pool/table/vendor-pool-excel-import-button.tsx @@ -0,0 +1,258 @@ +/** + * 특정 컬럼들 복합키로 묶어 UPDATE 처리해야 함. + */ + +"use client" + +import React, { useRef } from 'react' +import ExcelJS from 'exceljs' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Upload, Loader } from 'lucide-react' +import { createVendorPool } from '../service' +import { Input } from '@/components/ui/input' +import { useSession } from "next-auth/react" +import { + getCellValueAsString, + parseBoolean, + getAccessorKeyByHeader, + vendorPoolExcelColumns +} from '../excel-utils' + +interface ImportExcelProps { + onSuccess?: () => void +} + +export function ImportVendorPoolButton({ onSuccess }: ImportExcelProps) { + const fileInputRef = useRef<HTMLInputElement>(null) + const [isImporting, setIsImporting] = React.useState(false) + const { data: session, status } = useSession() + + // 헬퍼 함수들은 excel-utils에서 import + + const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => { + const file = event.target.files?.[0] + if (!file) return + + setIsImporting(true) + + try { + // Read the Excel file using ExcelJS + const data = await file.arrayBuffer() + const workbook = new ExcelJS.Workbook() + await workbook.xlsx.load(data) + + // Get the first worksheet + const worksheet = workbook.getWorksheet(1) + if (!worksheet) { + toast.error("No worksheet found in the spreadsheet") + return + } + + // Check if there's an instruction row (템플릿 안내 텍스트가 있는지 확인) + const firstRowText = getCellValueAsString(worksheet.getRow(1).getCell(1)); + const hasInstructionRow = firstRowText.includes('벤더풀 데이터 입력 템플릿') || + firstRowText.includes('입력 가이드 시트') || + firstRowText.includes('입력 가이드') || + (worksheet.getRow(1).getCell(1).value !== null && + worksheet.getRow(1).getCell(2).value === null); + + // Get header row index (row 2 if there's an instruction row, otherwise row 1) + const headerRowIndex = hasInstructionRow ? 2 : 1; + + // Get column headers and their indices + const headerRow = worksheet.getRow(headerRowIndex); + const columnIndices: Record<string, number> = {}; + + headerRow.eachCell((cell, colNumber) => { + const header = getCellValueAsString(cell); + // Excel 헤더를 통해 accessorKey 찾기 + const accessorKey = getAccessorKeyByHeader(header); + if (accessorKey) { + columnIndices[accessorKey] = colNumber; + } + }); + + // Process data rows + const rows: any[] = []; + const startRow = headerRowIndex + 1; + + for (let i = startRow; i <= worksheet.rowCount; i++) { + const row = worksheet.getRow(i); + + // Skip empty rows + if (row.cellCount === 0) continue; + + // Check if this is likely an empty template row (빈 템플릿 행 건너뛰기) + let hasAnyData = false; + for (let col = 1; col <= row.cellCount; col++) { + if (getCellValueAsString(row.getCell(col)).trim()) { + hasAnyData = true; + break; + } + } + if (!hasAnyData) continue; + + const rowData: Record<string, any> = {}; + let hasData = false; + + // Map the data using accessorKey indices + Object.entries(columnIndices).forEach(([accessorKey, colIndex]) => { + const value = getCellValueAsString(row.getCell(colIndex)); + if (value) { + rowData[accessorKey] = value; + hasData = true; + } + }); + + if (hasData) { + rows.push(rowData); + } + } + + if (rows.length === 0) { + toast.error("No data found in the spreadsheet") + setIsImporting(false) + return + } + + // Process each row + let successCount = 0; + let errorCount = 0; + + // Create promises for all vendor pool creation operations + const promises = rows.map(async (row) => { + try { + // Excel 컬럼 설정을 기반으로 데이터 매핑 + const vendorPoolData: any = {}; + + vendorPoolExcelColumns.forEach(column => { + const { accessorKey, type } = column; + const value = row[accessorKey] || ''; + + if (type === 'boolean') { + vendorPoolData[accessorKey] = parseBoolean(String(value)); + } else if (value === '') { + // 빈 문자열은 null로 설정 (스키마에 맞게) + vendorPoolData[accessorKey] = null; + } else { + vendorPoolData[accessorKey] = String(value); + } + }); + + // 현재 사용자 정보 추가 + vendorPoolData.registrant = session?.user?.name || 'system'; + vendorPoolData.lastModifier = session?.user?.name || 'system'; + + // Validate required fields + if (!vendorPoolData.constructionSector || !vendorPoolData.htDivision || + !vendorPoolData.designCategory || !vendorPoolData.vendorName || + !vendorPoolData.designCategoryCode || !vendorPoolData.equipBulkDivision) { + console.error("Missing required fields", vendorPoolData); + errorCount++; + return null; + } + + // Validate field lengths and formats + const validationErrors: string[] = []; + + if (vendorPoolData.designCategoryCode && vendorPoolData.designCategoryCode.length > 2) { + validationErrors.push(`설계기능코드는 2자리 이하여야 합니다: ${vendorPoolData.designCategoryCode}`); + } + + if (vendorPoolData.equipBulkDivision && vendorPoolData.equipBulkDivision.length > 1) { + validationErrors.push(`Equip/Bulk 구분은 1자리여야 합니다: ${vendorPoolData.equipBulkDivision}`); + } + + if (vendorPoolData.constructionSector && !['조선', '해양'].includes(vendorPoolData.constructionSector)) { + validationErrors.push(`공사부문은 '조선' 또는 '해양'이어야 합니다: ${vendorPoolData.constructionSector}`); + } + + if (vendorPoolData.htDivision && !['H', 'T', '공통'].includes(vendorPoolData.htDivision)) { + validationErrors.push(`H/T구분은 'H', 'T' 또는 '공통'이어야 합니다: ${vendorPoolData.htDivision}`); + } + + if (validationErrors.length > 0) { + console.error("Validation errors:", validationErrors, vendorPoolData); + errorCount++; + return null; + } + + if (!session || !session.user || !session.user.id) { + toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.") + return + } + + // Create the vendor pool entry + const result = await createVendorPool(vendorPoolData as any) + + if (!result) { + console.error(`Failed to import row - createVendorPool returned null:`, vendorPoolData); + errorCount++; + return null; + } + + successCount++; + return result; + } catch (error) { + console.error("Error processing row:", error, row); + errorCount++; + return null; + } + }); + + // Wait for all operations to complete + await Promise.all(promises); + + // Show results + if (successCount > 0) { + toast.success(`${successCount}개 항목이 성공적으로 가져와졌습니다.`); + if (errorCount > 0) { + toast.warning(`${errorCount}개 항목 가져오기에 실패했습니다. 콘솔에서 자세한 오류를 확인하세요.`); + } + // Call the success callback to refresh data + onSuccess?.(); + } else if (errorCount > 0) { + toast.error(`모든 ${errorCount}개 항목 가져오기에 실패했습니다. 데이터 형식을 확인하세요.`); + } + + } catch (error) { + console.error("Import error:", error); + toast.error("Error importing data. Please check file format."); + } finally { + setIsImporting(false); + // Reset the file input + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + } + + return ( + <> + <Input + type="file" + ref={fileInputRef} + onChange={handleImport} + accept=".xlsx,.xls" + className="hidden" + /> + <Button + variant="outline" + size="sm" + onClick={() => fileInputRef.current?.click()} + disabled={isImporting} + className="gap-2" + > + {isImporting ? ( + <Loader className="size-4 animate-spin" aria-hidden="true" /> + ) : ( + <Upload className="size-4" aria-hidden="true" /> + )} + <span className="hidden sm:inline"> + {isImporting ? "Importing..." : "Import"} + </span> + </Button> + </> + ) +} diff --git a/lib/vendor-pool/table/vendor-pool-table-columns.tsx b/lib/vendor-pool/table/vendor-pool-table-columns.tsx index 8f09e684..1f0c455e 100644 --- a/lib/vendor-pool/table/vendor-pool-table-columns.tsx +++ b/lib/vendor-pool/table/vendor-pool-table-columns.tsx @@ -1,7 +1,7 @@ import { Checkbox } from "@/components/ui/checkbox" import { Button } from "@/components/ui/button" -import { MoreHorizontal, Eye, Edit, Trash2 } from "lucide-react" -import { type ColumnDef, TableMeta } from "@tanstack/react-table" +import { Trash2 } from "lucide-react" +import { type ColumnDef } from "@tanstack/react-table" import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" import { EditableCell } from "@/components/data-table/editable-cell" @@ -11,1566 +11,1530 @@ const getIsModified = (table: any, rowId: string, fieldName: string) => { return String(rowId) in pendingChanges && fieldName in pendingChanges[String(rowId)] } -// 테이블 메타 타입 확장 -declare module "@tanstack/react-table" { - interface TableMeta<TData> { - onCellUpdate?: (id: string, field: keyof TData, newValue: any) => Promise<void> - onCellCancel?: (id: string, field: keyof TData) => void - onAction?: (action: string, data?: any) => void - onSaveEmptyRow?: (tempId: string) => Promise<void> - onCancelEmptyRow?: (tempId: string) => void - isEmptyRow?: (id: string) => boolean - onTaxIdChange?: (id: string, taxId: string) => Promise<void> - onMaterialGroupCodeChange?: (id: string, materialGroupCode: string) => Promise<void> - } +// vendor-pool 테이블 메타 타입 (복사본) +interface VendorPoolTableMeta { + onCellUpdate?: (id: string | number, field: string, newValue: any) => Promise<void> + onCellCancel?: (id: string | number, field: string) => void + onAction?: (action: string, data?: any) => void + onSaveEmptyRow?: (tempId: string) => Promise<void> + onCancelEmptyRow?: (tempId: string) => void + isEmptyRow?: (id: string) => boolean + onTaxIdChange?: (id: string, taxId: string) => Promise<void> } -// Vendor Pool 데이터 타입 -export type VendorPoolItem = { - id: string - no: number - selected: boolean - constructionSector: string // 공사부문: 조선 또는 해양 - htDivision: string // H/T구분: H 또는 T - designCategoryCode: string // 설계기능(공종) 코드: 2자리 영문대문자 - designCategory: string // 설계기능(공종): 전장 등 - equipBulkDivision: string // Equip/Bulk 구분: E 또는 B - // 패키지 정보 (스키마: packageCode, packageName) - packageCode: string - packageName: string - // 자재그룹 (스키마: materialGroupCode, materialGroupName) - materialGroupCode: string - materialGroupName: string - smCode: string // SM Code - similarMaterialNamePurchase: string // 유사자재명 (구매) - similarMaterialNameOther: string // 유사자재명 (구매 외) - // 협력업체 정보 (스키마: vendorCode, vendorName) - vendorCode: string - vendorName: string - taxId: string // 사업자번호(Tax ID) - faTarget: boolean // FA대상 - faStatus: string // FA현황 - faRemark: string // FA상세(Remark) - tier: string // 등급(Tier) - isAgent: boolean // Agent 여부 - // 계약서명주체 (스키마: contractSignerCode, contractSignerName) - contractSignerCode: string - contractSignerName: string - headquarterLocation: string // 본사 위치(국가) - manufacturingLocation: string // 제작/선적지(국가) - avlVendorName: string // AVL 등재업체명 - similarVendorName: string // 유사업체명(기술영업) - hasAvl: boolean // AVL: 존재여부 - isBlacklist: boolean // Blacklist - isBcc: boolean // BCC - purchaseOpinion: string // 구매의견 - // AVL 적용 선종(조선) - shipTypeCommon: boolean // 공통 - shipTypeAmax: boolean // A-max - shipTypeSmax: boolean // S-max - shipTypeVlcc: boolean // VLCC - shipTypeLngc: boolean // LNGC - shipTypeCont: boolean // CONT - // AVL 적용 선종(해양) - offshoreTypeCommon: boolean // 공통 - offshoreTypeFpso: boolean // FPSO - offshoreTypeFlng: boolean // FLNG - offshoreTypeFpu: boolean // FPU - offshoreTypePlatform: boolean // Platform - offshoreTypeWtiv: boolean // WTIV - offshoreTypeGom: boolean // GOM - // eVCP 미등록 정보 - picName: string // PIC(담당자) - picEmail: string // PIC(E-mail) - picPhone: string // PIC(Phone) - agentName: string // Agent(담당자) - agentEmail: string // Agent(E-mail) - agentPhone: string // Agent(Phone) - // 업체 실적 현황 - recentQuoteDate: string // 최근견적일 - recentQuoteNumber: string // 최근견적번호 - recentOrderDate: string // 최근발주일 - recentOrderNumber: string // 최근발주번호 - // 업데이트 히스토리 - registrationDate: string // 등재일 - registrant: string // 등재자 - lastModifiedDate: string // 최종변경일 - lastModifier: string // 최종변경자 +// Vendor Pool 데이터 타입 - 스키마 기반 + 테이블용 추가 필드 +import type { VendorPool } from "@/db/schema/avl/vendor-pool" +import { DisciplineCode, EngineeringDisciplineSelector } 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 { VendorTierSelector } from "@/components/common/selectors/vendor-tier/vendor-tier-selector" +import { VendorSelectorDialogSingle } from "@/components/common/vendor/vendor-selector-dialog-single" +import type { VendorSearchItem } from "@/components/common/vendor/vendor-service" +import { PlaceOfShippingSelectorDialogSingle } from "@/components/common/selectors/place-of-shipping/place-of-shipping-selector" + +export type VendorPoolItem = Omit<VendorPool, 'registrationDate' | 'lastModifiedDate'> & { + id: string | number // temp-로 시작하는 경우 string, 실제 데이터는 number + no: number // 테이블 표시용 순번 + selected: boolean // 테이블 선택 상태 + registrationDate: string // 표시용 string으로 변환 + lastModifiedDate: string // 표시용 string으로 변환 } // 테이블 컬럼 정의 export const columns: ColumnDef<VendorPoolItem>[] = [ - { - id: "select", - header: ({ table }) => ( - <Checkbox - checked={ - table.getIsAllPageRowsSelected() || - (table.getIsSomePageRowsSelected() && "indeterminate") - } - onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} - aria-label="Select all" - /> - ), - cell: ({ row }) => ( - <Checkbox - checked={row.getIsSelected()} - onCheckedChange={(value) => row.toggleSelected(!!value)} - aria-label="Select row" - /> - ), - enableSorting: false, - enableHiding: false, - size: 40, - }, - { - accessorKey: "id", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="No." /> - ), - cell: ({ row }) => { - const id = String(row.original.id) - - // 빈 행의 경우 No. 표시하지 않음 - if (id.startsWith('temp-')) { - return <div className="text-sm text-muted-foreground italic">신규</div> - } + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + /> + ), + enableSorting: false, + enableHiding: false, + size: 40, + }, + { + accessorKey: "id", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="ID" /> + ), + cell: ({ row }) => { + const id = String(row.original.id) + + // 빈 행의 경우 신규 표시 + if (id.startsWith('temp-')) { + return <div className="text-sm text-muted-foreground italic">신규</div> + } - // vendor_pool 테이블의 실제 id 표시 - return <div className="text-sm font-mono">{id}</div> - }, - size: 60, - }, - { - accessorKey: "constructionSector", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">공사부문 *</span>} /> - ), - cell: ({ row, table }) => { - const value = row.getValue("constructionSector") - const isEmptyRow = String(row.original.id).startsWith('temp-') - - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "constructionSector", newValue) - } - } + // 실제 ID 표시 + return <div className="text-sm font-mono">{id}</div> + }, + size: 60, + }, + { + accessorKey: "constructionSector", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">공사부문 *</span>} /> + ), + cell: ({ row, table }) => { + const value = row.getValue("constructionSector") + const isEmptyRow = String(row.original.id).startsWith('temp-') + + const onSave = async (newValue: any) => { + const meta = table.options.meta as VendorPoolTableMeta + if (meta?.onCellUpdate) { + await meta.onCellUpdate(row.original.id, "constructionSector", newValue) + } + } - // 수정 여부 확인 - const isModified = getIsModified(table, row.original.id, "constructionSector") - - return ( - <EditableCell - value={value} - type="select" - onSave={onSave} - options={[ - { label: "조선", value: "조선" }, - { label: "해양", value: "해양" } - ]} - placeholder="공사부문 선택" - autoSave={true} - disabled={false} - initialEditMode={isEmptyRow} - isModified={isModified} - /> - ) - }, - size: 100, - }, - { - accessorKey: "htDivision", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">H/T구분 *</span>} /> - ), - cell: ({ row, table }) => { - const value = row.getValue("htDivision") - const isEmptyRow = String(row.original.id).startsWith('temp-') - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "htDivision", newValue) - } - } + // 수정 여부 확인 + const isModified = getIsModified(table, row.original.id, "constructionSector") - // 수정 여부 확인 - const isModified = getIsModified(table, row.original.id, "htDivision") - - return ( - <EditableCell - value={value} - type="select" - onSave={onSave} - options={[ - { label: "H", value: "H" }, - { label: "T", value: "T" }, - { label: "공통", value: "공통" }, - ]} - placeholder="H/T 선택" - autoSave={false} - initialEditMode={isEmptyRow} - isModified={isModified} - /> - ) - }, - size: 80, - }, - { - accessorKey: "designCategoryCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="설계기능코드" /> - ), - cell: ({ row, table }) => { - const value = row.getValue("designCategoryCode") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "designCategoryCode", newValue) - } - } + return ( + <EditableCell + value={value} + type="select" + onSave={onSave} + options={[ + { label: "조선", value: "조선" }, + { label: "해양", value: "해양" } + ]} + placeholder="공사부문 선택" + autoSave={true} + disabled={false} + initialEditMode={isEmptyRow} + isModified={isModified} + /> + ) + }, + size: 100, + }, + { + accessorKey: "htDivision", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">H/T *</span>} /> + ), + cell: ({ row, table }) => { + const value = row.getValue("htDivision") + const isEmptyRow = String(row.original.id).startsWith('temp-') + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "htDivision", newValue) + } + } - // 수정 여부 확인 - const isModified = getIsModified(table, row.original.id, "designCategoryCode") - - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="설계기능코드 입력" - maxLength={10} - autoSave={false} - isModified={isModified} - /> - ) - }, - size: 120, - }, - { - accessorKey: "designCategory", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">설계기능(공종) *</span>} /> - ), - cell: ({ row, table }) => { - const value = row.getValue("designCategory") - const isEmptyRow = String(row.original.id).startsWith('temp-') - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "designCategory", newValue) - } - } + // 수정 여부 확인 + const isModified = getIsModified(table, row.original.id, "htDivision") - // 수정 여부 확인 - const isModified = getIsModified(table, row.original.id, "designCategory") - - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="설계기능(공종) 입력" - maxLength={50} - autoSave={false} - initialEditMode={isEmptyRow} - isModified={isModified} - /> - ) - }, - size: 120, - }, - { - accessorKey: "equipBulkDivision", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Equip/Bulk 구분" /> - ), - cell: ({ row, table }) => { - const value = row.getValue("equipBulkDivision") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "equipBulkDivision", newValue) - } - } + return ( + <EditableCell + value={value} + type="select" + onSave={onSave} + options={[ + { label: "H", value: "H" }, + { label: "T", value: "T" }, + { label: "공통", value: "공통" }, + ]} + placeholder="H/T 선택" + autoSave={false} + initialEditMode={isEmptyRow} + isModified={isModified} + /> + ) + }, + size: 80, + }, + { + accessorKey: "designCategory", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">설계기능(공종) *</span>} /> + ), + cell: ({ row, table }) => { + const designCategoryCode = row.original.designCategoryCode as string + const designCategory = row.original.designCategory as string + + // 현재 선택된 discipline 구성 + const selectedDiscipline: DisciplineCode | undefined = designCategoryCode && designCategory ? { + CD: designCategoryCode, + USR_DF_CHAR_18: designCategory + } : undefined + + const onDisciplineSelect = async (discipline: DisciplineCode) => { + console.log('선택된 설계공종:', discipline) + console.log('행 ID:', row.original.id) + + // 설계기능코드와 설계기능(공종) 필드 모두 업데이트 + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "designCategoryCode", discipline.CD) + await table.options.meta.onCellUpdate(row.original.id, "designCategory", discipline.USR_DF_CHAR_18) + } else { + console.error('onCellUpdate가 정의되지 않음') + } + } - return ( - <EditableCell - value={value} - type="select" - onSave={onSave} - options={[ - { label: "E (Equip)", value: "E" }, - { label: "B (Bulk)", value: "B" }, - { label: "S (강재)", value: "S" } - ]} - placeholder="구분 선택" - autoSave={false} - /> - ) - }, - size: 120, - }, - { - accessorKey: "packageCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="패키지 코드" /> - ), - cell: ({ row, table }) => { - const value = row.getValue("packageCode") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "packageCode", newValue) - } - } + return ( + <EngineeringDisciplineSelector + selectedDiscipline={selectedDiscipline} + onDisciplineSelect={onDisciplineSelect} + disabled={false} + placeholder="설계공종을 선택하세요" + /> + ) + }, + size: 260, + }, + { + accessorKey: "equipBulkDivision", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Equip/Bulk" /> + ), + cell: ({ row, table }) => { + const value = row.getValue("equipBulkDivision") + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "equipBulkDivision", newValue) + } + } - // 수정 여부 확인 - const isModified = getIsModified(table, row.original.id, "packageCode") - - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="패키지 코드 입력" - maxLength={50} - autoSave={false} - isModified={isModified} - /> - ) - }, - size: 120, - }, - { - accessorKey: "packageName", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="패키지 명" /> - ), - cell: ({ row, table }) => { - const value = row.getValue("packageName") - const isEmptyRow = String(row.original.id).startsWith('temp-') - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "packageName", newValue) - } - } + return ( + <EditableCell + value={value} + type="select" + onSave={onSave} + options={[ + { label: "E (Equip)", value: "E" }, + { label: "B (Bulk)", value: "B" }, + { label: "S (강재)", value: "S" } + ]} + placeholder="구분 선택" + autoSave={false} + /> + ) + }, + size: 120, + }, + { + accessorKey: "packageCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="패키지 코드" /> + ), + cell: ({ row, table }) => { + const value = row.getValue("packageCode") + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "packageCode", newValue) + } + } - // 수정 여부 확인 - const isModified = getIsModified(table, row.original.id, "packageName") - - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="패키지 명 입력" - maxLength={100} - autoSave={false} - initialEditMode={isEmptyRow} - isModified={isModified} - /> - ) - }, - size: 120, - }, - { - accessorKey: "materialGroupCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">자재그룹 코드 *</span>} /> - ), - cell: ({ row, table }) => { - const value = row.getValue("materialGroupCode") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "materialGroupCode", newValue) - } - } + // 수정 여부 확인 + const isModified = getIsModified(table, row.original.id, "packageCode") - // 수정 여부 확인 - const isModified = getIsModified(table, row.original.id, "materialGroupCode") + return ( + <EditableCell + value={value} + type="text" + onSave={onSave} + placeholder="패키지 코드 입력" + maxLength={50} + autoSave={false} + isModified={isModified} + /> + ) + }, + size: 120, + }, + { + accessorKey: "packageName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="패키지 명" /> + ), + cell: ({ row, table }) => { + const value = row.getValue("packageName") + const isEmptyRow = String(row.original.id).startsWith('temp-') + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "packageName", newValue) + } + } - const onChange = async (newValue: any) => { - if (table.options.meta?.onMaterialGroupCodeChange) { - await table.options.meta.onMaterialGroupCodeChange(row.original.id, newValue) - } - } + // 수정 여부 확인 + const isModified = getIsModified(table, row.original.id, "packageName") - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - onChange={onChange} - placeholder="자재그룹 코드 입력" - maxLength={50} - autoSave={false} - isModified={isModified} - /> - ) - }, - size: 120, - }, - { - accessorKey: "materialGroupName", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">자재그룹 명 *</span>} /> - ), - cell: ({ row, table }) => { - const value = row.getValue("materialGroupName") - const isEmptyRow = String(row.original.id).startsWith('temp-') - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "materialGroupName", newValue) - } + return ( + <EditableCell + value={value} + type="text" + onSave={onSave} + placeholder="패키지 명 입력" + maxLength={100} + autoSave={false} + initialEditMode={isEmptyRow} + isModified={isModified} + /> + ) + }, + size: 200, + }, + { + accessorKey: "materialGroupName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">자재그룹 *</span>} /> + ), + cell: ({ row, table }) => { + const materialGroupCode = row.original.materialGroupCode as string + const materialGroupName = row.original.materialGroupName as string + + // 현재 선택된 material 구성 + const selectedMaterial: MaterialSearchItem | null = materialGroupCode && materialGroupName ? { + materialGroupCode, + materialGroupDescription: materialGroupName, + displayText: `${materialGroupCode} - ${materialGroupName}` + } : null + + const onMaterialSelect = async (material: MaterialSearchItem | null) => { + console.log('선택된 자재그룹:', material) + + if (material) { + // 자재그룹코드와 자재그룹명 필드 모두 업데이트 + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "materialGroupCode", material.materialGroupCode) + await table.options.meta.onCellUpdate(row.original.id, "materialGroupName", material.materialGroupDescription) } - - // 수정 여부 확인 - const isModified = getIsModified(table, row.original.id, "materialGroupName") - - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="자재그룹 명 입력" - maxLength={100} - autoSave={false} - initialEditMode={isEmptyRow} - isModified={isModified} - /> - ) - }, - size: 120, - }, - { - accessorKey: "smCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="SM Code" /> - ), - cell: ({ row, table }) => { - const value = row.getValue("smCode") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "smCode", newValue) - } + } else { + // 선택 해제 시 빈 값으로 설정 + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "materialGroupCode", "") + await table.options.meta.onCellUpdate(row.original.id, "materialGroupName", "") } + } + } - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="SM Code 입력" - maxLength={50} - /> - ) - }, - size: 100, - }, - { - accessorKey: "similarMaterialNamePurchase", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="유사자재명(구매)" /> - ), - cell: ({ row, table }) => { - const value = row.getValue("similarMaterialNamePurchase") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "similarMaterialNamePurchase", newValue) - } - } + return ( + <MaterialGroupSelectorDialogSingle + selectedMaterial={selectedMaterial} + onMaterialSelect={onMaterialSelect} + disabled={false} + triggerLabel="자재그룹 선택" + placeholder="자재그룹을 검색하세요..." + title="자재그룹 선택" + description="필요한 자재그룹을 검색하고 선택해주세요." + /> + ) + }, + size: 400, + }, + { + accessorKey: "smCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="SM Code" /> + ), + cell: ({ row, table }) => { + const value = row.getValue("smCode") + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "smCode", newValue) + } + } - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="유사자재명(구매) 입력" - maxLength={100} - autoSave={false} - /> - ) - }, - size: 140, - }, - { - accessorKey: "similarMaterialNameOther", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="유사자재명(구매외)" /> - ), - cell: ({ row, table }) => { - const value = row.getValue("similarMaterialNameOther") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "similarMaterialNameOther", newValue) - } - } + return ( + <EditableCell + value={value} + type="text" + onSave={onSave} + placeholder="SM Code 입력" + maxLength={50} + /> + ) + }, + size: 200, + }, + { + accessorKey: "similarMaterialNamePurchase", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="자재명 (검색 키워드)" /> + // 이전에는 컬럼명이 '유사자재명(구매외)' 였음. + ), + cell: ({ row, table }) => { + const value = row.getValue("similarMaterialNamePurchase") + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "similarMaterialNamePurchase", newValue) + } + } - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="유사자재명(구매외) 입력" - maxLength={100} - autoSave={false} - /> - ) - }, - size: 140, - }, - { - accessorKey: "vendorCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="협력업체 코드" /> - ), - cell: ({ row, table }) => { - const value = row.getValue("vendorCode") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "vendorCode", newValue) - } - } + return ( + <EditableCell + value={value} + type="text" + onSave={onSave} + placeholder="유사자재명(구매) 입력" + maxLength={100} + autoSave={false} + /> + ) + }, + size: 250, + }, + { + accessorKey: "similarMaterialNameOther", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="유사자재명(구매외)" /> + ), + cell: ({ row, table }) => { + const value = row.getValue("similarMaterialNameOther") + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "similarMaterialNameOther", newValue) + } + } - // 수정 여부 확인 - const isModified = getIsModified(table, row.original.id, "vendorCode") - - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="협력업체 코드 입력" - maxLength={50} - autoSave={false} - isModified={isModified} - /> - ) - }, - size: 130, - }, - { - accessorKey: "vendorName", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">협력업체 명 *</span>} /> - ), - cell: ({ row, table }) => { - const value = row.getValue("vendorName") - const isEmptyRow = String(row.original.id).startsWith('temp-') - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "vendorName", newValue) - } + return ( + <EditableCell + value={value} + type="text" + onSave={onSave} + placeholder="유사자재명(구매외) 입력" + maxLength={100} + autoSave={false} + /> + ) + }, + size: 140, + }, + { + accessorKey: "vendorSelector", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="협력업체 선택" /> + ), + cell: ({ row, table }) => { + const vendorCode = row.original.vendorCode as string + const vendorName = row.original.vendorName as string + + // 현재 선택된 vendor 구성 + const selectedVendor: VendorSearchItem | null = vendorCode && vendorName ? { + id: 0, // 실제로는 vendorId가 있어야 하지만 여기서는 임시로 0 사용 + vendorName, + vendorCode: vendorCode || null, + status: "ACTIVE", // 임시 값 + displayText: vendorName + (vendorCode ? ` (${vendorCode})` : "") + } : null + + const onVendorSelect = async (vendor: VendorSearchItem | null) => { + console.log('선택된 협력업체:', vendor) + + if (vendor) { + // 협력업체코드, 협력업체명, 사업자번호 필드 모두 업데이트 + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "vendorCode", vendor.vendorCode || "") + await table.options.meta.onCellUpdate(row.original.id, "vendorName", vendor.vendorName) + await table.options.meta.onCellUpdate(row.original.id, "taxId", vendor.taxId || "") } - - // 수정 여부 확인 - const isModified = getIsModified(table, row.original.id, "vendorName") - - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="협력업체 명 입력" - maxLength={100} - autoSave={false} - initialEditMode={isEmptyRow} - isModified={isModified} - /> - ) - }, - size: 130, - }, - { - accessorKey: "taxId", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">사업자번호 *</span>} /> - ), - cell: ({ row, table }) => { - const value = row.getValue("taxId") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "taxId", newValue) - } + } else { + // 선택 해제 시 빈 값으로 설정 + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "vendorCode", "") + await table.options.meta.onCellUpdate(row.original.id, "vendorName", "") + await table.options.meta.onCellUpdate(row.original.id, "taxId", "") } + } + } - const onChange = async (newValue: any) => { - if (table.options.meta?.onTaxIdChange) { - await table.options.meta.onTaxIdChange(row.original.id, newValue) - } - } + return ( + <VendorSelectorDialogSingle + selectedVendor={selectedVendor} + onVendorSelect={onVendorSelect} + disabled={false} + triggerLabel="협력업체 선택" + placeholder="협력업체를 검색하세요..." + title="협력업체 선택" + description="협력업체를 검색하고 선택해주세요." + statusFilter="ACTIVE" + /> + ) + }, + size: 150, + }, + { + accessorKey: "vendorCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="협력업체 코드" /> + ), + cell: ({ row, table }) => { + const value = row.getValue("vendorCode") + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "vendorCode", newValue) + } + } - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - onChange={onChange} - placeholder="사업자번호 입력" - maxLength={50} - /> - ) - }, - size: 120, - }, - { - accessorKey: "faTarget", - header: "FA대상", - cell: ({ row, table }) => { - const value = row.getValue("faTarget") as boolean - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "faTarget", newValue) - } - } + // 수정 여부 확인 + const isModified = getIsModified(table, row.original.id, "vendorCode") - // 수정 여부 확인 - const isModified = getIsModified(table, row.original.id, "faTarget") - - return ( - <EditableCell - value={value} - type="checkbox" - onSave={onSave} - autoSave={false} - isModified={isModified} - /> - ) - }, - enableSorting: false, - size: 80, - }, - { - accessorKey: "faStatus", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="FA현황" /> - ), - cell: ({ row, table }) => { - const value = row.getValue("faStatus") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "faStatus", newValue) - } - } + return ( + <EditableCell + value={value} + type="text" + onSave={onSave} + placeholder="협력업체 코드 입력" + maxLength={50} + autoSave={false} + isModified={isModified} + /> + ) + }, + size: 130, + }, + { + accessorKey: "vendorName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">협력업체 명 *</span>} /> + ), + cell: ({ row, table }) => { + const value = row.getValue("vendorName") + const isEmptyRow = String(row.original.id).startsWith('temp-') + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "vendorName", newValue) + } + } - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="FA현황 입력" - maxLength={50} - /> - ) - }, - size: 100, - }, - { - accessorKey: "faRemark", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="FA상세" /> - ), - cell: ({ row, table }) => { - const value = row.getValue("faRemark") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "faRemark", newValue) - } - } + // 수정 여부 확인 + const isModified = getIsModified(table, row.original.id, "vendorName") - return ( - <EditableCell - value={value} - type="textarea" - onSave={onSave} - placeholder="FA상세 입력" - maxLength={500} - autoSave={false} - /> - ) - }, - size: 120, - }, - { - accessorKey: "tier", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">등급 *</span>} /> - ), - cell: ({ row, table }) => { - const value = row.getValue("tier") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "tier", newValue) - } - } + return ( + <EditableCell + value={value} + type="text" + onSave={onSave} + placeholder="협력업체 명 입력" + maxLength={100} + autoSave={false} + initialEditMode={isEmptyRow} + isModified={isModified} + /> + ) + }, + size: 130, + }, + { + accessorKey: "taxId", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">사업자번호 *</span>} /> + ), + cell: ({ row, table }) => { + const value = row.getValue("taxId") + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "taxId", newValue) + } + } - return ( - <EditableCell - value={value} - type="select" - onSave={onSave} - options={[ - { label: "Tier 1", value: "Tier 1" }, - { label: "Tier 2", value: "Tier 2" }, - { label: "Tier 3", value: "Tier 3" }, - { label: "Tier 4", value: "Tier 4" }, - ]} - placeholder="등급 선택" - /> - ) - }, - size: 80, - }, - { - accessorKey: "isAgent", - header: "Agent 여부", - cell: ({ row, table }) => { - const value = row.getValue("isAgent") as boolean - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "isAgent", newValue) - } - } + const onChange = async (newValue: any) => { + if (table.options.meta?.onTaxIdChange) { + await table.options.meta.onTaxIdChange(row.original.id, newValue) + } + } - return ( - <EditableCell - value={value} - type="checkbox" - onSave={onSave} - autoSave={false} - /> - ) - }, - enableSorting: false, - size: 100, - }, - { - accessorKey: "contractSignerCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="계약서명주체 코드" /> - ), - cell: ({ row, table }) => { - const value = row.getValue("contractSignerCode") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "contractSignerCode", newValue) - } - } + return ( + <EditableCell + value={value} + type="text" + onSave={onSave} + onChange={onChange} + placeholder="사업자번호 입력" + maxLength={50} + /> + ) + }, + size: 120, + }, + { + accessorKey: "faTarget", + header: "FA대상", + cell: ({ row, table }) => { + const value = row.getValue("faTarget") as boolean + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "faTarget", newValue) + } + } - // 수정 여부 확인 - const isModified = getIsModified(table, row.original.id, "contractSignerCode") - - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="계약서명주체 코드 입력" - maxLength={50} - autoSave={false} - isModified={isModified} - /> - ) - }, - size: 120, - }, - { - accessorKey: "contractSignerName", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">계약서명주체 명 *</span>} /> - ), - cell: ({ row, table }) => { - const value = row.getValue("contractSignerName") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "contractSignerName", newValue) - } - } + // 수정 여부 확인 + const isModified = getIsModified(table, row.original.id, "faTarget") - // 수정 여부 확인 - const isModified = getIsModified(table, row.original.id, "contractSignerName") - - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="계약서명주체 명 입력" - maxLength={100} - autoSave={false} - isModified={isModified} - /> - ) - }, - size: 120, - }, - { - accessorKey: "headquarterLocation", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">본사 위치 *</span>} /> - ), - cell: ({ row, table }) => { - const value = row.getValue("headquarterLocation") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "headquarterLocation", newValue) - } - } + return ( + <EditableCell + value={value} + type="checkbox" + onSave={onSave} + autoSave={false} + isModified={isModified} + /> + ) + }, + enableSorting: false, + size: 80, + }, + { + accessorKey: "faStatus", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="FA현황" /> + ), + cell: ({ row }) => { + const value = row.original.faStatus as string - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="본사 위치 입력" - maxLength={50} - /> - ) - }, - size: 100, - }, - { - accessorKey: "manufacturingLocation", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">제작/선적지 *</span>} /> - ), - cell: ({ row, table }) => { - const value = row.getValue("manufacturingLocation") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "manufacturingLocation", newValue) - } - } + // 'O'인 경우에만 'O'를 표시, 그 외에는 빈 셀 + const displayValue = value === "O" ? "O" : "" - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="제작/선적지 입력" - maxLength={50} - /> - ) - }, - size: 110, - }, - { - accessorKey: "avlVendorName", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">AVL 등재업체명 *</span>} /> - ), - cell: ({ row, table }) => { - const value = row.getValue("avlVendorName") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "avlVendorName", newValue) - } - } + return ( + <div className="px-2 py-1 text-sm text-center"> + {displayValue} + </div> + ) + }, + size: 120, + }, + // { + // accessorKey: "faRemark", + // header: ({ column }) => ( + // <DataTableColumnHeaderSimple column={column} title="FA상세" /> + // ), + // cell: ({ row, table }) => { + // const value = row.getValue("faRemark") + // const onSave = async (newValue: any) => { + // if (table.options.meta?.onCellUpdate) { + // await table.options.meta.onCellUpdate(row.original.id, "faRemark", newValue) + // } + // } + + // return ( + // <EditableCell + // value={value} + // type="textarea" + // onSave={onSave} + // placeholder="FA상세 입력" + // maxLength={500} + // autoSave={false} + // /> + // ) + // }, + // size: 120, + // }, + { + accessorKey: "tier", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">등급 *</span>} /> + ), + cell: ({ row, table }) => { + const value = row.original.tier as string - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="AVL 등재업체명 입력" - maxLength={100} - /> - ) - }, - size: 140, - }, - { - accessorKey: "similarVendorName", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="유사업체명" /> - ), - cell: ({ row, table }) => { - const value = row.getValue("similarVendorName") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "similarVendorName", newValue) - } - } + const onValueChange = async (newValue: string) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "tier", newValue) + } + } - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="유사업체명 입력" - maxLength={100} - /> - ) - }, - size: 130, - }, - { - accessorKey: "hasAvl", - header: "AVL", - cell: ({ row, table }) => { - const value = row.getValue("hasAvl") as boolean - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "hasAvl", newValue) - } - } + return ( + <VendorTierSelector + value={value} + onValueChange={onValueChange} + disabled={false} + placeholder="등급 선택" + /> + ) + }, + size: 200, + }, + { + accessorKey: "isAgent", + header: "Agent 여부", + cell: ({ row, table }) => { + const value = row.getValue("isAgent") as boolean + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "isAgent", newValue) + } + } - return ( - <EditableCell - value={value} - type="checkbox" - onSave={onSave} - autoSave={false} - /> - ) - }, - enableSorting: false, - size: 60, - }, - { - accessorKey: "isBlacklist", - header: "Blacklist", - cell: ({ row, table }) => { - const value = row.getValue("isBlacklist") as boolean - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "isBlacklist", newValue) - } - } + return ( + <EditableCell + value={value} + type="checkbox" + onSave={onSave} + autoSave={false} + /> + ) + }, + enableSorting: false, + size: 100, + }, + { + accessorKey: "contractSignerCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="계약서명주체 코드" /> + ), + cell: ({ row, table }) => { + const value = row.getValue("contractSignerCode") + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "contractSignerCode", newValue) + } + } - return ( - <EditableCell - value={value} - type="checkbox" - onSave={onSave} - autoSave={false} - /> - ) - }, - enableSorting: false, - size: 100, - }, - { - accessorKey: "isBcc", - header: "BCC", - cell: ({ row, table }) => { - const value = row.getValue("isBcc") as boolean - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "isBcc", newValue) - } - } + // 수정 여부 확인 + const isModified = getIsModified(table, row.original.id, "contractSignerCode") - return ( - <EditableCell - value={value} - type="checkbox" - onSave={onSave} - autoSave={false} - /> - ) - }, - enableSorting: false, - size: 80, - }, - { - accessorKey: "purchaseOpinion", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="구매의견" /> - ), - cell: ({ row, table }) => { - const value = row.getValue("purchaseOpinion") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "purchaseOpinion", newValue) - } + return ( + <EditableCell + value={value} + type="text" + onSave={onSave} + placeholder="계약서명주체 코드 입력" + maxLength={50} + autoSave={false} + isModified={isModified} + /> + ) + }, + size: 120, + }, + { + accessorKey: "contractSignerSelector", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="계약서명주체 선택" /> + ), + cell: ({ row, table }) => { + const contractSignerCode = row.original.contractSignerCode as string + const contractSignerName = row.original.contractSignerName as string + + // 현재 선택된 contract signer 구성 + const selectedVendor: VendorSearchItem | null = contractSignerCode && contractSignerName ? { + id: 0, // 실제로는 vendorId가 있어야 하지만 여기서는 임시로 0 사용 + vendorName: contractSignerName, + vendorCode: contractSignerCode || null, + status: "ACTIVE", // 임시 값 + displayText: contractSignerName + (contractSignerCode ? ` (${contractSignerCode})` : "") + } : null + + const onVendorSelect = async (vendor: VendorSearchItem | null) => { + console.log('선택된 계약서명주체:', vendor) + + if (vendor) { + // 계약서명주체코드와 계약서명주체명 필드 업데이트 + // 사업자번호는 협력업체 선택 시에만 업데이트됨 (taxId 필드가 하나만 존재) + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "contractSignerCode", vendor.vendorCode || "") + await table.options.meta.onCellUpdate(row.original.id, "contractSignerName", vendor.vendorName) } - - return ( - <EditableCell - value={value} - type="textarea" - onSave={onSave} - placeholder="구매의견 입력" - maxLength={500} - /> - ) - }, - size: 120, - }, - { - accessorKey: "shipTypeCommon", - header: "공통", - cell: ({ row, table }) => { - const value = row.getValue("shipTypeCommon") as boolean - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "shipTypeCommon", newValue) - } + } else { + // 선택 해제 시 빈 값으로 설정 + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "contractSignerCode", "") + await table.options.meta.onCellUpdate(row.original.id, "contractSignerName", "") } + } + } - return ( - <EditableCell - value={value} - type="checkbox" - onSave={onSave} - autoSave={false} - /> - ) - }, - enableSorting: false, - size: 80, - }, - { - accessorKey: "shipTypeAmax", - header: "A-max", - cell: ({ row, table }) => { - const value = row.getValue("shipTypeAmax") as boolean - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "shipTypeAmax", newValue) - } - } + return ( + <VendorSelectorDialogSingle + selectedVendor={selectedVendor} + onVendorSelect={onVendorSelect} + disabled={false} + triggerLabel="계약서명주체 선택" + placeholder="계약서명주체를 검색하세요..." + title="계약서명주체 선택" + description="계약서명주체를 검색하고 선택해주세요." + statusFilter="ACTIVE" + /> + ) + }, + size: 150, + }, + { + accessorKey: "contractSignerName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">계약서명주체 명 *</span>} /> + ), + cell: ({ row, table }) => { + const value = row.getValue("contractSignerName") + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "contractSignerName", newValue) + } + } - return ( - <EditableCell - value={value} - type="checkbox" - onSave={onSave} - autoSave={false} - /> - ) - }, - enableSorting: false, - size: 80, - }, - { - accessorKey: "shipTypeSmax", - header: "S-max", - cell: ({ row, table }) => { - const value = row.getValue("shipTypeSmax") as boolean - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "shipTypeSmax", newValue) - } - } + // 수정 여부 확인 + const isModified = getIsModified(table, row.original.id, "contractSignerName") - return ( - <EditableCell - value={value} - type="checkbox" - onSave={onSave} - autoSave={false} - /> - ) - }, - enableSorting: false, - size: 80, - }, - { - accessorKey: "shipTypeVlcc", - header: "VLCC", - cell: ({ row, table }) => { - const value = row.getValue("shipTypeVlcc") as boolean - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "shipTypeVlcc", newValue) - } - } + return ( + <EditableCell + value={value} + type="text" + onSave={onSave} + placeholder="계약서명주체 명 입력" + maxLength={100} + autoSave={false} + isModified={isModified} + /> + ) + }, + size: 120, + }, + { + accessorKey: "headquarterLocation", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">본사 위치 *</span>} /> + ), + cell: ({ row, table }) => { + const value = row.getValue("headquarterLocation") + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "headquarterLocation", newValue) + } + } - return ( - <EditableCell - value={value} - type="checkbox" - onSave={onSave} - autoSave={false} - /> - ) - }, - enableSorting: false, - size: 80, - }, - { - accessorKey: "shipTypeLngc", - header: "LNGC", - cell: ({ row, table }) => { - const value = row.getValue("shipTypeLngc") as boolean - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "shipTypeLngc", newValue) - } - } + return ( + <EditableCell + value={value} + type="text" + onSave={onSave} + placeholder="본사 위치 입력" + maxLength={50} + /> + ) + }, + size: 100, + }, + { + accessorKey: "manufacturingLocation", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">제작/선적지 *</span>} /> + ), + cell: ({ row, table }) => { + const manufacturingLocation = row.original.manufacturingLocation as string - return ( - <EditableCell - value={value} - type="checkbox" - onSave={onSave} - autoSave={false} - /> - ) - }, - size: 80, - }, - { - accessorKey: "shipTypeCont", - header: "CONT", - cell: ({ row, table }) => { - const value = row.getValue("shipTypeCont") as boolean - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "shipTypeCont", newValue) - } - } + // 현재 선택된 장소 구성 (description은 알 수 없으므로 null로 설정) + const selectedPlace = null // 선택된 장소 표시를 위해 null로 설정 - return ( - <EditableCell - value={value} - type="checkbox" - onSave={onSave} - autoSave={false} - /> - ) - }, - size: 80, - }, - { - accessorKey: "offshoreTypeCommon", - header: "공통", - cell: ({ row, table }) => { - const value = row.getValue("offshoreTypeCommon") as boolean - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "offshoreTypeCommon", newValue) - } - } + const onPlaceSelect = async (place: { code: string; description: string } | null) => { + console.log('선택된 제작/선적지:', place) - return ( - <EditableCell - value={value} - type="checkbox" - onSave={onSave} - autoSave={false} - /> - ) - }, - size: 80, - }, - { - accessorKey: "offshoreTypeFpso", - header: "FPSO", - cell: ({ row, table }) => { - const value = row.getValue("offshoreTypeFpso") as boolean - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "offshoreTypeFpso", newValue) - } - } + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "manufacturingLocation", place?.code || "") + } + } - return ( - <EditableCell - value={value} - type="checkbox" - onSave={onSave} - autoSave={false} - /> - ) - }, - size: 80, - }, - { - accessorKey: "offshoreTypeFlng", - header: "FLNG", - cell: ({ row, table }) => { - const value = row.getValue("offshoreTypeFlng") as boolean - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "offshoreTypeFlng", newValue) - } - } + return ( + <PlaceOfShippingSelectorDialogSingle + selectedPlace={selectedPlace} + onPlaceSelect={onPlaceSelect} + disabled={false} + triggerLabel={manufacturingLocation || "제작/선적지 선택"} + placeholder="제작/선적지를 검색하세요..." + title="제작/선적지 선택" + description="제작/선적지를 검색하고 선택해주세요." + /> + ) + }, + size: 200, + }, + { + accessorKey: "avlVendorName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">AVL 등재업체명 *</span>} /> + ), + cell: ({ row, table }) => { + const value = row.getValue("avlVendorName") + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "avlVendorName", newValue) + } + } - return ( - <EditableCell - value={value} - type="checkbox" - onSave={onSave} - autoSave={false} - /> - ) - }, - size: 80, - }, - { - accessorKey: "offshoreTypeFpu", - header: "FPU", - cell: ({ row, table }) => { - const value = row.getValue("offshoreTypeFpu") as boolean - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "offshoreTypeFpu", newValue) - } - } + return ( + <EditableCell + value={value} + type="text" + onSave={onSave} + placeholder="AVL 등재업체명 입력" + maxLength={100} + /> + ) + }, + size: 140, + }, + { + accessorKey: "similarVendorName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="업체명 (검색 키워드)" /> + // 이전에는 컬럼명이 '유사업체명' 였음. + ), + cell: ({ row, table }) => { + const value = row.getValue("similarVendorName") + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "similarVendorName", newValue) + } + } - return ( - <EditableCell - value={value} - type="checkbox" - onSave={onSave} - autoSave={false} - /> - ) - }, - size: 80, - }, - { - accessorKey: "offshoreTypePlatform", - header: "Platform", - cell: ({ row, table }) => { - const value = row.getValue("offshoreTypePlatform") as boolean - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "offshoreTypePlatform", newValue) - } - } + return ( + <EditableCell + value={value} + type="text" + onSave={onSave} + placeholder="유사업체명 입력" + maxLength={100} + /> + ) + }, + size: 130, + }, + { + accessorKey: "hasAvl", + header: "AVL", + cell: ({ row, table }) => { + const value = row.getValue("hasAvl") as boolean + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "hasAvl", newValue) + } + } - return ( - <EditableCell - value={value} - type="checkbox" - onSave={onSave} - autoSave={false} - /> - ) - }, - size: 100, - }, - { - accessorKey: "offshoreTypeWtiv", - header: "WTIV", - cell: ({ row, table }) => { - const value = row.getValue("offshoreTypeWtiv") as boolean - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "offshoreTypeWtiv", newValue) - } - } + return ( + <EditableCell + value={value} + type="checkbox" + onSave={onSave} + autoSave={false} + /> + ) + }, + enableSorting: false, + size: 60, + }, + { + accessorKey: "isBlacklist", + header: "Blacklist", + cell: ({ row, table }) => { + const value = row.getValue("isBlacklist") as boolean + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "isBlacklist", newValue) + } + } - return ( - <EditableCell - value={value} - type="checkbox" - onSave={onSave} - autoSave={false} - /> - ) - }, - size: 80, - }, - { - accessorKey: "offshoreTypeGom", - header: "GOM", - cell: ({ row, table }) => { - const value = row.getValue("offshoreTypeGom") as boolean - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "offshoreTypeGom", newValue) - } - } + return ( + <EditableCell + value={value} + type="checkbox" + onSave={onSave} + autoSave={false} + /> + ) + }, + enableSorting: false, + size: 60, + }, + { + accessorKey: "isBcc", + header: "BCC", + cell: ({ row, table }) => { + const value = row.getValue("isBcc") as boolean + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "isBcc", newValue) + } + } - return ( - <EditableCell - value={value} - type="checkbox" - onSave={onSave} - autoSave={false} - /> - ) - }, - size: 80, - }, - { - accessorKey: "picName", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="PIC(담당자)" /> - ), - cell: ({ row, table }) => { - const value = row.getValue("picName") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "picName", newValue) - } - } + return ( + <EditableCell + value={value} + type="checkbox" + onSave={onSave} + autoSave={false} + /> + ) + }, + enableSorting: false, + size: 60, + }, + { + accessorKey: "purchaseOpinion", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="구매의견" /> + ), + cell: ({ row, table }) => { + const value = row.getValue("purchaseOpinion") + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "purchaseOpinion", newValue) + } + } - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="PIC 담당자명 입력" - maxLength={50} - /> - ) - }, - size: 120, - }, - { - accessorKey: "picEmail", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="PIC(E-mail)" /> - ), - cell: ({ row, table }) => { - const value = row.getValue("picEmail") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "picEmail", newValue) - } - } - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="PIC 이메일 입력" - maxLength={100} - /> - ) - }, - size: 140, - }, - { - accessorKey: "picPhone", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="PIC(Phone)" /> - ), - cell: ({ row, table }) => { - const value = row.getValue("picPhone") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "picPhone", newValue) - } - } + return ( + <EditableCell + value={value} + type="textarea" + onSave={onSave} + placeholder="구매의견 입력" + maxLength={500} + /> + ) + }, + size: 300, + }, + { + accessorKey: "shipTypeCommon", + header: "공통", + cell: ({ row, table }) => { + const value = row.getValue("shipTypeCommon") as boolean + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "shipTypeCommon", newValue) + } + } - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="PIC 전화번호 입력" - maxLength={20} - /> - ) - }, - size: 120, - }, - { - accessorKey: "agentName", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Agent(담당자)" /> - ), - cell: ({ row, table }) => { - const value = row.getValue("agentName") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "agentName", newValue) - } - } + return ( + <EditableCell + value={value} + type="checkbox" + onSave={onSave} + autoSave={false} + /> + ) + }, + enableSorting: false, + size: 80, + }, + { + accessorKey: "shipTypeAmax", + header: "A-max", + cell: ({ row, table }) => { + const value = row.getValue("shipTypeAmax") as boolean + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "shipTypeAmax", newValue) + } + } - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="Agent 담당자명 입력" - maxLength={50} - /> - ) - }, - size: 120, - }, - { - accessorKey: "agentEmail", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Agent(E-mail)" /> - ), - cell: ({ row, table }) => { - const value = row.getValue("agentEmail") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "agentEmail", newValue) - } - } + return ( + <EditableCell + value={value} + type="checkbox" + onSave={onSave} + autoSave={false} + /> + ) + }, + enableSorting: false, + size: 60, + }, + { + accessorKey: "shipTypeSmax", + header: "S-max", + cell: ({ row, table }) => { + const value = row.getValue("shipTypeSmax") as boolean + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "shipTypeSmax", newValue) + } + } - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="Agent 이메일 입력" - maxLength={100} - /> - ) - }, - size: 140, - }, - { - accessorKey: "agentPhone", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Agent(Phone)" /> - ), - cell: ({ row, table }) => { - const value = row.getValue("agentPhone") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "agentPhone", newValue) - } - } + return ( + <EditableCell + value={value} + type="checkbox" + onSave={onSave} + autoSave={false} + /> + ) + }, + enableSorting: false, + size: 60, + }, + { + accessorKey: "shipTypeVlcc", + header: "VLCC", + cell: ({ row, table }) => { + const value = row.getValue("shipTypeVlcc") as boolean + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "shipTypeVlcc", newValue) + } + } - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="Agent 전화번호 입력" - maxLength={20} - /> - ) - }, - size: 120, - }, - { - accessorKey: "recentQuoteDate", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="최근견적일" /> - ), - cell: ({ row, table }) => { - const value = row.getValue("recentQuoteDate") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "recentQuoteDate", newValue) - } - } + return ( + <EditableCell + value={value} + type="checkbox" + onSave={onSave} + autoSave={false} + /> + ) + }, + enableSorting: false, + size: 60, + }, + { + accessorKey: "shipTypeLngc", + header: "LNGC", + cell: ({ row, table }) => { + const value = row.getValue("shipTypeLngc") as boolean + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "shipTypeLngc", newValue) + } + } - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="최근견적일 입력 (YYYY-MM-DD)" - maxLength={20} - autoSave={false} - /> - ) - }, - size: 120, - }, - { - accessorKey: "recentQuoteNumber", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="최근견적번호" /> - ), - cell: ({ row, table }) => { - const value = row.getValue("recentQuoteNumber") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "recentQuoteNumber", newValue) - } - } + return ( + <EditableCell + value={value} + type="checkbox" + onSave={onSave} + autoSave={false} + /> + ) + }, + size: 60, + }, + { + accessorKey: "shipTypeCont", + header: "CONT", + cell: ({ row, table }) => { + const value = row.getValue("shipTypeCont") as boolean + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "shipTypeCont", newValue) + } + } - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="최근견적번호 입력" - maxLength={50} - /> - ) - }, - size: 130, - }, - { - accessorKey: "recentOrderDate", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="최근발주일" /> - ), - cell: ({ row, table }) => { - const value = row.getValue("recentOrderDate") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "recentOrderDate", newValue) - } - } + return ( + <EditableCell + value={value} + type="checkbox" + onSave={onSave} + autoSave={false} + /> + ) + }, + size: 60, + }, + { + accessorKey: "offshoreTypeCommon", + header: "공통", + cell: ({ row, table }) => { + const value = row.getValue("offshoreTypeCommon") as boolean + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "offshoreTypeCommon", newValue) + } + } - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="최근발주일 입력 (YYYY-MM-DD)" - maxLength={20} - autoSave={false} - /> - ) - }, - size: 120, - }, - { - accessorKey: "recentOrderNumber", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="최근발주번호" /> - ), - cell: ({ row, table }) => { - const value = row.getValue("recentOrderNumber") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "recentOrderNumber", newValue) - } - } + return ( + <EditableCell + value={value} + type="checkbox" + onSave={onSave} + autoSave={false} + /> + ) + }, + size: 60, + }, + { + accessorKey: "offshoreTypeFpso", + header: "FPSO", + cell: ({ row, table }) => { + const value = row.getValue("offshoreTypeFpso") as boolean + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "offshoreTypeFpso", newValue) + } + } + + return ( + <EditableCell + value={value} + type="checkbox" + onSave={onSave} + autoSave={false} + /> + ) + }, + size: 60, + }, + { + accessorKey: "offshoreTypeFlng", + header: "FLNG", + cell: ({ row, table }) => { + const value = row.getValue("offshoreTypeFlng") as boolean + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "offshoreTypeFlng", newValue) + } + } + + return ( + <EditableCell + value={value} + type="checkbox" + onSave={onSave} + autoSave={false} + /> + ) + }, + size: 60, + }, + { + accessorKey: "offshoreTypeFpu", + header: "FPU", + cell: ({ row, table }) => { + const value = row.getValue("offshoreTypeFpu") as boolean + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "offshoreTypeFpu", newValue) + } + } + + return ( + <EditableCell + value={value} + type="checkbox" + onSave={onSave} + autoSave={false} + /> + ) + }, + size: 60, + }, + { + accessorKey: "offshoreTypePlatform", + header: "Platform", + cell: ({ row, table }) => { + const value = row.getValue("offshoreTypePlatform") as boolean + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "offshoreTypePlatform", newValue) + } + } + + return ( + <EditableCell + value={value} + type="checkbox" + onSave={onSave} + autoSave={false} + /> + ) + }, + size: 60, + }, + { + accessorKey: "offshoreTypeWtiv", + header: "WTIV", + cell: ({ row, table }) => { + const value = row.getValue("offshoreTypeWtiv") as boolean + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "offshoreTypeWtiv", newValue) + } + } + + return ( + <EditableCell + value={value} + type="checkbox" + onSave={onSave} + autoSave={false} + /> + ) + }, + size: 60, + }, + { + accessorKey: "offshoreTypeGom", + header: "GOM", + cell: ({ row, table }) => { + const value = row.getValue("offshoreTypeGom") as boolean + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "offshoreTypeGom", newValue) + } + } + + return ( + <EditableCell + value={value} + type="checkbox" + onSave={onSave} + autoSave={false} + /> + ) + }, + size: 60, + }, + { + accessorKey: "picName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="협력업체담당자" /> + // 이전에는 컬럼명이 PIC(담당자) 였음. + ), + cell: ({ row, table }) => { + const value = row.getValue("picName") + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "picName", newValue) + } + } + + return ( + <EditableCell + value={value} + type="text" + onSave={onSave} + placeholder="입력가능" + maxLength={50} + /> + ) + }, + size: 120, + }, + { + accessorKey: "picEmail", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="협력업체담당자(E-mail)" /> + // 이전에는 컬럼명이 PIC(E-mail) 였음. + ), + cell: ({ row, table }) => { + const value = row.getValue("picEmail") + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "picEmail", newValue) + } + } + return ( + <EditableCell + value={value} + type="text" + onSave={onSave} + placeholder="입력가능" + maxLength={100} + /> + ) + }, + size: 140, + }, + { + accessorKey: "picPhone", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="협력업체담당자(Phone)" /> + // 이전에는 컬럼명이 PIC(Phone) 였음. + ), + cell: ({ row, table }) => { + const value = row.getValue("picPhone") + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "picPhone", newValue) + } + } + + return ( + <EditableCell + value={value} + type="text" + onSave={onSave} + placeholder="입력가능" + maxLength={20} + /> + ) + }, + size: 120, + }, + { + accessorKey: "agentName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Agent(담당자)" /> + ), + cell: ({ row, table }) => { + const value = row.getValue("agentName") + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "agentName", newValue) + } + } + + return ( + <EditableCell + value={value} + type="text" + onSave={onSave} + placeholder="입력가능" + maxLength={50} + /> + ) + }, + size: 120, + }, + { + accessorKey: "agentEmail", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Agent(E-mail)" /> + ), + cell: ({ row, table }) => { + const value = row.getValue("agentEmail") + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "agentEmail", newValue) + } + } - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="최근발주번호 입력" - maxLength={50} - /> - ) - }, - size: 130, - }, - { - accessorKey: "registrationDate", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="등재일" /> - ), - size: 120, - }, - { - accessorKey: "registrant", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="등재자" /> - ), - cell: ({ row }) => { - const value = row.getValue("registrant") as string - return <div className="text-sm">{value || ""}</div> - }, - size: 100, - }, - { - accessorKey: "lastModifiedDate", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="최종변경일" /> - ), - size: 120, - }, - { - accessorKey: "lastModifier", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="최종변경자" /> - ), - cell: ({ row }) => { - const value = row.getValue("lastModifier") as string - return <div className="text-sm">{value || ""}</div> - }, - size: 120, - }, + return ( + <EditableCell + value={value} + type="text" + onSave={onSave} + placeholder="입력가능" + maxLength={100} + /> + ) + }, + size: 140, + }, + { + accessorKey: "agentPhone", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Agent(Phone)" /> + ), + cell: ({ row, table }) => { + const value = row.getValue("agentPhone") + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "agentPhone", newValue) + } + } + + return ( + <EditableCell + value={value} + type="text" + onSave={onSave} + placeholder="입력가능" + maxLength={20} + /> + ) + }, + size: 120, + }, + { + accessorKey: "recentQuoteDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="최근견적일" /> + ), + cell: ({ row }) => { + const value = row.getValue("recentQuoteDate") as string + return ( + <div className="px-2 py-1 text-sm"> + {value || "-"} + </div> + ) + }, + size: 120, + }, + { + accessorKey: "recentQuoteNumber", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="최근견적번호" /> + ), + cell: ({ row }) => { + const value = row.getValue("recentQuoteNumber") as string + return ( + <div className="px-2 py-1 text-sm"> + {value || "-"} + </div> + ) + }, + size: 130, + }, + { + accessorKey: "recentOrderDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="최근발주일" /> + ), + cell: ({ row }) => { + const value = row.getValue("recentOrderDate") as string + return ( + <div className="px-2 py-1 text-sm"> + {value || "-"} + </div> + ) + }, + size: 120, + }, + { + accessorKey: "recentOrderNumber", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="최근발주번호" /> + ), + cell: ({ row }) => { + const value = row.getValue("recentOrderNumber") as string + return ( + <div className="px-2 py-1 text-sm"> + {value || "-"} + </div> + ) + }, + size: 130, + }, + { + accessorKey: "registrationDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="등록일" /> + ), + size: 120, + }, + { + accessorKey: "registrant", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="등록자" /> + ), + cell: ({ row }) => { + const value = row.getValue("registrant") as string + return <div className="text-sm">{value || ""}</div> + }, + size: 100, + }, + { + accessorKey: "lastModifiedDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="최종변경일" /> + ), + size: 120, + }, + { + accessorKey: "lastModifier", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="최종변경자" /> + ), + cell: ({ row }) => { + const value = row.getValue("lastModifier") as string + return <div className="text-sm">{value || ""}</div> + }, + size: 120, + }, // 액션 그룹 { id: "actions", diff --git a/lib/vendor-pool/table/vendor-pool-table.tsx b/lib/vendor-pool/table/vendor-pool-table.tsx index 43dd64c1..46a0588d 100644 --- a/lib/vendor-pool/table/vendor-pool-table.tsx +++ b/lib/vendor-pool/table/vendor-pool-table.tsx @@ -16,19 +16,20 @@ import { BulkImportDialog } from "./bulk-import-dialog" import { columns, type VendorPoolItem } from "./vendor-pool-table-columns" import { createVendorPool, updateVendorPool, deleteVendorPool } from "../service" -import type { VendorPool } from "../types" - -// 테이블 메타 타입 확장 -declare module "@tanstack/react-table" { - interface TableMeta<TData> { - onCellUpdate?: (id: string, field: keyof TData, newValue: any) => Promise<void> - onCellCancel?: (id: string, field: keyof TData) => void - onAction?: (action: string, data?: any) => void - onSaveEmptyRow?: (tempId: string) => Promise<void> - onCancelEmptyRow?: (tempId: string) => void - isEmptyRow?: (id: string) => boolean - getPendingChanges?: () => Record<string, Partial<VendorPoolItem>> - } +import type { VendorPool } from "@/db/schema/avl/vendor-pool" +import { Download, FileSpreadsheet, Upload } from "lucide-react" +import { ImportVendorPoolButton } from "./vendor-pool-excel-import-button" +import { exportVendorPoolToExcel, createVendorPoolTemplate } from "../excel-utils" + +// vendor-pool 테이블 메타 타입 +interface VendorPoolTableMeta { + onCellUpdate?: (id: string | number, field: string, newValue: any) => Promise<void> + onCellCancel?: (id: string | number, field: string) => void + onAction?: (action: string, data?: any) => void + onSaveEmptyRow?: (tempId: string) => Promise<void> + onCancelEmptyRow?: (tempId: string) => void + isEmptyRow?: (id: string) => boolean + getPendingChanges?: () => Record<string, Partial<VendorPoolItem>> } interface VendorPoolTableProps { @@ -37,6 +38,67 @@ interface VendorPoolTableProps { onRefresh?: () => void // 데이터 새로고침 콜백 } +// 빈 행 기본값 객체 +const createEmptyVendorPoolBase = (): Omit<VendorPool, 'id'> & { id?: string | number } => ({ + constructionSector: "", + htDivision: "", + designCategoryCode: "", + designCategory: "", + equipBulkDivision: "", + packageCode: null, + packageName: null, + materialGroupCode: null, + materialGroupName: null, + smCode: null, + similarMaterialNamePurchase: null, + similarMaterialNameOther: null, + vendorCode: null, + vendorName: "", + taxId: null, + faTarget: false, + faStatus: null, + faRemark: null, + tier: null, + isAgent: false, + contractSignerCode: null, + contractSignerName: "", + headquarterLocation: "", + manufacturingLocation: "", + avlVendorName: null, + similarVendorName: null, + hasAvl: false, + isBlacklist: false, + isBcc: false, + purchaseOpinion: null, + shipTypeCommon: false, + shipTypeAmax: false, + shipTypeSmax: false, + shipTypeVlcc: false, + shipTypeLngc: false, + shipTypeCont: false, + offshoreTypeCommon: false, + offshoreTypeFpso: false, + offshoreTypeFlng: false, + offshoreTypeFpu: false, + offshoreTypePlatform: false, + offshoreTypeWtiv: false, + offshoreTypeGom: false, + picName: null, + picEmail: null, + picPhone: null, + agentName: null, + agentEmail: null, + agentPhone: null, + recentQuoteDate: null, + recentQuoteNumber: null, + recentOrderDate: null, + recentOrderNumber: null, + registrationDate: null, + registrant: null, + lastModifiedDate: null, + lastModifier: null, +}) + export function VendorPoolTable({ data, pageCount, onRefresh }: VendorPoolTableProps) { const { data: session } = useSession() @@ -54,7 +116,7 @@ export function VendorPoolTable({ data, pageCount, onRefresh }: VendorPoolTableP // 인라인 편집 핸들러 (일괄 저장용) - const handleCellUpdate = React.useCallback(async (id: string, field: keyof VendorPoolItem, newValue: any) => { + const handleCellUpdate = React.useCallback(async (id: string | number, field: string, newValue: any) => { const isEmptyRow = String(id).startsWith('temp-') if (isEmptyRow) { @@ -81,7 +143,7 @@ export function VendorPoolTable({ data, pageCount, onRefresh }: VendorPoolTableP // 편집 취소 핸들러 - const handleCellCancel = React.useCallback((id: string, field: keyof VendorPoolItem) => { + const handleCellCancel = React.useCallback((id: string | number, field: string) => { const isEmptyRow = String(id).startsWith('temp-') if (isEmptyRow) { @@ -142,13 +204,13 @@ export function VendorPoolTable({ data, pageCount, onRefresh }: VendorPoolTableP for (const [id, changes] of Object.entries(pendingChanges)) { try { // changes에서 id 필드 제거 (서버에서 자동 생성) - const { id: _, ...updateData } = changes as any + const { id: _, no: __, selected: ___, ...updateData } = changes // 최종변경자를 현재 세션 사용자 정보로 설정 - const updateDataWithModifier = { + const updateDataWithModifier: any = { ...updateData, - lastModifier: session?.user?.name || "" + lastModifier: session?.user?.name || null } - const result = await updateVendorPool(Number(id), updateDataWithModifier as Partial<VendorPool>) + const result = await updateVendorPool(Number(id), updateDataWithModifier) if (result) { successCount++ } else { @@ -190,68 +252,18 @@ export function VendorPoolTable({ data, pageCount, onRefresh }: VendorPoolTableP if (isCreating) return // 이미 생성 중이면 중복 생성 방지 const tempId = `temp-${Date.now()}` + const userName = session?.user?.name || null + const emptyRow: VendorPoolItem = { - id: tempId, + ...createEmptyVendorPoolBase(), + id: tempId, // string 타입으로 설정 no: 0, // 나중에 계산 selected: false, - constructionSector: "", - htDivision: "", - designCategoryCode: "", - designCategory: "", - equipBulkDivision: "", - packageCode: "", - packageName: "", - materialGroupCode: "", - materialGroupName: "", - smCode: "", - similarMaterialNamePurchase: "", - similarMaterialNameOther: "", - vendorCode: "", - vendorName: "", - taxId: "", - faTarget: false, - faStatus: "", - faRemark: "", - tier: "", - isAgent: false, - contractSignerCode: "", - contractSignerName: "", - headquarterLocation: "", - manufacturingLocation: "", - avlVendorName: "", - similarVendorName: "", - hasAvl: false, - isBlacklist: false, - isBcc: false, - purchaseOpinion: "", - shipTypeCommon: false, - shipTypeAmax: false, - shipTypeSmax: false, - shipTypeVlcc: false, - shipTypeLngc: false, - shipTypeCont: false, - offshoreTypeCommon: false, - offshoreTypeFpso: false, - offshoreTypeFlng: false, - offshoreTypeFpu: false, - offshoreTypePlatform: false, - offshoreTypeWtiv: false, - offshoreTypeGom: false, - picName: "", - picEmail: "", - picPhone: "", - agentName: "", - agentEmail: "", - agentPhone: "", - recentQuoteDate: "", - recentQuoteNumber: "", - recentOrderDate: "", - recentOrderNumber: "", - registrationDate: "", - registrant: session?.user?.name || "", + registrationDate: "", // 빈 행에서는 string으로 표시 + registrant: userName, lastModifiedDate: "", - lastModifier: session?.user?.name || "", - } + lastModifier: userName, + } as unknown as VendorPoolItem setEmptyRows(prev => ({ ...prev, [tempId]: emptyRow })) setIsCreating(true) @@ -312,10 +324,10 @@ export function VendorPoolTable({ data, pageCount, onRefresh }: VendorPoolTableP try { setIsSaving(true) - // id 필드 제거 (서버에서 자동 생성) - const { id: _, no: __, selected: ___, ...createData } = finalData + // id, no, selected 필드 제거 및 타입 변환 + const { id: _, no: __, selected: ___, registrationDate: ____, lastModifiedDate: _____, ...createData } = finalData - const result = await createVendorPool(createData as Omit<VendorPool, 'id' | 'registrationDate' | 'lastModifiedDate'>) + const result = await createVendorPool(createData as any) if (result) { toast.success("새 항목이 추가되었습니다.") @@ -591,8 +603,34 @@ export function VendorPoolTable({ data, pageCount, onRefresh }: VendorPoolTableP toast.info('저장 기능은 개발 중입니다.') break - case 'excel-import': - toast.info('Excel Import 기능은 개발 중입니다.') + + case 'excel-export': + try { + // 현재 테이블 데이터를 Excel로 내보내기 (ID 포함) + const currentData = table.getFilteredRowModel().rows.map(row => row.original) + await exportVendorPoolToExcel( + currentData, + `vendor-pool-${new Date().toISOString().split('T')[0]}.xlsx`, + true // ID 포함 + ) + toast.success('Excel 파일이 다운로드되었습니다.') + } catch (error) { + console.error('Excel export 실패:', error) + toast.error('Excel 내보내기에 실패했습니다.') + } + break + + case 'excel-template': + try { + // 템플릿 파일 다운로드 (데이터 없음, ID 컬럼 제외) + await createVendorPoolTemplate( + `vendor-pool-template-${new Date().toISOString().split('T')[0]}.xlsx` + ) + toast.success('Excel 템플릿이 다운로드되었습니다.') + } catch (error) { + console.error('Excel template export 실패:', error) + toast.error('Excel 템플릿 다운로드에 실패했습니다.') + } break case 'delete': @@ -634,7 +672,7 @@ export function VendorPoolTable({ data, pageCount, onRefresh }: VendorPoolTableP // 제공된 값들만 적용 (빈 값이나 undefined는 건너뜀) Object.entries(bulkData).forEach(([field, value]) => { if (value !== undefined && value !== null && value !== '') { - handleCellUpdate(rowId, field as keyof VendorPoolItem, value) + handleCellUpdate(rowId, field as keyof VendorPool, value) } }) } @@ -648,7 +686,7 @@ export function VendorPoolTable({ data, pageCount, onRefresh }: VendorPoolTableP }, [table, handleCellUpdate]) // 테이블 메타에 핸들러 설정 - table.options.meta = { + const tableMeta: VendorPoolTableMeta = { onAction: handleAction, onCellUpdate: handleCellUpdate, onCellCancel: handleCellCancel, @@ -657,6 +695,8 @@ export function VendorPoolTable({ data, pageCount, onRefresh }: VendorPoolTableP isEmptyRow: (id: string) => String(id).startsWith('temp-'), getPendingChanges: () => pendingChanges } + + table.options.meta = tableMeta as any // 툴바 액션 핸들러들 @@ -699,12 +739,24 @@ export function VendorPoolTable({ data, pageCount, onRefresh }: VendorPoolTableP 일괄입력 </Button> + <ImportVendorPoolButton onSuccess={onRefresh} /> + + <Button + onClick={() => handleToolbarAction('excel-export')} + variant="outline" + size="sm" + > + <Download className="mr-2 h-4 w-4" /> + Excel Export + </Button> + <Button - onClick={() => handleToolbarAction('excel-import')} + onClick={() => handleToolbarAction('excel-template')} variant="outline" size="sm" > - Excel Import + <FileSpreadsheet className="mr-2 h-4 w-4" /> + Template </Button> <Button |
