diff options
19 files changed, 1664 insertions, 588 deletions
diff --git a/app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/items/page.tsx b/app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/items/page.tsx new file mode 100644 index 00000000..69c36576 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/items/page.tsx @@ -0,0 +1,48 @@ +// import { Separator } from "@/components/ui/separator" +// import { getTechVendorById, getVendorItemsByType } from "@/lib/tech-vendors/service" +// import { type SearchParams } from "@/types/table" +// import { TechVendorItemsTable } from "@/lib/tech-vendors/items-table/item-table" + +// interface IndexPageProps { +// // Next.js 13 App Router에서 기본으로 주어지는 객체들 +// params: { +// lng: string +// id: string +// } +// searchParams: Promise<SearchParams> +// } + +// export default async function TechVendorItemsPage(props: IndexPageProps) { +// const resolvedParams = await props.params +// const id = resolvedParams.id + +// const idAsNumber = Number(id) + +// // 벤더 정보 가져오기 (벤더 타입 필요) +// const vendorInfo = await getTechVendorById(idAsNumber) +// const vendorType = vendorInfo.data?.techVendorType || "조선" + +// const promises = getVendorItemsByType(idAsNumber, vendorType) + +// // 4) 렌더링 +// return ( +// <div className="space-y-6"> +// <div> +// <h3 className="text-lg font-medium"> +// 공급품목 +// </h3> +// <p className="text-sm text-muted-foreground"> +// 기술영업 벤더의 공급 가능한 품목을 확인하세요. +// </p> +// </div> +// <Separator /> +// <div> +// <TechVendorItemsTable +// promises={promises} +// vendorId={idAsNumber} +// vendorType={vendorType} +// /> +// </div> +// </div> +// ) +// }
\ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/layout.tsx b/app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/layout.tsx new file mode 100644 index 00000000..7c389720 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/layout.tsx @@ -0,0 +1,82 @@ +import { Metadata } from "next" + +import { Separator } from "@/components/ui/separator" +import { SidebarNav } from "@/components/layout/sidebar-nav" +import { findTechVendorById } from "@/lib/tech-vendors/service" +import { TechVendor } from "@/db/schema/techVendors" +import { Button } from "@/components/ui/button" +import { ArrowLeft } from "lucide-react" +import Link from "next/link" +export const metadata: Metadata = { + title: "Tech Vendor Detail", +} + +export default async function SettingsLayout({ + children, + params, +}: { + children: React.ReactNode + params: { lng: string , id: string} +}) { + + // 1) URL 파라미터에서 id 추출, Number로 변환 + const resolvedParams = await params + const lng = resolvedParams.lng + const id = resolvedParams.id + + const idAsNumber = Number(id) + // 2) DB에서 해당 협력업체 정보 조회 + const vendor: TechVendor | null = await findTechVendorById(idAsNumber) + + // 3) 사이드바 메뉴 + const sidebarNavItems = [ + { + title: "연락처", + href: `/${lng}/evcp/tech-vendors/${id}/info`, + }, + // { + // title: "자재 리스트", + // href: `/${lng}/evcp/tech-vendors/${id}/info/items`, + // }, + // { + // title: "견적 히스토리", + // href: `/${lng}/evcp/tech-vendors/${id}/info/rfq-history`, + // }, + ] + + return ( + <> + <div className="container py-6"> + <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow"> + <div className="hidden space-y-6 p-10 pb-16 md:block"> + {/* RFQ 목록으로 돌아가는 링크 추가 */} + <div className="flex items-center justify-end mb-4"> + <Link href={`/${lng}/evcp/tech-vendors`} passHref> + <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto"> + <ArrowLeft className="mr-1 h-4 w-4" /> + <span>기술영업 벤더 목록으로 돌아가기</span> + </Button> + </Link> + </div> + <div className="space-y-0.5"> + {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */} + <h2 className="text-2xl font-bold tracking-tight"> + {vendor + ? `${vendor.vendorCode ?? ""} - ${vendor.vendorName} 상세 정보` + : "Loading Vendor..."} + </h2> + <p className="text-muted-foreground">기술영업 벤더 관련 상세사항을 확인하세요.</p> + </div> + <Separator className="my-6" /> + <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0"> + <aside className="-mx-4 lg:w-1/5"> + <SidebarNav items={sidebarNavItems} /> + </aside> + <div className="flex-1">{children}</div> + </div> + </div> + </section> + </div> + </> + ) +}
\ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/page.tsx b/app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/page.tsx new file mode 100644 index 00000000..a57d6df7 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/page.tsx @@ -0,0 +1,55 @@ +import { Separator } from "@/components/ui/separator" +import { getTechVendorContacts } from "@/lib/tech-vendors/service" +import { type SearchParams } from "@/types/table" +import { getValidFilters } from "@/lib/data-table" +import { searchParamsContactCache } from "@/lib/tech-vendors/validations" +import { TechVendorContactsTable } from "@/lib/tech-vendors/contacts-table/contact-table" + +interface IndexPageProps { + // Next.js 13 App Router에서 기본으로 주어지는 객체들 + params: { + lng: string + id: string + } + searchParams: Promise<SearchParams> +} + +export default async function SettingsAccountPage(props: IndexPageProps) { + const resolvedParams = await props.params + const id = resolvedParams.id + + const idAsNumber = Number(id) + + // 2) SearchParams 파싱 (Zod) + // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 + const searchParams = await props.searchParams + const search = searchParamsContactCache.parse(searchParams) + const validFilters = getValidFilters(search.filters) + + + + const promises = Promise.all([ + getTechVendorContacts({ + ...search, + filters: validFilters, + }, + idAsNumber) + ]) + // 4) 렌더링 + return ( + <div className="space-y-6"> + <div> + <h3 className="text-lg font-medium"> + Contacts + </h3> + <p className="text-sm text-muted-foreground"> + 업무별 담당자 정보를 확인하세요. + </p> + </div> + <Separator /> + <div> + <TechVendorContactsTable promises={promises} vendorId={idAsNumber}/> + </div> + </div> + ) +}
\ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/rfq-history/page.tsx b/app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/rfq-history/page.tsx new file mode 100644 index 00000000..4ed2b39f --- /dev/null +++ b/app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/rfq-history/page.tsx @@ -0,0 +1,55 @@ +// import { Separator } from "@/components/ui/separator"
+// import { getRfqHistory } from "@/lib/vendors/service"
+// import { type SearchParams } from "@/types/table"
+// import { getValidFilters } from "@/lib/data-table"
+// import { searchParamsRfqHistoryCache } from "@/lib/vendors/validations"
+// import { TechVendorRfqHistoryTable } from "@/lib/tech-vendors/rfq-history-table/rfq-history-table"
+
+// interface IndexPageProps {
+// // Next.js 13 App Router에서 기본으로 주어지는 객체들
+// params: {
+// lng: string
+// id: string
+// }
+// searchParams: Promise<SearchParams>
+// }
+
+// export default async function RfqHistoryPage(props: IndexPageProps) {
+// const resolvedParams = await props.params
+// const lng = resolvedParams.lng
+// const id = resolvedParams.id
+
+// const idAsNumber = Number(id)
+
+// // 2) SearchParams 파싱 (Zod)
+// // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
+// const searchParams = await props.searchParams
+// const search = searchParamsRfqHistoryCache.parse(searchParams)
+// const validFilters = getValidFilters(search.filters)
+
+// const promises = Promise.all([
+// getRfqHistory({
+// ...search,
+// filters: validFilters,
+// },
+// idAsNumber)
+// ])
+
+// // 4) 렌더링
+// return (
+// <div className="space-y-6">
+// <div>
+// <h3 className="text-lg font-medium">
+// RFQ History
+// </h3>
+// <p className="text-sm text-muted-foreground">
+// 협력업체의 RFQ 참여 이력을 확인할 수 있습니다.
+// </p>
+// </div>
+// <Separator />
+// <div>
+// <TechVendorRfqHistoryTable promises={promises} vendorId={idAsNumber} />
+// </div>
+// </div>
+// )
+// }
\ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/tech-vendors/page.tsx b/app/[lng]/evcp/(evcp)/tech-vendors/page.tsx new file mode 100644 index 00000000..64e8737f --- /dev/null +++ b/app/[lng]/evcp/(evcp)/tech-vendors/page.tsx @@ -0,0 +1,75 @@ +import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+import { Ellipsis } from "lucide-react"
+
+import { searchParamsCache } from "@/lib/tech-vendors/validations"
+import { getTechVendors, getTechVendorStatusCounts } from "@/lib/tech-vendors/service"
+import { TechVendorsTable } from "@/lib/tech-vendors/table/tech-vendors-table"
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getTechVendors({
+ ...search,
+ filters: validFilters,
+ }),
+ getTechVendorStatusCounts(),
+ ])
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ 기술영업 벤더 리스트
+ </h2>
+ <p className="text-muted-foreground">
+ 기술영업 벤더에 대한 요약 정보를 확인하고{" "}
+ <span className="inline-flex items-center whitespace-nowrap">
+ <Ellipsis className="size-3" />
+ <span className="ml-1">버튼</span>
+ </span>
+ 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다.
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="ml-auto w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ /> */}
+ </React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <TechVendorsTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
\ No newline at end of file diff --git a/lib/tech-vendors/items-table/add-item-dialog.tsx b/lib/tech-vendors/items-table/add-item-dialog.tsx index bd1c32f5..e4d74204 100644 --- a/lib/tech-vendors/items-table/add-item-dialog.tsx +++ b/lib/tech-vendors/items-table/add-item-dialog.tsx @@ -37,14 +37,14 @@ import { type CreateTechVendorItemSchema, } from "@/lib/tech-vendors/validations" -import { createTechVendorItem, getItemsByVendorType, ItemDropdownOption } from "../service" +import { createTechVendorItem, getItemsForTechVendor, ItemDropdownOption } from "../service" interface AddItemDialogProps { vendorId: number - vendorType: string + vendorType: string // UI에서 전달하지만 내부적으로는 사용하지 않음 } -export function AddItemDialog({ vendorId, vendorType }: AddItemDialogProps) { +export function AddItemDialog({ vendorId }: AddItemDialogProps) { const router = useRouter() const [open, setOpen] = React.useState(false) const [commandOpen, setCommandOpen] = React.useState(false) @@ -69,30 +69,21 @@ export function AddItemDialog({ vendorId, vendorType }: AddItemDialogProps) { const fetchItems = React.useCallback(async () => { if (items.length > 0) return - console.log(`[AddItemDialog] fetchItems - 벤더 타입: ${vendorType || '알 수 없음'}, 벤더 ID: ${vendorId} 시작`) - - if (!vendorType) { - console.error("[AddItemDialog] 벤더 타입이 지정되지 않았습니다. 아이템을 불러올 수 없습니다.") - toast.error("벤더 타입이 지정되지 않아 아이템을 불러올 수 없습니다.") - setIsLoading(false) - return - } + console.log(`[AddItemDialog] fetchItems - 벤더 ID: ${vendorId} 시작`) setIsLoading(true) try { - console.log(`[AddItemDialog] getItemsByVendorType 호출 - 타입: ${vendorType}`) - const result = await getItemsByVendorType(vendorType, "") - console.log(`[AddItemDialog] getItemsByVendorType 결과:`, result) + console.log(`[AddItemDialog] getItemsForTechVendor 호출 - vendorId: ${vendorId}`) + const result = await getItemsForTechVendor(vendorId) + console.log(`[AddItemDialog] getItemsForTechVendor 결과:`, result) if (result.data) { - const formattedItems = result.data.map(item => ({ - itemCode: item.itemCode, - itemName: "기술영업", - description: "" - })) - console.log(`[AddItemDialog] 포맷된 아이템 목록:`, formattedItems) - setItems(formattedItems) - setFilteredItems(formattedItems) + console.log(`[AddItemDialog] 사용 가능한 아이템 목록:`, result.data) + setItems(result.data) + setFilteredItems(result.data) + } else if (result.error) { + console.error("[AddItemDialog] 아이템 조회 실패:", result.error) + toast.error(result.error) } } catch (err) { console.error("[AddItemDialog] 아이템 조회 실패:", err) @@ -101,7 +92,7 @@ export function AddItemDialog({ vendorId, vendorType }: AddItemDialogProps) { setIsLoading(false) console.log(`[AddItemDialog] fetchItems 완료`) } - }, [items.length, vendorType, vendorId]) + }, [items.length, vendorId]) React.useEffect(() => { if (commandOpen) { @@ -157,7 +148,7 @@ export function AddItemDialog({ vendorId, vendorType }: AddItemDialogProps) { console.log(`[AddItemDialog] createTechVendorItem 호출 - vendorId: ${data.vendorId}, itemCode: ${data.itemCode}`) const submitData = { ...data, - itemName: "기술영업" + itemName: selectedItem?.itemName || "기술영업" } console.log(`[AddItemDialog] 최종 제출 데이터:`, submitData) diff --git a/lib/tech-vendors/items-table/item-table.tsx b/lib/tech-vendors/items-table/item-table.tsx index 52e5a57f..2eecd829 100644 --- a/lib/tech-vendors/items-table/item-table.tsx +++ b/lib/tech-vendors/items-table/item-table.tsx @@ -11,17 +11,20 @@ import { useDataTable } from "@/hooks/use-data-table" import { DataTable } from "@/components/data-table/data-table" import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" import { getColumns } from "./item-table-columns" -import { TechVendorItemsView } from "@/db/schema/techVendors" +import { getVendorItemsByType } from "@/lib/tech-vendors/service" import { TechVendorItemsTableToolbarActions } from "./item-table-toolbar-actions" interface TechVendorItemsTableProps { - data: (TechVendorItemsView & { techVendorType?: string })[] + promises: Promise<Awaited<ReturnType<typeof getVendorItemsByType>>> vendorId: number vendorType: string } -export function TechVendorItemsTable({ data, vendorId, vendorType }: TechVendorItemsTableProps) { - const [rowAction, setRowAction] = React.useState<DataTableRowAction<TechVendorItemsView> | null>(null) +export function TechVendorItemsTable({ promises, vendorId, vendorType }: TechVendorItemsTableProps) { + // Suspense로 받아온 데이터 + const { data } = React.use(promises) + + const [rowAction, setRowAction] = React.useState<DataTableRowAction<any> | null>(null) const columns = React.useMemo( () => getColumns({ @@ -31,11 +34,13 @@ export function TechVendorItemsTable({ data, vendorId, vendorType }: TechVendorI [vendorType] ) - const filterFields: DataTableFilterField<TechVendorItemsView>[] = [] + const filterFields: DataTableFilterField<any>[] = [] - const advancedFilterFields: DataTableAdvancedFilterField<TechVendorItemsView>[] = [ - { id: "itemName", label: "Item Name", type: "text" }, + const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [ + { id: "itemList", label: "Item List", type: "text" }, { id: "itemCode", label: "Item Code", type: "text" }, + { id: "workType", label: "Work Type", type: "text" }, + { id: "subItemList", label: "Sub Item List", type: "text" }, { id: "createdAt", label: "Created at", type: "date" }, { id: "updatedAt", label: "Updated at", type: "date" }, ] diff --git a/lib/tech-vendors/repository.ts b/lib/tech-vendors/repository.ts index b71fb32d..72c01a1c 100644 --- a/lib/tech-vendors/repository.ts +++ b/lib/tech-vendors/repository.ts @@ -38,7 +38,11 @@ export async function selectTechVendorsWithAttachments( representativeEmail: techVendors.representativeEmail, representativePhone: techVendors.representativePhone, representativeBirth: techVendors.representativeBirth, - corporateRegistrationNumber: techVendors.corporateRegistrationNumber, + countryEng: techVendors.countryEng, + countryFab: techVendors.countryFab, + agentName: techVendors.agentName, + agentPhone: techVendors.agentPhone, + agentEmail: techVendors.agentEmail, items: techVendors.items, createdAt: techVendors.createdAt, updatedAt: techVendors.updatedAt, diff --git a/lib/tech-vendors/service.ts b/lib/tech-vendors/service.ts index 657314e6..7513a283 100644 --- a/lib/tech-vendors/service.ts +++ b/lib/tech-vendors/service.ts @@ -34,7 +34,7 @@ import type { CreateTechVendorItemSchema, } from "./validations"; -import { asc, desc, ilike, inArray, and, or, eq, isNull } from "drizzle-orm"; +import { asc, desc, ilike, inArray, and, or, eq, isNull, not } from "drizzle-orm"; import path from "path"; import fs from "fs/promises"; import { randomUUID } from "crypto"; @@ -144,9 +144,6 @@ export async function getTechVendorStatusCounts() { "ACTIVE": 0, "INACTIVE": 0, "BLACKLISTED": 0, - "PENDING_REVIEW": 0, - "IN_REVIEW": 0, - "REJECTED": 0 }; const result = await db.transaction(async (tx) => { @@ -267,6 +264,11 @@ export async function createTechVendor(input: CreateTechVendorSchema) { taxId: input.taxId, address: input.address || null, country: input.country, + countryEng: null, + countryFab: null, + agentName: null, + agentPhone: null, + agentEmail: null, phone: input.phone || null, email: input.email, website: input.website || null, @@ -275,9 +277,8 @@ export async function createTechVendor(input: CreateTechVendorSchema) { representativeBirth: input.representativeBirth || null, representativeEmail: input.representativeEmail || null, representativePhone: input.representativePhone || null, - corporateRegistrationNumber: input.corporateRegistrationNumber || null, items: input.items || null, - status: "PENDING_REVIEW" + status: "ACTIVE" }); // 2. 연락처 정보 등록 @@ -547,8 +548,10 @@ export async function getTechVendorItems(input: GetTechVendorItemsSchema, id: nu export interface ItemDropdownOption { itemCode: string; - itemName: string; - description: string | null; + itemList: string; + workType: string | null; + shipTypes: string | null; + subItemList: string | null; } /** @@ -559,31 +562,140 @@ export async function getItemsForTechVendor(vendorId: number) { return unstable_cache( async () => { try { - // 해당 vendorId가 이미 가지고 있는 itemCode 목록을 서브쿼리로 구함 - // 그 아이템코드를 제외(notIn)하여 모든 items 테이블에서 조회 - const itemsData = await db + // 1. 벤더 정보 조회로 벤더 타입 확인 + const vendor = await db.query.techVendors.findFirst({ + where: eq(techVendors.id, vendorId), + columns: { + techVendorType: true + } + }); + + if (!vendor) { + return { + data: [], + error: "벤더를 찾을 수 없습니다.", + }; + } + + // 2. 해당 벤더가 이미 가지고 있는 itemCode 목록 조회 + const existingItems = await db .select({ - itemCode: items.itemCode, - itemName: items.itemName, - description: items.description, + itemCode: techVendorPossibleItems.itemCode, }) - .from(items) - .leftJoin( - techVendorPossibleItems, - eq(items.itemCode, techVendorPossibleItems.itemCode) - ) - // vendorPossibleItems.vendorId가 이 vendorId인 행이 없는(즉 아직 등록되지 않은) 아이템만 - .where( - isNull(techVendorPossibleItems.id) - ) - .orderBy(asc(items.itemName)); + .from(techVendorPossibleItems) + .where(eq(techVendorPossibleItems.vendorId, vendorId)); + + const existingItemCodes = existingItems.map(item => item.itemCode); + + // 3. 벤더 타입에 따라 해당 타입의 아이템만 조회 + // let availableItems: ItemDropdownOption[] = []; + let availableItems: (typeof itemShipbuilding.$inferSelect | typeof itemOffshoreTop.$inferSelect | typeof itemOffshoreHull.$inferSelect)[] = []; + switch (vendor.techVendorType) { + case "조선": + const shipbuildingItems = await db + .select({ + id: itemShipbuilding.id, + createdAt: itemShipbuilding.createdAt, + updatedAt: itemShipbuilding.updatedAt, + itemCode: itemShipbuilding.itemCode, + itemList: itemShipbuilding.itemList, + workType: itemShipbuilding.workType, + shipTypes: itemShipbuilding.shipTypes, + }) + .from(itemShipbuilding) + .where( + existingItemCodes.length > 0 + ? not(inArray(itemShipbuilding.itemCode, existingItemCodes)) + : undefined + ) + .orderBy(asc(itemShipbuilding.itemCode)); + + availableItems = shipbuildingItems + .filter(item => item.itemCode != null) + .map(item => ({ + id: item.id, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + itemCode: item.itemCode!, + itemList: item.itemList || "조선 아이템", + workType: item.workType || "조선 관련 아이템", + shipTypes: item.shipTypes || "조선 관련 아이템" + })); + break; + + case "해양TOP": + const offshoreTopItems = await db + .select({ + id: itemOffshoreTop.id, + createdAt: itemOffshoreTop.createdAt, + updatedAt: itemOffshoreTop.updatedAt, + itemCode: itemOffshoreTop.itemCode, + itemList: itemOffshoreTop.itemList, + workType: itemOffshoreTop.workType, + subItemList: itemOffshoreTop.subItemList, + }) + .from(itemOffshoreTop) + .where( + existingItemCodes.length > 0 + ? not(inArray(itemOffshoreTop.itemCode, existingItemCodes)) + : undefined + ) + .orderBy(asc(itemOffshoreTop.itemCode)); + + availableItems = offshoreTopItems + .filter(item => item.itemCode != null) + .map(item => ({ + id: item.id, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + itemCode: item.itemCode!, + itemList: item.itemList || "해양TOP 아이템", + workType: item.workType || "해양TOP 관련 아이템", + subItemList: item.subItemList || "해양TOP 관련 아이템" + })); + break; + + case "해양HULL": + const offshoreHullItems = await db + .select({ + id: itemOffshoreHull.id, + createdAt: itemOffshoreHull.createdAt, + updatedAt: itemOffshoreHull.updatedAt, + itemCode: itemOffshoreHull.itemCode, + itemList: itemOffshoreHull.itemList, + workType: itemOffshoreHull.workType, + subItemList: itemOffshoreHull.subItemList, + }) + .from(itemOffshoreHull) + .where( + existingItemCodes.length > 0 + ? not(inArray(itemOffshoreHull.itemCode, existingItemCodes)) + : undefined + ) + .orderBy(asc(itemOffshoreHull.itemCode)); + + availableItems = offshoreHullItems + .filter(item => item.itemCode != null) + .map(item => ({ + id: item.id, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + itemCode: item.itemCode!, + itemList: item.itemList || "해양HULL 아이템", + workType: item.workType || "해양HULL 관련 아이템", + subItemList: item.subItemList || "해양HULL 관련 아이템" + })); + break; + + default: + return { + data: [], + error: `지원하지 않는 벤더 타입입니다: ${vendor.techVendorType}`, + }; + } return { - data: itemsData.map((item) => ({ - itemCode: item.itemCode ?? "", // null이라면 ""로 치환 - itemName: item.itemName, - description: item.description ?? "" // null이라면 ""로 치환 - })), + data: availableItems, error: null }; } catch (err) { @@ -827,7 +939,7 @@ export async function rejectTechVendors(input: ApproveTechVendorsInput) { const [updated] = await tx .update(techVendors) .set({ - status: "REJECTED", + status: "INACTIVE", updatedAt: new Date() }) .where(inArray(techVendors.id, input.ids.map(id => parseInt(id)))) @@ -918,7 +1030,6 @@ export async function exportTechVendorDetails(vendorIds: number[]) { representativeEmail: techVendors.representativeEmail, representativePhone: techVendors.representativePhone, representativeBirth: techVendors.representativeBirth, - corporateRegistrationNumber: techVendors.corporateRegistrationNumber, items: techVendors.items, createdAt: techVendors.createdAt, updatedAt: techVendors.updatedAt, @@ -1090,75 +1201,124 @@ export const findVendorById = async (id: number): Promise<TechVendor | null> => export async function importTechVendorsFromExcel( vendors: Array<{ vendorName: string; + vendorCode?: string | null; email: string; taxId: string; - address?: string; - country?: string; - phone?: string; - website?: string; + country?: string | null; + countryEng?: string | null; + countryFab?: string | null; + agentName?: string | null; + agentPhone?: string | null; + agentEmail?: string | null; + address?: string | null; + phone?: string | null; + website?: string | null; techVendorType: string; - items: string; // 쉼표로 구분된 아이템 코드들 + representativeName?: string | null; + representativeEmail?: string | null; + representativePhone?: string | null; + representativeBirth?: string | null; + items: string; }>, ) { unstable_noStore(); try { + console.log("Import 시작 - 벤더 수:", vendors.length); + console.log("첫 번째 벤더 데이터:", vendors[0]); + const result = await db.transaction(async (tx) => { const createdVendors = []; for (const vendor of vendors) { - // 1. 벤더 생성 - const [newVendor] = await tx.insert(techVendors).values({ - vendorName: vendor.vendorName, - vendorCode: null, // 자동 생성 - taxId: vendor.taxId, - address: vendor.address || null, - country: vendor.country || null, - phone: vendor.phone || null, - email: vendor.email, - website: vendor.website || null, - techVendorType: vendor.techVendorType as "조선" | "해양TOP" | "해양HULL", - status: "PENDING_REVIEW" - }).returning(); - - // 2. 유저 생성 (이메일이 있는 경우) - if (vendor.email) { - // 이미 존재하는 유저인지 확인 - const existingUser = await tx.query.users.findFirst({ - where: eq(users.email, vendor.email), - columns: { id: true } + console.log("벤더 처리 시작:", vendor.vendorName); + + try { + // 1. 벤더 생성 + console.log("벤더 생성 시도:", { + vendorName: vendor.vendorName, + email: vendor.email, + techVendorType: vendor.techVendorType }); - // 유저가 존재하지 않는 경우에만 생성 - if (!existingUser) { - await tx.insert(users).values({ - name: vendor.vendorName, - email: vendor.email, - companyId: newVendor.id, - domain: "partners", + const [newVendor] = await tx.insert(techVendors).values({ + vendorName: vendor.vendorName, + vendorCode: vendor.vendorCode || null, + taxId: vendor.taxId, + country: vendor.country || null, + countryEng: vendor.countryEng || null, + countryFab: vendor.countryFab || null, + agentName: vendor.agentName || null, + agentPhone: vendor.agentPhone || null, + agentEmail: vendor.agentEmail || null, + address: vendor.address || null, + phone: vendor.phone || null, + email: vendor.email, + website: vendor.website || null, + techVendorType: vendor.techVendorType as "조선" | "해양TOP" | "해양HULL", + status: "ACTIVE", + representativeName: vendor.representativeName || null, + representativeEmail: vendor.representativeEmail || null, + representativePhone: vendor.representativePhone || null, + representativeBirth: vendor.representativeBirth || null, + }).returning(); + + console.log("벤더 생성 성공:", newVendor.id); + + // 2. 유저 생성 (이메일이 있는 경우) + if (vendor.email) { + console.log("유저 생성 시도:", vendor.email); + + // 이미 존재하는 유저인지 확인 + const existingUser = await tx.query.users.findFirst({ + where: eq(users.email, vendor.email), + columns: { id: true } }); - } - } - // 3. 아이템 등록 - if (vendor.items) { - const itemCodes = vendor.items.split(',').map(code => code.trim()); - for (const itemCode of itemCodes) { - // 아이템 정보 조회 - const [item] = await tx.select().from(items).where(eq(items.itemCode, itemCode)); - if (item && item.itemCode && item.itemName) { - await tx.insert(techVendorPossibleItems).values({ - vendorId: newVendor.id, - itemCode: item.itemCode, - itemName: item.itemName, + // 유저가 존재하지 않는 경우에만 생성 + if (!existingUser) { + await tx.insert(users).values({ + name: vendor.vendorName, + email: vendor.email, + techCompanyId: newVendor.id, // techCompanyId 설정 + domain: "partners", }); + console.log("유저 생성 성공"); + } else { + console.log("이미 존재하는 유저:", existingUser.id); } } - } - createdVendors.push(newVendor); + // // 3. 아이템 등록 + // if (vendor.items) { + // console.log("아이템 등록 시도:", vendor.items); + // const itemCodes = vendor.items.split(',').map(code => code.trim()); + + // for (const itemCode of itemCodes) { + // // 아이템 정보 조회 + // const [item] = await tx.select().from(items).where(eq(items.itemCode, itemCode)); + // if (item && item.itemCode && item.itemName) { + // await tx.insert(techVendorPossibleItems).values({ + // vendorId: newVendor.id, + // itemCode: item.itemCode, + // itemName: item.itemName, + // }); + // console.log("아이템 등록 성공:", itemCode); + // } else { + // console.log("아이템을 찾을 수 없음:", itemCode); + // } + // } + // } + + createdVendors.push(newVendor); + console.log("벤더 처리 완료:", vendor.vendorName); + } catch (error) { + console.error("벤더 처리 중 오류 발생:", vendor.vendorName, error); + throw error; + } } + console.log("모든 벤더 처리 완료. 생성된 벤더 수:", createdVendors.length); return createdVendors; }); @@ -1166,9 +1326,130 @@ export async function importTechVendorsFromExcel( revalidateTag("tech-vendors"); revalidateTag("users"); + console.log("Import 완료 - 결과:", result); + return { success: true, data: result }; + } catch (error) { + console.error("Import 실패:", error); + return { success: false, error: getErrorMessage(error) }; + } +} + +export async function findTechVendorById(id: number): Promise<TechVendor | null> { + const result = await db + .select() + .from(techVendors) + .where(eq(techVendors.id, id)) + .limit(1) + + return result[0] || null +} + +/** + * 단일 기술영업 벤더 추가 (사용자 계정도 함께 생성) + */ +export async function addTechVendor(input: { + vendorName: string; + vendorCode?: string | null; + email: string; + taxId: string; + country?: string | null; + countryEng?: string | null; + countryFab?: string | null; + agentName?: string | null; + agentPhone?: string | null; + agentEmail?: string | null; + address?: string | null; + phone?: string | null; + website?: string | null; + techVendorType: "조선" | "해양TOP" | "해양HULL"; + representativeName?: string | null; + representativeEmail?: string | null; + representativePhone?: string | null; + representativeBirth?: string | null; +}) { + unstable_noStore(); + + try { + console.log("벤더 추가 시작:", input.vendorName); + + const result = await db.transaction(async (tx) => { + // 1. 이메일 중복 체크 + const existingVendor = await tx.query.techVendors.findFirst({ + where: eq(techVendors.email, input.email), + columns: { id: true, vendorName: true } + }); + + if (existingVendor) { + throw new Error(`이미 등록된 이메일입니다: ${input.email} (업체명: ${existingVendor.vendorName})`); + } + + // 2. 벤더 생성 + console.log("벤더 생성 시도:", { + vendorName: input.vendorName, + email: input.email, + techVendorType: input.techVendorType + }); + + const [newVendor] = await tx.insert(techVendors).values({ + vendorName: input.vendorName, + vendorCode: input.vendorCode || null, + taxId: input.taxId, + country: input.country || null, + countryEng: input.countryEng || null, + countryFab: input.countryFab || null, + agentName: input.agentName || null, + agentPhone: input.agentPhone || null, + agentEmail: input.agentEmail || null, + address: input.address || null, + phone: input.phone || null, + email: input.email, + website: input.website || null, + techVendorType: input.techVendorType, + status: "ACTIVE", + representativeName: input.representativeName || null, + representativeEmail: input.representativeEmail || null, + representativePhone: input.representativePhone || null, + representativeBirth: input.representativeBirth || null, + }).returning(); + + console.log("벤더 생성 성공:", newVendor.id); + + // 3. 유저 생성 (techCompanyId 설정) + console.log("유저 생성 시도:", input.email); + + // 이미 존재하는 유저인지 확인 + const existingUser = await tx.query.users.findFirst({ + where: eq(users.email, input.email), + columns: { id: true } + }); + + let userId = null; + // 유저가 존재하지 않는 경우에만 생성 + if (!existingUser) { + const [newUser] = await tx.insert(users).values({ + name: input.vendorName, + email: input.email, + techCompanyId: newVendor.id, // techCompanyId 설정 + domain: "partners", + }).returning(); + userId = newUser.id; + console.log("유저 생성 성공:", userId); + } else { + console.log("이미 존재하는 유저:", existingUser.id); + } + + return { vendor: newVendor, userId }; + }); + + // 캐시 무효화 + revalidateTag("tech-vendors"); + revalidateTag("users"); + + console.log("벤더 추가 완료:", result); return { success: true, data: result }; } catch (error) { - console.error("Failed to import tech vendors:", error); + console.error("벤더 추가 실패:", error); return { success: false, error: getErrorMessage(error) }; } -}
\ No newline at end of file +} + diff --git a/lib/tech-vendors/table/add-vendor-dialog.tsx b/lib/tech-vendors/table/add-vendor-dialog.tsx new file mode 100644 index 00000000..bc260d51 --- /dev/null +++ b/lib/tech-vendors/table/add-vendor-dialog.tsx @@ -0,0 +1,458 @@ +"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+import { z } from "zod"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Textarea } from "@/components/ui/textarea"
+import { Plus, Loader2 } from "lucide-react"
+
+import { addTechVendor } from "../service"
+
+// 폼 스키마 정의
+const addVendorSchema = z.object({
+ vendorName: z.string().min(1, "업체명을 입력해주세요"),
+ vendorCode: z.string().optional(),
+ email: z.string().email("올바른 이메일 주소를 입력해주세요"),
+ taxId: z.string().optional(),
+ country: z.string().optional(),
+ countryEng: z.string().optional(),
+ countryFab: z.string().optional(),
+ agentName: z.string().optional(),
+ agentPhone: z.string().optional(),
+ agentEmail: z.string().email("올바른 이메일 주소를 입력해주세요").optional().or(z.literal("")),
+ address: z.string().optional(),
+ phone: z.string().optional(),
+ website: z.string().optional(),
+ techVendorType: z.enum(["조선", "해양TOP", "해양HULL"], {
+ required_error: "벤더 타입을 선택해주세요",
+ }),
+ representativeName: z.string().optional(),
+ representativeEmail: z.string().email("올바른 이메일 주소를 입력해주세요").optional().or(z.literal("")),
+ representativePhone: z.string().optional(),
+ representativeBirth: z.string().optional(),
+})
+
+type AddVendorFormData = z.infer<typeof addVendorSchema>
+
+interface AddVendorDialogProps {
+ onSuccess?: () => void
+}
+
+export function AddVendorDialog({ onSuccess }: AddVendorDialogProps) {
+ const [open, setOpen] = React.useState(false)
+ const [isLoading, setIsLoading] = React.useState(false)
+
+ const form = useForm<AddVendorFormData>({
+ resolver: zodResolver(addVendorSchema),
+ defaultValues: {
+ vendorName: "",
+ vendorCode: "",
+ email: "",
+ taxId: "",
+ country: "",
+ countryEng: "",
+ countryFab: "",
+ agentName: "",
+ agentPhone: "",
+ agentEmail: "",
+ address: "",
+ phone: "",
+ website: "",
+ techVendorType: undefined,
+ representativeName: "",
+ representativeEmail: "",
+ representativePhone: "",
+ representativeBirth: "",
+ },
+ })
+
+ const onSubmit = async (data: AddVendorFormData) => {
+ setIsLoading(true)
+ try {
+ const result = await addTechVendor({
+ ...data,
+ vendorCode: data.vendorCode || null,
+ country: data.country || null,
+ countryEng: data.countryEng || null,
+ countryFab: data.countryFab || null,
+ agentName: data.agentName || null,
+ agentPhone: data.agentPhone || null,
+ agentEmail: data.agentEmail || null,
+ address: data.address || null,
+ phone: data.phone || null,
+ website: data.website || null,
+ representativeName: data.representativeName || null,
+ representativeEmail: data.representativeEmail || null,
+ representativePhone: data.representativePhone || null,
+ representativeBirth: data.representativeBirth || null,
+ taxId: data.taxId || "",
+ })
+
+ if (result.success) {
+ toast.success("벤더가 성공적으로 추가되었습니다.")
+ form.reset()
+ setOpen(false)
+ onSuccess?.()
+ } else {
+ toast.error(result.error || "벤더 추가 중 오류가 발생했습니다.")
+ }
+ } catch (error) {
+ console.error("벤더 추가 오류:", error)
+ toast.error("벤더 추가 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogTrigger asChild>
+ <Button size="sm" className="gap-2">
+ <Plus className="size-4" />
+ 벤더 추가
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>새 기술영업 벤더 추가</DialogTitle>
+ <DialogDescription>
+ 새로운 기술영업 벤더 정보를 입력하고 사용자 계정을 생성합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
+ {/* 기본 정보 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-medium">기본 정보</h3>
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="vendorName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>업체명 *</FormLabel>
+ <FormControl>
+ <Input placeholder="업체명을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="vendorCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>업체 코드</FormLabel>
+ <FormControl>
+ <Input placeholder="업체 코드를 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="email"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>이메일 *</FormLabel>
+ <FormControl>
+ <Input type="email" placeholder="이메일을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="taxId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>사업자등록번호</FormLabel>
+ <FormControl>
+ <Input placeholder="사업자등록번호를 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <FormField
+ control={form.control}
+ name="techVendorType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>벤더 타입 *</FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="벤더 타입을 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="조선">조선</SelectItem>
+ <SelectItem value="해양TOP">해양TOP</SelectItem>
+ <SelectItem value="해양HULL">해양HULL</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 연락처 정보 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-medium">연락처 정보</h3>
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="phone"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>전화번호</FormLabel>
+ <FormControl>
+ <Input placeholder="전화번호를 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="website"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>웹사이트</FormLabel>
+ <FormControl>
+ <Input placeholder="웹사이트 URL을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <FormField
+ control={form.control}
+ name="address"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>주소</FormLabel>
+ <FormControl>
+ <Textarea placeholder="주소를 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 국가 정보 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-medium">국가 정보</h3>
+ <div className="grid grid-cols-3 gap-4">
+ <FormField
+ control={form.control}
+ name="country"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>국가</FormLabel>
+ <FormControl>
+ <Input placeholder="국가를 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="countryEng"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>국가 (영문)</FormLabel>
+ <FormControl>
+ <Input placeholder="국가 영문명을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="countryFab"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>제조국가</FormLabel>
+ <FormControl>
+ <Input placeholder="제조국가를 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+
+ {/* 담당자 정보 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-medium">담당자 정보</h3>
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="agentName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>담당자명</FormLabel>
+ <FormControl>
+ <Input placeholder="담당자명을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="agentPhone"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>담당자 전화번호</FormLabel>
+ <FormControl>
+ <Input placeholder="담당자 전화번호를 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ <FormField
+ control={form.control}
+ name="agentEmail"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>담당자 이메일</FormLabel>
+ <FormControl>
+ <Input type="email" placeholder="담당자 이메일을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 대표자 정보 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-medium">대표자 정보</h3>
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="representativeName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>대표자명</FormLabel>
+ <FormControl>
+ <Input placeholder="대표자명을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="representativePhone"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>대표자 전화번호</FormLabel>
+ <FormControl>
+ <Input placeholder="대표자 전화번호를 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="representativeEmail"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>대표자 이메일</FormLabel>
+ <FormControl>
+ <Input type="email" placeholder="대표자 이메일을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="representativeBirth"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>대표자 생년월일</FormLabel>
+ <FormControl>
+ <Input placeholder="YYYY-MM-DD" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setOpen(false)}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={isLoading}>
+ {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ 벤더 추가
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+}
\ No newline at end of file diff --git a/lib/tech-vendors/table/excel-template-download.tsx b/lib/tech-vendors/table/excel-template-download.tsx index 65b880da..db2c5fb5 100644 --- a/lib/tech-vendors/table/excel-template-download.tsx +++ b/lib/tech-vendors/table/excel-template-download.tsx @@ -1,128 +1,150 @@ -import * as ExcelJS from 'exceljs';
-import { saveAs } from "file-saver";
-
-// 벤더 타입 enum
-const VENDOR_TYPES = ["조선", "해양TOP", "해양HULL"] as const;
-
-/**
- * 기술영업 벤더 데이터 가져오기를 위한 Excel 템플릿 파일 생성 및 다운로드
- */
-export async function exportTechVendorTemplate() {
- // 워크북 생성
- const workbook = new ExcelJS.Workbook();
- workbook.creator = 'Tech Vendor Management System';
- workbook.created = new Date();
-
- // 워크시트 생성
- const worksheet = workbook.addWorksheet('기술영업 벤더');
-
- // 컬럼 헤더 정의 및 스타일 적용
- worksheet.columns = [
- { header: '업체명', key: 'vendorName', width: 20 },
- { header: '이메일', key: 'email', width: 25 },
- { header: '사업자등록번호', key: 'taxId', width: 15 },
- { header: '벤더타입', key: 'techVendorType', width: 15 },
- { header: '주소', key: 'address', width: 30 },
- { header: '국가', key: 'country', width: 15 },
- { header: '전화번호', key: 'phone', width: 15 },
- { header: '웹사이트', key: 'website', width: 25 },
- { header: '아이템', key: 'items', width: 30 },
- ];
-
- // 헤더 스타일 적용
- const headerRow = worksheet.getRow(1);
- headerRow.font = { bold: true };
- headerRow.fill = {
- type: 'pattern',
- pattern: 'solid',
- fgColor: { argb: 'FFE0E0E0' }
- };
- headerRow.alignment = { vertical: 'middle', horizontal: 'center' };
-
- // 테두리 스타일 적용
- headerRow.eachCell((cell) => {
- cell.border = {
- top: { style: 'thin' },
- left: { style: 'thin' },
- bottom: { style: 'thin' },
- right: { style: 'thin' }
- };
- });
-
- // 샘플 데이터 추가
- const sampleData = [
- {
- vendorName: '샘플 업체 1',
- email: 'sample1@example.com',
- taxId: '123-45-67890',
- techVendorType: '조선',
- address: '서울시 강남구',
- country: '대한민국',
- phone: '02-1234-5678',
- website: 'https://example1.com',
- items: 'ITEM001,ITEM002'
- },
- {
- vendorName: '샘플 업체 2',
- email: 'sample2@example.com',
- taxId: '234-56-78901',
- techVendorType: '해양TOP',
- address: '부산시 해운대구',
- country: '대한민국',
- phone: '051-234-5678',
- website: 'https://example2.com',
- items: 'ITEM003,ITEM004'
- }
- ];
-
- // 데이터 행 추가
- sampleData.forEach(item => {
- worksheet.addRow(item);
- });
-
- // 데이터 행 스타일 적용
- worksheet.eachRow((row, rowNumber) => {
- if (rowNumber > 1) { // 헤더를 제외한 데이터 행
- row.eachCell((cell) => {
- cell.border = {
- top: { style: 'thin' },
- left: { style: 'thin' },
- bottom: { style: 'thin' },
- right: { style: 'thin' }
- };
- });
- }
- });
-
- // 워크시트에 벤더 타입 관련 메모 추가
- const infoRow = worksheet.addRow(['벤더 타입 안내: ' + VENDOR_TYPES.join(', ')]);
- infoRow.font = { bold: true, color: { argb: 'FF0000FF' } };
- worksheet.mergeCells(`A${infoRow.number}:I${infoRow.number}`);
-
- // 워크시트 보호 (선택적)
- worksheet.protect('', {
- selectLockedCells: true,
- selectUnlockedCells: true,
- formatColumns: true,
- formatRows: true,
- insertColumns: false,
- insertRows: true,
- insertHyperlinks: false,
- deleteColumns: false,
- deleteRows: true,
- sort: true,
- autoFilter: true,
- pivotTables: false
- });
-
- try {
- // 워크북을 Blob으로 변환
- const buffer = await workbook.xlsx.writeBuffer();
- const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
- saveAs(blob, 'tech-vendor-template.xlsx');
- return true;
- } catch (error) {
- console.error('Excel 템플릿 생성 오류:', error);
- throw error;
- }
+import * as ExcelJS from 'exceljs'; +import { saveAs } from "file-saver"; + +/** + * 기술영업 벤더 데이터 가져오기를 위한 Excel 템플릿 파일 생성 및 다운로드 + */ +export async function exportTechVendorTemplate() { + // 워크북 생성 + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'Tech Vendor Management System'; + workbook.created = new Date(); + + // 워크시트 생성 + const worksheet = workbook.addWorksheet('기술영업 벤더'); + + // 컬럼 헤더 정의 및 스타일 적용 + worksheet.columns = [ + { header: '업체명', key: 'vendorName', width: 20 }, + { header: '업체코드', key: 'vendorCode', width: 15 }, + { header: '사업자등록번호', key: 'taxId', width: 15 }, + { header: '국가', key: 'country', width: 15 }, + { header: '영문국가명', key: 'countryEng', width: 15 }, + { header: '제조국', key: 'countryFab', width: 15 }, + { header: '대리점명', key: 'agentName', width: 20 }, + { header: '대리점연락처', key: 'agentPhone', width: 15 }, + { header: '대리점이메일', key: 'agentEmail', width: 25 }, + { header: '주소', key: 'address', width: 30 }, + { header: '전화번호', key: 'phone', width: 15 }, + { header: '이메일', key: 'email', width: 25 }, + { header: '웹사이트', key: 'website', width: 25 }, + { header: '벤더타입', key: 'techVendorType', width: 15 }, + { header: '대표자명', key: 'representativeName', width: 20 }, + { header: '대표자이메일', key: 'representativeEmail', width: 25 }, + { header: '대표자연락처', key: 'representativePhone', width: 15 }, + { header: '대표자생년월일', key: 'representativeBirth', width: 15 }, + { header: '아이템', key: 'items', width: 30 }, + ]; + + // 헤더 스타일 적용 + const headerRow = worksheet.getRow(1); + headerRow.font = { bold: true }; + headerRow.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE0E0E0' } + }; + headerRow.alignment = { vertical: 'middle', horizontal: 'center' }; + + // 테두리 스타일 적용 + headerRow.eachCell((cell) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + }); + + // 샘플 데이터 추가 + const sampleData = [ + { + vendorName: '샘플 업체 1', + vendorCode: 'TV001', + taxId: '123-45-67890', + country: '대한민국', + countryEng: 'Korea', + countryFab: '대한민국', + agentName: '대리점1', + agentPhone: '02-1234-5678', + agentEmail: 'agent1@example.com', + address: '서울시 강남구', + phone: '02-1234-5678', + email: 'sample1@example.com', + website: 'https://example1.com', + techVendorType: '조선', + representativeName: '홍길동', + representativeEmail: 'ceo1@example.com', + representativePhone: '010-1234-5678', + representativeBirth: '1980-01-01', + items: 'ITEM001,ITEM002' + }, + { + vendorName: '샘플 업체 2', + vendorCode: 'TV002', + taxId: '234-56-78901', + country: '대한민국', + countryEng: 'Korea', + countryFab: '대한민국', + agentName: '대리점2', + agentPhone: '051-234-5678', + agentEmail: 'agent2@example.com', + address: '부산시 해운대구', + phone: '051-234-5678', + email: 'sample2@example.com', + website: 'https://example2.com', + techVendorType: '해양TOP', + representativeName: '김철수', + representativeEmail: 'ceo2@example.com', + representativePhone: '010-2345-6789', + representativeBirth: '1985-02-02', + items: 'ITEM003,ITEM004' + } + ]; + + // 데이터 행 추가 + sampleData.forEach(item => { + worksheet.addRow(item); + }); + + // 데이터 행 스타일 적용 + worksheet.eachRow((row, rowNumber) => { + if (rowNumber > 1) { // 헤더를 제외한 데이터 행 + row.eachCell((cell) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + }); + } + }); + + // 워크시트 보호 (선택적) + worksheet.protect('', { + selectLockedCells: true, + selectUnlockedCells: true, + formatColumns: true, + formatRows: true, + insertColumns: false, + insertRows: true, + insertHyperlinks: false, + deleteColumns: false, + deleteRows: true, + sort: true, + autoFilter: true, + pivotTables: false + }); + + try { + // 워크북을 Blob으로 변환 + const buffer = await workbook.xlsx.writeBuffer(); + const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); + saveAs(blob, 'tech-vendor-template.xlsx'); + return true; + } catch (error) { + console.error('Excel 템플릿 생성 오류:', error); + throw error; + } }
\ No newline at end of file diff --git a/lib/tech-vendors/table/import-button.tsx b/lib/tech-vendors/table/import-button.tsx index 7346e5fe..ba01e150 100644 --- a/lib/tech-vendors/table/import-button.tsx +++ b/lib/tech-vendors/table/import-button.tsx @@ -1,293 +1,313 @@ -"use client"
-
-import * as React from "react"
-import { Upload } from "lucide-react"
-import { toast } from "sonner"
-import * as ExcelJS from 'exceljs'
-
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import { Progress } from "@/components/ui/progress"
-import { importTechVendorsFromExcel } from "../service"
-import { decryptWithServerAction } from "@/components/drm/drmUtils"
-
-interface ImportTechVendorButtonProps {
- onSuccess?: () => void;
-}
-
-export function ImportTechVendorButton({ onSuccess }: ImportTechVendorButtonProps) {
- const [open, setOpen] = React.useState(false);
- const [file, setFile] = React.useState<File | null>(null);
- const [isUploading, setIsUploading] = React.useState(false);
- const [progress, setProgress] = React.useState(0);
- const [error, setError] = React.useState<string | null>(null);
-
- const fileInputRef = React.useRef<HTMLInputElement>(null);
-
- // 파일 선택 처리
- const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
- const selectedFile = e.target.files?.[0];
- if (!selectedFile) return;
-
- if (!selectedFile.name.endsWith('.xlsx') && !selectedFile.name.endsWith('.xls')) {
- setError("Excel 파일(.xlsx 또는 .xls)만 가능합니다.");
- return;
- }
-
- setFile(selectedFile);
- setError(null);
- };
-
- // 데이터 가져오기 처리
- const handleImport = async () => {
- if (!file) {
- setError("가져올 파일을 선택해주세요.");
- return;
- }
-
- try {
- setIsUploading(true);
- setProgress(0);
- setError(null);
-
- // DRM 복호화 처리
- let arrayBuffer: ArrayBuffer;
- try {
- setProgress(10);
- toast.info("파일 복호화 중...");
- arrayBuffer = await decryptWithServerAction(file);
- setProgress(30);
- } catch (decryptError) {
- console.error("파일 복호화 실패, 원본 파일 사용:", decryptError);
- toast.warning("파일 복호화에 실패하여 원본 파일을 사용합니다.");
- arrayBuffer = await file.arrayBuffer();
- }
-
- // ExcelJS 워크북 로드
- const workbook = new ExcelJS.Workbook();
- await workbook.xlsx.load(arrayBuffer);
-
- // 첫 번째 워크시트 가져오기
- const worksheet = workbook.worksheets[0];
- if (!worksheet) {
- throw new Error("Excel 파일에 워크시트가 없습니다.");
- }
-
- // 헤더 행 찾기
- let headerRowIndex = 1;
- let headerRow: ExcelJS.Row | undefined;
- let headerValues: (string | null)[] = [];
-
- worksheet.eachRow((row, rowNumber) => {
- const values = row.values as (string | null)[];
- if (!headerRow && values.some(v => v === "업체명" || v === "vendorName")) {
- headerRowIndex = rowNumber;
- headerRow = row;
- headerValues = [...values];
- }
- });
-
- if (!headerRow) {
- throw new Error("Excel 파일에서 헤더 행을 찾을 수 없습니다.");
- }
-
- // 헤더를 기반으로 인덱스 매핑 생성
- const headerMapping: Record<string, number> = {};
- headerValues.forEach((value, index) => {
- if (typeof value === 'string') {
- headerMapping[value] = index;
- }
- });
-
- // 필수 헤더 확인
- const requiredHeaders = ["업체명", "이메일", "사업자등록번호", "벤더타입"];
- const alternativeHeaders = {
- "업체명": ["vendorName"],
- "이메일": ["email"],
- "사업자등록번호": ["taxId"],
- "벤더타입": ["techVendorType"],
- "주소": ["address"],
- "국가": ["country"],
- "전화번호": ["phone"],
- "웹사이트": ["website"],
- "아이템": ["items"]
- };
-
- // 헤더 매핑 확인 (대체 이름 포함)
- const missingHeaders = requiredHeaders.filter(header => {
- const alternatives = alternativeHeaders[header as keyof typeof alternativeHeaders] || [];
- return !(header in headerMapping) &&
- !alternatives.some(alt => alt in headerMapping);
- });
-
- if (missingHeaders.length > 0) {
- throw new Error(`다음 필수 헤더가 누락되었습니다: ${missingHeaders.join(", ")}`);
- }
-
- // 데이터 행 추출
- const dataRows: Record<string, any>[] = [];
-
- worksheet.eachRow((row, rowNumber) => {
- if (rowNumber > headerRowIndex) {
- const rowData: Record<string, any> = {};
- const values = row.values as (string | null | undefined)[];
-
- // 헤더 매핑에 따라 데이터 추출
- Object.entries(headerMapping).forEach(([header, index]) => {
- rowData[header] = values[index] || "";
- });
-
- // 빈 행이 아닌 경우만 추가
- if (Object.values(rowData).some(value => value && value.toString().trim() !== "")) {
- dataRows.push(rowData);
- }
- }
- });
-
- if (dataRows.length === 0) {
- throw new Error("Excel 파일에 가져올 데이터가 없습니다.");
- }
-
- // 진행 상황 업데이트를 위한 콜백
- const updateProgress = (current: number, total: number) => {
- const percentage = Math.round((current / total) * 100);
- setProgress(percentage);
- };
-
- // 벤더 데이터 처리
- const vendors = dataRows.map(row => ({
- vendorName: row["업체명"] || row["vendorName"] || "",
- email: row["이메일"] || row["email"] || "",
- taxId: row["사업자등록번호"] || row["taxId"] || "",
- techVendorType: row["벤더타입"] || row["techVendorType"] || "",
- address: row["주소"] || row["address"] || null,
- country: row["국가"] || row["country"] || null,
- phone: row["전화번호"] || row["phone"] || null,
- website: row["웹사이트"] || row["website"] || null,
- items: row["아이템"] || row["items"] || ""
- }));
-
- // 벤더 데이터 가져오기 실행
- const result = await importTechVendorsFromExcel(vendors);
-
- if (result.success) {
- toast.success(`${vendors.length}개의 기술영업 벤더가 성공적으로 가져와졌습니다.`);
- } else {
- toast.error(result.error || "벤더 가져오기에 실패했습니다.");
- }
-
- // 상태 초기화 및 다이얼로그 닫기
- setFile(null);
- setOpen(false);
-
- // 성공 콜백 호출
- if (onSuccess) {
- onSuccess();
- }
- } catch (error) {
- console.error("Excel 파일 처리 중 오류 발생:", error);
- setError(error instanceof Error ? error.message : "파일 처리 중 오류가 발생했습니다.");
- } finally {
- setIsUploading(false);
- }
- };
-
- // 다이얼로그 열기/닫기 핸들러
- const handleOpenChange = (newOpen: boolean) => {
- if (!newOpen) {
- // 닫을 때 상태 초기화
- setFile(null);
- setError(null);
- setProgress(0);
- if (fileInputRef.current) {
- fileInputRef.current.value = "";
- }
- }
- setOpen(newOpen);
- };
-
- return (
- <>
- <Button
- variant="outline"
- size="sm"
- className="gap-2"
- onClick={() => setOpen(true)}
- disabled={isUploading}
- >
- <Upload className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">Import</span>
- </Button>
-
- <Dialog open={open} onOpenChange={handleOpenChange}>
- <DialogContent className="sm:max-w-[500px]">
- <DialogHeader>
- <DialogTitle>기술영업 벤더 가져오기</DialogTitle>
- <DialogDescription>
- 기술영업 벤더를 Excel 파일에서 가져옵니다.
- <br />
- 올바른 형식의 Excel 파일(.xlsx)을 업로드하세요.
- </DialogDescription>
- </DialogHeader>
-
- <div className="space-y-4 py-4">
- <div className="flex items-center gap-4">
- <input
- type="file"
- ref={fileInputRef}
- className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-foreground file:font-medium"
- accept=".xlsx,.xls"
- onChange={handleFileChange}
- disabled={isUploading}
- />
- </div>
-
- {file && (
- <div className="text-sm text-muted-foreground">
- 선택된 파일: <span className="font-medium">{file.name}</span> ({(file.size / 1024).toFixed(1)} KB)
- </div>
- )}
-
- {isUploading && (
- <div className="space-y-2">
- <Progress value={progress} />
- <p className="text-sm text-muted-foreground text-center">
- {progress}% 완료
- </p>
- </div>
- )}
-
- {error && (
- <div className="text-sm font-medium text-destructive">
- {error}
- </div>
- )}
- </div>
-
- <DialogFooter>
- <Button
- variant="outline"
- onClick={() => setOpen(false)}
- disabled={isUploading}
- >
- 취소
- </Button>
- <Button
- onClick={handleImport}
- disabled={!file || isUploading}
- >
- {isUploading ? "처리 중..." : "가져오기"}
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- </>
- );
+"use client" + +import * as React from "react" +import { Upload } from "lucide-react" +import { toast } from "sonner" +import * as ExcelJS from 'exceljs' + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Progress } from "@/components/ui/progress" +import { importTechVendorsFromExcel } from "../service" +import { decryptWithServerAction } from "@/components/drm/drmUtils" + +interface ImportTechVendorButtonProps { + onSuccess?: () => void; +} + +export function ImportTechVendorButton({ onSuccess }: ImportTechVendorButtonProps) { + const [open, setOpen] = React.useState(false); + const [file, setFile] = React.useState<File | null>(null); + const [isUploading, setIsUploading] = React.useState(false); + const [progress, setProgress] = React.useState(0); + const [error, setError] = React.useState<string | null>(null); + + const fileInputRef = React.useRef<HTMLInputElement>(null); + + // 파일 선택 처리 + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const selectedFile = e.target.files?.[0]; + if (!selectedFile) return; + + if (!selectedFile.name.endsWith('.xlsx') && !selectedFile.name.endsWith('.xls')) { + setError("Excel 파일(.xlsx 또는 .xls)만 가능합니다."); + return; + } + + setFile(selectedFile); + setError(null); + }; + + // 데이터 가져오기 처리 + const handleImport = async () => { + if (!file) { + setError("가져올 파일을 선택해주세요."); + return; + } + + try { + setIsUploading(true); + setProgress(0); + setError(null); + + // DRM 복호화 처리 + let arrayBuffer: ArrayBuffer; + try { + setProgress(10); + toast.info("파일 복호화 중..."); + arrayBuffer = await decryptWithServerAction(file); + setProgress(30); + } catch (decryptError) { + console.error("파일 복호화 실패, 원본 파일 사용:", decryptError); + toast.warning("파일 복호화에 실패하여 원본 파일을 사용합니다."); + arrayBuffer = await file.arrayBuffer(); + } + + // ExcelJS 워크북 로드 + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(arrayBuffer); + + // 첫 번째 워크시트 가져오기 + const worksheet = workbook.worksheets[0]; + if (!worksheet) { + throw new Error("Excel 파일에 워크시트가 없습니다."); + } + + // 헤더 행 찾기 + let headerRowIndex = 1; + let headerRow: ExcelJS.Row | undefined; + let headerValues: (string | null)[] = []; + + worksheet.eachRow((row, rowNumber) => { + const values = row.values as (string | null)[]; + if (!headerRow && values.some(v => v === "업체명" || v === "vendorName")) { + headerRowIndex = rowNumber; + headerRow = row; + headerValues = [...values]; + } + }); + + if (!headerRow) { + throw new Error("Excel 파일에서 헤더 행을 찾을 수 없습니다."); + } + + // 헤더를 기반으로 인덱스 매핑 생성 + const headerMapping: Record<string, number> = {}; + headerValues.forEach((value, index) => { + if (typeof value === 'string') { + headerMapping[value] = index; + } + }); + + // 필수 헤더 확인 + const requiredHeaders = ["업체명", "이메일", "사업자등록번호", "벤더타입"]; + const alternativeHeaders = { + "업체명": ["vendorName"], + "업체코드": ["vendorCode"], + "이메일": ["email"], + "사업자등록번호": ["taxId"], + "국가": ["country"], + "영문국가명": ["countryEng"], + "제조국": ["countryFab"], + "대리점명": ["agentName"], + "대리점연락처": ["agentPhone"], + "대리점이메일": ["agentEmail"], + "주소": ["address"], + "전화번호": ["phone"], + "웹사이트": ["website"], + "벤더타입": ["techVendorType"], + "대표자명": ["representativeName"], + "대표자이메일": ["representativeEmail"], + "대표자연락처": ["representativePhone"], + "대표자생년월일": ["representativeBirth"], + "아이템": ["items"] + }; + + // 헤더 매핑 확인 (대체 이름 포함) + const missingHeaders = requiredHeaders.filter(header => { + const alternatives = alternativeHeaders[header as keyof typeof alternativeHeaders] || []; + return !(header in headerMapping) && + !alternatives.some(alt => alt in headerMapping); + }); + + if (missingHeaders.length > 0) { + throw new Error(`다음 필수 헤더가 누락되었습니다: ${missingHeaders.join(", ")}`); + } + + // 데이터 행 추출 + const dataRows: Record<string, any>[] = []; + + worksheet.eachRow((row, rowNumber) => { + if (rowNumber > headerRowIndex) { + const rowData: Record<string, any> = {}; + const values = row.values as (string | null | undefined)[]; + + // 헤더 매핑에 따라 데이터 추출 + Object.entries(headerMapping).forEach(([header, index]) => { + rowData[header] = values[index] || ""; + }); + + // 빈 행이 아닌 경우만 추가 + if (Object.values(rowData).some(value => value && value.toString().trim() !== "")) { + dataRows.push(rowData); + } + } + }); + + if (dataRows.length === 0) { + throw new Error("Excel 파일에 가져올 데이터가 없습니다."); + } + + // 진행 상황 업데이트를 위한 콜백 + const updateProgress = (current: number, total: number) => { + const percentage = Math.round((current / total) * 100); + setProgress(percentage); + }; + + // 벤더 데이터 처리 + const vendors = dataRows.map(row => ({ + vendorName: row["업체명"] || row["vendorName"] || "", + vendorCode: row["업체코드"] || row["vendorCode"] || null, + email: row["이메일"] || row["email"] || "", + taxId: row["사업자등록번호"] || row["taxId"] || "", + country: row["국가"] || row["country"] || null, + countryEng: row["영문국가명"] || row["countryEng"] || null, + countryFab: row["제조국"] || row["countryFab"] || null, + agentName: row["대리점명"] || row["agentName"] || null, + agentPhone: row["대리점연락처"] || row["agentPhone"] || null, + agentEmail: row["대리점이메일"] || row["agentEmail"] || null, + address: row["주소"] || row["address"] || null, + phone: row["전화번호"] || row["phone"] || null, + website: row["웹사이트"] || row["website"] || null, + techVendorType: row["벤더타입"] || row["techVendorType"] || "", + representativeName: row["대표자명"] || row["representativeName"] || null, + representativeEmail: row["대표자이메일"] || row["representativeEmail"] || null, + representativePhone: row["대표자연락처"] || row["representativePhone"] || null, + representativeBirth: row["대표자생년월일"] || row["representativeBirth"] || null, + items: row["아이템"] || row["items"] || "" + })); + + // 벤더 데이터 가져오기 실행 + const result = await importTechVendorsFromExcel(vendors); + + if (result.success) { + toast.success(`${vendors.length}개의 기술영업 벤더가 성공적으로 가져와졌습니다.`); + } else { + toast.error(result.error || "벤더 가져오기에 실패했습니다."); + } + + // 상태 초기화 및 다이얼로그 닫기 + setFile(null); + setOpen(false); + + // 성공 콜백 호출 + if (onSuccess) { + onSuccess(); + } + } catch (error) { + console.error("Excel 파일 처리 중 오류 발생:", error); + setError(error instanceof Error ? error.message : "파일 처리 중 오류가 발생했습니다."); + } finally { + setIsUploading(false); + } + }; + + // 다이얼로그 열기/닫기 핸들러 + const handleOpenChange = (newOpen: boolean) => { + if (!newOpen) { + // 닫을 때 상태 초기화 + setFile(null); + setError(null); + setProgress(0); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + } + setOpen(newOpen); + }; + + return ( + <> + <Button + variant="outline" + size="sm" + className="gap-2" + onClick={() => setOpen(true)} + disabled={isUploading} + > + <Upload className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Import</span> + </Button> + + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogContent className="sm:max-w-[500px]"> + <DialogHeader> + <DialogTitle>기술영업 벤더 가져오기</DialogTitle> + <DialogDescription> + 기술영업 벤더를 Excel 파일에서 가져옵니다. + <br /> + 올바른 형식의 Excel 파일(.xlsx)을 업로드하세요. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4 py-4"> + <div className="flex items-center gap-4"> + <input + type="file" + ref={fileInputRef} + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-foreground file:font-medium" + accept=".xlsx,.xls" + onChange={handleFileChange} + disabled={isUploading} + /> + </div> + + {file && ( + <div className="text-sm text-muted-foreground"> + 선택된 파일: <span className="font-medium">{file.name}</span> ({(file.size / 1024).toFixed(1)} KB) + </div> + )} + + {isUploading && ( + <div className="space-y-2"> + <Progress value={progress} /> + <p className="text-sm text-muted-foreground text-center"> + {progress}% 완료 + </p> + </div> + )} + + {error && ( + <div className="text-sm font-medium text-destructive"> + {error} + </div> + )} + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => setOpen(false)} + disabled={isUploading} + > + 취소 + </Button> + <Button + onClick={handleImport} + disabled={!file || isUploading} + > + {isUploading ? "처리 중..." : "가져오기"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </> + ); }
\ No newline at end of file diff --git a/lib/tech-vendors/table/tech-vendors-table-columns.tsx b/lib/tech-vendors/table/tech-vendors-table-columns.tsx index 438f4000..e586a667 100644 --- a/lib/tech-vendors/table/tech-vendors-table-columns.tsx +++ b/lib/tech-vendors/table/tech-vendors-table-columns.tsx @@ -217,45 +217,27 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef // Status badge variant mapping - 더 뚜렷한 색상으로 변경 const getStatusConfig = (status: StatusType): StatusConfig & { iconColor: string } => { switch (status) { - case "PENDING_REVIEW": - return { - variant: "outline", - className: "bg-yellow-100 text-yellow-800 border-yellow-300", - iconColor: "text-yellow-600" - }; - case "IN_REVIEW": - return { - variant: "outline", - className: "bg-blue-100 text-blue-800 border-blue-300", - iconColor: "text-blue-600" - }; - case "REJECTED": - return { - variant: "outline", - className: "bg-red-100 text-red-800 border-red-300", - iconColor: "text-red-600" - }; case "ACTIVE": return { - variant: "outline", + variant: "default", className: "bg-emerald-100 text-emerald-800 border-emerald-300 font-semibold", iconColor: "text-emerald-600" }; case "INACTIVE": return { - variant: "outline", + variant: "default", className: "bg-gray-100 text-gray-800 border-gray-300", iconColor: "text-gray-600" }; case "BLACKLISTED": return { - variant: "outline", + variant: "destructive", className: "bg-slate-800 text-white border-slate-900", iconColor: "text-white" }; default: return { - variant: "outline", + variant: "default", className: "bg-gray-100 text-gray-800 border-gray-300", iconColor: "text-gray-600" }; @@ -265,9 +247,6 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef // 상태 표시 텍스트 const getStatusDisplay = (status: StatusType): string => { const statusMap: StatusDisplayMap = { - "PENDING_REVIEW": "가입 신청 중", - "IN_REVIEW": "심사 중", - "REJECTED": "심사 거부됨", "ACTIVE": "활성 상태", "INACTIVE": "비활성 상태", "BLACKLISTED": "거래 금지" diff --git a/lib/tech-vendors/table/tech-vendors-table-toolbar-actions.tsx b/lib/tech-vendors/table/tech-vendors-table-toolbar-actions.tsx index 82383a3a..06b2cc42 100644 --- a/lib/tech-vendors/table/tech-vendors-table-toolbar-actions.tsx +++ b/lib/tech-vendors/table/tech-vendors-table-toolbar-actions.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { type Table } from "@tanstack/react-table" -import { Download, FileSpreadsheet, Upload, Check, BuildingIcon, FileText } from "lucide-react" +import { Download, FileSpreadsheet, FileText } from "lucide-react" import { toast } from "sonner" import { exportTableToExcel } from "@/lib/export" @@ -19,12 +19,14 @@ import { exportVendorsWithRelatedData } from "./vendor-all-export" import { TechVendor } from "@/db/schema/techVendors" import { ImportTechVendorButton } from "./import-button" import { exportTechVendorTemplate } from "./excel-template-download" +import { AddVendorDialog } from "./add-vendor-dialog" interface TechVendorsTableToolbarActionsProps { table: Table<TechVendor> + onRefresh?: () => void } -export function TechVendorsTableToolbarActions({ table }: TechVendorsTableToolbarActionsProps) { +export function TechVendorsTableToolbarActions({ table, onRefresh }: TechVendorsTableToolbarActionsProps) { const [isExporting, setIsExporting] = React.useState(false); // 선택된 모든 벤더 가져오기 @@ -82,9 +84,22 @@ export function TechVendorsTableToolbarActions({ table }: TechVendorsTableToolba setIsExporting(false); } }; + + // 벤더 추가 성공 시 테이블 새로고침을 위한 핸들러 + const handleVendorAddSuccess = () => { + // 테이블 데이터 리프레시 + if (onRefresh) { + onRefresh(); + } else { + window.location.reload(); // 간단한 새로고침 방법 + } + }; return ( <div className="flex items-center gap-2"> + {/* 벤더 추가 다이얼로그 추가 */} + <AddVendorDialog onSuccess={handleVendorAddSuccess} /> + {/* Import 버튼 추가 */} <ImportTechVendorButton onSuccess={() => { diff --git a/lib/tech-vendors/table/tech-vendors-table.tsx b/lib/tech-vendors/table/tech-vendors-table.tsx index 55632182..d6e6f99f 100644 --- a/lib/tech-vendors/table/tech-vendors-table.tsx +++ b/lib/tech-vendors/table/tech-vendors-table.tsx @@ -47,9 +47,6 @@ export function TechVendorsTable({ promises }: TechVendorsTableProps) { // 상태 한글 변환 유틸리티 함수 const getStatusDisplay = (status: string): string => { const statusMap: Record<string, string> = { - "PENDING_REVIEW": "가입 신청 중", - "IN_REVIEW": "심사 중", - "REJECTED": "심사 거부됨", "ACTIVE": "활성 상태", "INACTIVE": "비활성 상태", "BLACKLISTED": "거래 금지" @@ -111,6 +108,11 @@ export function TechVendorsTable({ promises }: TechVendorsTableProps) { const handleCompactChange = React.useCallback((compact: boolean) => { setIsCompact(compact) }, []) + + // 테이블 새로고침 핸들러 + const handleRefresh = React.useCallback(() => { + router.refresh() + }, [router]) return ( @@ -128,7 +130,7 @@ export function TechVendorsTable({ promises }: TechVendorsTableProps) { compactStorageKey="techVendorsTableCompact" onCompactChange={handleCompactChange} > - <TechVendorsTableToolbarActions table={table} /> + <TechVendorsTableToolbarActions table={table} onRefresh={handleRefresh} /> </DataTableAdvancedToolbar> </DataTable> <UpdateVendorSheet @@ -136,13 +138,6 @@ export function TechVendorsTable({ promises }: TechVendorsTableProps) { onOpenChange={() => setRowAction(null)} vendor={rowAction?.row.original ?? null} /> - - {/* ViewTechVendorLogsDialog 컴포넌트는 아직 구현되지 않았습니다. - <ViewTechVendorLogsDialog - open={rowAction?.type === "log"} - onOpenChange={() => setRowAction(null)} - vendorId={rowAction?.row.original?.id ?? null} - /> */} </> ) }
\ No newline at end of file diff --git a/lib/tech-vendors/table/update-vendor-sheet.tsx b/lib/tech-vendors/table/update-vendor-sheet.tsx index c33bbf03..cc6b4003 100644 --- a/lib/tech-vendors/table/update-vendor-sheet.tsx +++ b/lib/tech-vendors/table/update-vendor-sheet.tsx @@ -326,7 +326,7 @@ export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps) <FormLabel>업체승인상태</FormLabel> <FormControl> <Select - value={field.value} + value={field.value || ""} onValueChange={field.onChange} > <SelectTrigger className="w-full"> diff --git a/lib/tech-vendors/table/vendor-all-export.ts b/lib/tech-vendors/table/vendor-all-export.ts index 4278249a..a1ad4fd1 100644 --- a/lib/tech-vendors/table/vendor-all-export.ts +++ b/lib/tech-vendors/table/vendor-all-export.ts @@ -82,6 +82,13 @@ function createBasicInfoSheet( { header: "주소", key: "address", width: 30 }, { header: "대표자명", key: "representativeName", width: 15 }, { header: "생성일", key: "createdAt", width: 15 }, + { header: "벤더타입", key: "techVendorType", width: 15 }, + { header: "대리점명", key: "agentName", width: 15 }, + { header: "대리점연락처", key: "agentPhone", width: 15 }, + { header: "대리점이메일", key: "agentEmail", width: 25 }, + { header: "대리점주소", key: "agentAddress", width: 30 }, + { header: "대리점국가", key: "agentCountry", width: 15 }, + { header: "대리점영문국가명", key: "agentCountryEng", width: 20 }, ]; // 헤더 스타일 설정 @@ -240,9 +247,6 @@ function formatDate(date: Date | string): string { // 상태 코드를 읽기 쉬운 텍스트로 변환하는 함수 function getStatusText(status: string): string { const statusMap: Record<string, string> = { - "PENDING_REVIEW": "검토 대기중", - "IN_REVIEW": "검토 중", - "REJECTED": "거부됨", "ACTIVE": "활성", "INACTIVE": "비활성", "BLACKLISTED": "거래 금지" diff --git a/lib/tech-vendors/utils.ts b/lib/tech-vendors/utils.ts index b0bc33f0..ac49c78a 100644 --- a/lib/tech-vendors/utils.ts +++ b/lib/tech-vendors/utils.ts @@ -8,12 +8,6 @@ type StatusType = TechVendor["status"]; */ export function getVendorStatusIcon(status: StatusType): LucideIcon { switch (status) { - case "PENDING_REVIEW": - return Clock; - case "IN_REVIEW": - return Hourglass; - case "REJECTED": - return XCircle; case "ACTIVE": return CheckCircle2; case "INACTIVE": diff --git a/lib/tech-vendors/validations.ts b/lib/tech-vendors/validations.ts index 8bba3103..bae3e5b4 100644 --- a/lib/tech-vendors/validations.ts +++ b/lib/tech-vendors/validations.ts @@ -154,14 +154,13 @@ export const createTechVendorSchema = z website: z.string().url("유효하지 않은 URL입니다. https:// 혹은 http:// 로 시작하는 주소를 입력해주세요.").max(255).optional(), files: z.any().optional(), - status: z.enum(techVendors.status.enumValues).default("PENDING_REVIEW"), + status: z.enum(techVendors.status.enumValues).default("ACTIVE"), techVendorType: z.enum(VENDOR_TYPES).default("조선"), representativeName: z.union([z.string().max(255), z.literal("")]).optional(), representativeBirth: z.union([z.string().max(20), z.literal("")]).optional(), representativeEmail: z.union([z.string().email("Invalid email").max(255), z.literal("")]).optional(), representativePhone: z.union([z.string().max(50), z.literal("")]).optional(), - corporateRegistrationNumber: z.union([z.string().max(100), z.literal("")]).optional(), taxId: z.string().min(1, { message: "사업자등록번호를 입력해주세요" }), items: z.string().min(1, { message: "공급품목을 입력해주세요" }), @@ -201,13 +200,7 @@ export const createTechVendorSchema = z message: "대표자 전화번호는 한국(KR) 업체일 경우 필수입니다.", }) } - if (!data.corporateRegistrationNumber) { - ctx.addIssue({ - code: "custom", - path: ["corporateRegistrationNumber"], - message: "법인등록번호는 한국(KR) 업체일 경우 필수입니다.", - }) - } + } }); |
