diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-21 07:54:26 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-21 07:54:26 +0000 |
| commit | 14f61e24947fb92dd71ec0a7196a6e815f8e66da (patch) | |
| tree | 317c501d64662d05914330628f867467fba78132 /lib/tech-vendors | |
| parent | 194bd4bd7e6144d5c09c5e3f5476d254234dce72 (diff) | |
(최겸)기술영업 RFQ 담당자 초대, 요구사항 반영
Diffstat (limited to 'lib/tech-vendors')
28 files changed, 8450 insertions, 5880 deletions
diff --git a/lib/tech-vendors/contacts-table/add-contact-dialog.tsx b/lib/tech-vendors/contacts-table/add-contact-dialog.tsx index ff845e20..93ea6761 100644 --- a/lib/tech-vendors/contacts-table/add-contact-dialog.tsx +++ b/lib/tech-vendors/contacts-table/add-contact-dialog.tsx @@ -1,196 +1,196 @@ -"use client" - -import * as React from "react" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" - -import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" - -import { - createTechVendorContactSchema, - type CreateTechVendorContactSchema, -} from "@/lib/tech-vendors/validations" -import { createTechVendorContact } from "@/lib/tech-vendors/service" - -interface AddContactDialogProps { - vendorId: number -} - -export function AddContactDialog({ vendorId }: AddContactDialogProps) { - const [open, setOpen] = React.useState(false) - - // react-hook-form 세팅 - const form = useForm<CreateTechVendorContactSchema>({ - resolver: zodResolver(createTechVendorContactSchema), - defaultValues: { - // vendorId는 form에 표시할 필요가 없다면 hidden으로 관리하거나, submit 시 추가 - vendorId, - contactName: "", - contactPosition: "", - contactEmail: "", - contactPhone: "", - country: "", - isPrimary: false, - }, - }) - - async function onSubmit(data: CreateTechVendorContactSchema) { - // 혹은 여기서 data.vendorId = vendorId; 해줘도 됨 - const result = await createTechVendorContact(data) - if (result.error) { - alert(`에러: ${result.error}`) - return - } - - // 성공 시 메시지 표시 - if (result.data?.message) { - alert(result.data.message) - } - - // 성공 시 모달 닫고 폼 리셋 - form.reset() - setOpen(false) - } - - function handleDialogOpenChange(nextOpen: boolean) { - if (!nextOpen) { - form.reset() - } - setOpen(nextOpen) - } - - return ( - <Dialog open={open} onOpenChange={handleDialogOpenChange}> - {/* 모달을 열기 위한 버튼 */} - <DialogTrigger asChild> - <Button variant="default" size="sm"> - Add Contact - </Button> - </DialogTrigger> - - <DialogContent> - <DialogHeader> - <DialogTitle>Create New Contact</DialogTitle> - <DialogDescription> - 새 Contact 정보를 입력하고 <b>Create</b> 버튼을 누르세요. - </DialogDescription> - </DialogHeader> - - {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */} - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)}> - <div className="space-y-4 py-4"> - <FormField - control={form.control} - name="contactName" - render={({ field }) => ( - <FormItem> - <FormLabel>Contact Name</FormLabel> - <FormControl> - <Input placeholder="예: 홍길동" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="contactPosition" - render={({ field }) => ( - <FormItem> - <FormLabel>Position / Title</FormLabel> - <FormControl> - <Input placeholder="예: 과장" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="contactEmail" - render={({ field }) => ( - <FormItem> - <FormLabel>Email</FormLabel> - <FormControl> - <Input placeholder="name@company.com" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="contactPhone" - render={({ field }) => ( - <FormItem> - <FormLabel>Phone</FormLabel> - <FormControl> - <Input placeholder="010-1234-5678" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="country" - render={({ field }) => ( - <FormItem> - <FormLabel>Country</FormLabel> - <FormControl> - <Input placeholder="예: Korea" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 단순 checkbox */} - <FormField - control={form.control} - name="isPrimary" - render={({ field }) => ( - <FormItem> - <div className="flex items-center space-x-2 mt-2"> - <input - type="checkbox" - checked={field.value} - onChange={(e) => field.onChange(e.target.checked)} - /> - <FormLabel>Is Primary?</FormLabel> - </div> - <FormMessage /> - </FormItem> - )} - /> - </div> - - <DialogFooter> - <Button type="button" variant="outline" onClick={() => setOpen(false)}> - Cancel - </Button> - <Button type="submit" disabled={form.formState.isSubmitting}> - Create - </Button> - </DialogFooter> - </form> - </Form> - </DialogContent> - </Dialog> - ) +"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+
+import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+
+import {
+ createTechVendorContactSchema,
+ type CreateTechVendorContactSchema,
+} from "@/lib/tech-vendors/validations"
+import { createTechVendorContact } from "@/lib/tech-vendors/service"
+
+interface AddContactDialogProps {
+ vendorId: number
+}
+
+export function AddContactDialog({ vendorId }: AddContactDialogProps) {
+ const [open, setOpen] = React.useState(false)
+
+ // react-hook-form 세팅
+ const form = useForm<CreateTechVendorContactSchema>({
+ resolver: zodResolver(createTechVendorContactSchema),
+ defaultValues: {
+ // vendorId는 form에 표시할 필요가 없다면 hidden으로 관리하거나, submit 시 추가
+ vendorId,
+ contactName: "",
+ contactPosition: "",
+ contactEmail: "",
+ contactPhone: "",
+ contactCountry: "",
+ isPrimary: false,
+ },
+ })
+
+ async function onSubmit(data: CreateTechVendorContactSchema) {
+ // 혹은 여기서 data.vendorId = vendorId; 해줘도 됨
+ const result = await createTechVendorContact(data)
+ if (result.error) {
+ alert(`에러: ${result.error}`)
+ return
+ }
+
+ // 성공 시 메시지 표시
+ if (result.data?.message) {
+ alert(result.data.message)
+ }
+
+ // 성공 시 모달 닫고 폼 리셋
+ form.reset()
+ setOpen(false)
+ }
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ form.reset()
+ }
+ setOpen(nextOpen)
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ {/* 모달을 열기 위한 버튼 */}
+ <DialogTrigger asChild>
+ <Button variant="default" size="sm">
+ Add Contact
+ </Button>
+ </DialogTrigger>
+
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Create New Contact</DialogTitle>
+ <DialogDescription>
+ 새 Contact 정보를 입력하고 <b>Create</b> 버튼을 누르세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */}
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)}>
+ <div className="space-y-4 py-4">
+ <FormField
+ control={form.control}
+ name="contactName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Contact Name</FormLabel>
+ <FormControl>
+ <Input placeholder="예: 홍길동" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="contactPosition"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Position / Title</FormLabel>
+ <FormControl>
+ <Input placeholder="예: 과장" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="contactEmail"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Email</FormLabel>
+ <FormControl>
+ <Input placeholder="name@company.com" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="contactPhone"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Phone</FormLabel>
+ <FormControl>
+ <Input placeholder="010-1234-5678" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="contactCountry"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Contact Country</FormLabel>
+ <FormControl>
+ <Input placeholder="예: Korea" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 단순 checkbox */}
+ <FormField
+ control={form.control}
+ name="isPrimary"
+ render={({ field }) => (
+ <FormItem>
+ <div className="flex items-center space-x-2 mt-2">
+ <input
+ type="checkbox"
+ checked={field.value}
+ onChange={(e) => field.onChange(e.target.checked)}
+ />
+ <FormLabel>Is Primary?</FormLabel>
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <DialogFooter>
+ <Button type="button" variant="outline" onClick={() => setOpen(false)}>
+ Cancel
+ </Button>
+ <Button type="submit" disabled={form.formState.isSubmitting}>
+ Create
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
}
\ No newline at end of file diff --git a/lib/tech-vendors/contacts-table/contact-table-columns.tsx b/lib/tech-vendors/contacts-table/contact-table-columns.tsx index b8f4e7a2..1a65a58c 100644 --- a/lib/tech-vendors/contacts-table/contact-table-columns.tsx +++ b/lib/tech-vendors/contacts-table/contact-table-columns.tsx @@ -1,176 +1,176 @@ -"use client" - -import * as React from "react" -import { type DataTableRowAction } from "@/types/table" -import { type ColumnDef } from "@tanstack/react-table" -import { Ellipsis } from "lucide-react" - -import { formatDate } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { Checkbox } from "@/components/ui/checkbox" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" - -import { TechVendorContact } from "@/db/schema/techVendors" -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import { techVendorContactsColumnsConfig } from "@/config/techVendorContactsColumnsConfig" - -interface GetColumnsProps { - setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TechVendorContact> | null>>; -} - -/** - * tanstack table 컬럼 정의 (중첩 헤더 버전) - */ -export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<TechVendorContact>[] { - // ---------------------------------------------------------------- - // 1) select 컬럼 (체크박스) - // ---------------------------------------------------------------- - const selectColumn: ColumnDef<TechVendorContact> = { - id: "select", - header: ({ table }) => ( - <Checkbox - checked={ - table.getIsAllPageRowsSelected() || - (table.getIsSomePageRowsSelected() && "indeterminate") - } - onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} - aria-label="Select all" - className="translate-y-0.5" - /> - ), - cell: ({ row }) => ( - <Checkbox - checked={row.getIsSelected()} - onCheckedChange={(value) => row.toggleSelected(!!value)} - aria-label="Select row" - className="translate-y-0.5" - /> - ), - size:40, - enableSorting: false, - enableHiding: false, - } - - // ---------------------------------------------------------------- - // 2) actions 컬럼 (Dropdown 메뉴) - // ---------------------------------------------------------------- - const actionsColumn: ColumnDef<TechVendorContact> = { - id: "actions", - enableHiding: false, - cell: function Cell({ row }) { - return ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - aria-label="Open menu" - variant="ghost" - className="flex size-8 p-0 data-[state=open]:bg-muted" - > - <Ellipsis className="size-4" aria-hidden="true" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="w-40"> - <DropdownMenuItem - onSelect={() => { - setRowAction({ row, type: "update" }) - }} - > - Edit - </DropdownMenuItem> - - <DropdownMenuSeparator /> - <DropdownMenuItem - onSelect={() => setRowAction({ row, type: "delete" })} - > - Delete - <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - ) - }, - size: 40, - } - - // ---------------------------------------------------------------- - // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 - // ---------------------------------------------------------------- - // 3-1) groupMap: { [groupName]: ColumnDef<TechVendorContact>[] } - const groupMap: Record<string, ColumnDef<TechVendorContact>[]> = {} - - techVendorContactsColumnsConfig.forEach((cfg) => { - // 만약 group가 없으면 "_noGroup" 처리 - const groupName = cfg.group || "_noGroup" - - if (!groupMap[groupName]) { - groupMap[groupName] = [] - } - - // child column 정의 - const childCol: ColumnDef<TechVendorContact> = { - accessorKey: cfg.id, - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={cfg.label} /> - ), - meta: { - excelHeader: cfg.excelHeader, - group: cfg.group, - type: cfg.type, - }, - cell: ({ row, cell }) => { - if (cfg.id === "createdAt") { - const dateVal = cell.getValue() as Date - return formatDate(dateVal) - } - - if (cfg.id === "updatedAt") { - const dateVal = cell.getValue() as Date - return formatDate(dateVal) - } - - // code etc... - return row.getValue(cfg.id) ?? "" - }, - } - - groupMap[groupName].push(childCol) - }) - - // ---------------------------------------------------------------- - // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 - // ---------------------------------------------------------------- - const nestedColumns: ColumnDef<TechVendorContact>[] = [] - - // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 - // 여기서는 그냥 Object.entries 순서 - Object.entries(groupMap).forEach(([groupName, colDefs]) => { - if (groupName === "_noGroup") { - // 그룹 없음 → 그냥 최상위 레벨 컬럼 - nestedColumns.push(...colDefs) - } else { - // 상위 컬럼 - nestedColumns.push({ - id: groupName, - header: groupName, // "Basic Info", "Metadata" 등 - columns: colDefs, - }) - } - }) - - // ---------------------------------------------------------------- - // 4) 최종 컬럼 배열: select, nestedColumns, actions - // ---------------------------------------------------------------- - return [ - selectColumn, - ...nestedColumns, - actionsColumn, - ] +"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis } from "lucide-react"
+
+import { formatDate } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+import { TechVendorContact } from "@/db/schema/techVendors"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { techVendorContactsColumnsConfig } from "@/config/techVendorContactsColumnsConfig"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TechVendorContact> | null>>;
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<TechVendorContact>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<TechVendorContact> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size:40,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) actions 컬럼 (Dropdown 메뉴)
+ // ----------------------------------------------------------------
+ const actionsColumn: ColumnDef<TechVendorContact> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onSelect={() => {
+ setRowAction({ row, type: "update" })
+ }}
+ >
+ Edit
+ </DropdownMenuItem>
+
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ >
+ Delete
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ }
+
+ // ----------------------------------------------------------------
+ // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // ----------------------------------------------------------------
+ // 3-1) groupMap: { [groupName]: ColumnDef<TechVendorContact>[] }
+ const groupMap: Record<string, ColumnDef<TechVendorContact>[]> = {}
+
+ techVendorContactsColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<TechVendorContact> = {
+ accessorKey: cfg.id,
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
+ ),
+ meta: {
+ excelHeader: cfg.excelHeader,
+ group: cfg.group,
+ type: cfg.type,
+ },
+ cell: ({ row, cell }) => {
+ if (cfg.id === "createdAt") {
+ const dateVal = cell.getValue() as Date
+ return formatDate(dateVal)
+ }
+
+ if (cfg.id === "updatedAt") {
+ const dateVal = cell.getValue() as Date
+ return formatDate(dateVal)
+ }
+
+ // code etc...
+ return row.getValue(cfg.id) ?? ""
+ },
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // ----------------------------------------------------------------
+ // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
+ // ----------------------------------------------------------------
+ const nestedColumns: ColumnDef<TechVendorContact>[] = []
+
+ // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함
+ // 여기서는 그냥 Object.entries 순서
+ Object.entries(groupMap).forEach(([groupName, colDefs]) => {
+ if (groupName === "_noGroup") {
+ // 그룹 없음 → 그냥 최상위 레벨 컬럼
+ nestedColumns.push(...colDefs)
+ } else {
+ // 상위 컬럼
+ nestedColumns.push({
+ id: groupName,
+ header: groupName, // "Basic Info", "Metadata" 등
+ columns: colDefs,
+ })
+ }
+ })
+
+ // ----------------------------------------------------------------
+ // 4) 최종 컬럼 배열: select, nestedColumns, actions
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ ...nestedColumns,
+ actionsColumn,
+ ]
}
\ No newline at end of file diff --git a/lib/tech-vendors/contacts-table/contact-table-toolbar-actions.tsx b/lib/tech-vendors/contacts-table/contact-table-toolbar-actions.tsx index 7622c6d6..84228a54 100644 --- a/lib/tech-vendors/contacts-table/contact-table-toolbar-actions.tsx +++ b/lib/tech-vendors/contacts-table/contact-table-toolbar-actions.tsx @@ -1,103 +1,163 @@ -"use client" - -import * as React from "react" -import { type Table } from "@tanstack/react-table" -import { Download, Upload } from "lucide-react" -import { toast } from "sonner" - -import { exportTableToExcel } from "@/lib/export" -import { Button } from "@/components/ui/button" -import { TechVendorContact } from "@/db/schema/techVendors" -import { AddContactDialog } from "./add-contact-dialog" -import { importTasksExcel } from "@/lib/tasks/service" - -interface TechVendorContactsTableToolbarActionsProps { - table: Table<TechVendorContact> - vendorId: number -} - -export function TechVendorContactsTableToolbarActions({ table, vendorId }: TechVendorContactsTableToolbarActionsProps) { - // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 - const fileInputRef = React.useRef<HTMLInputElement>(null) - - // 파일이 선택되었을 때 처리 - async function onFileChange(event: React.ChangeEvent<HTMLInputElement>) { - const file = event.target.files?.[0] - if (!file) return - - // 파일 초기화 (동일 파일 재업로드 시에도 onChange가 트리거되도록) - event.target.value = "" - - // 서버 액션 or API 호출 - try { - // 예: 서버 액션 호출 - const { errorFile, errorMessage } = await importTasksExcel(file) - - if (errorMessage) { - toast.error(errorMessage) - } - if (errorFile) { - // 에러 엑셀을 다운로드 - const url = URL.createObjectURL(errorFile) - const link = document.createElement("a") - link.href = url - link.download = "errors.xlsx" - link.click() - URL.revokeObjectURL(url) - } else { - // 성공 - toast.success("Import success") - // 필요 시 revalidateTag("tasks") 등 - } - - } catch (error) { - toast.error("파일 업로드 중 오류가 발생했습니다.") - - } - } - - function handleImportClick() { - // 숨겨진 <input type="file" /> 요소를 클릭 - fileInputRef.current?.click() - } - - return ( - <div className="flex items-center gap-2"> - - <AddContactDialog vendorId={vendorId}/> - - {/** 3) Import 버튼 (파일 업로드) */} - <Button variant="outline" size="sm" className="gap-2" onClick={handleImportClick}> - <Upload className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">Import</span> - </Button> - {/* - 실제로는 숨겨진 input과 연결: - - accept=".xlsx,.xls" 등으로 Excel 파일만 업로드 허용 - */} - <input - ref={fileInputRef} - type="file" - accept=".xlsx,.xls" - className="hidden" - onChange={onFileChange} - /> - - {/** 4) Export 버튼 */} - <Button - variant="outline" - size="sm" - onClick={() => - exportTableToExcel(table, { - filename: "tech-vendor-contacts", - excludeColumns: ["select", "actions"], - }) - } - className="gap-2" - > - <Download className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">Export</span> - </Button> - </div> - ) +"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, Upload } from "lucide-react"
+import { toast } from "sonner"
+import ExcelJS from "exceljs"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { TechVendorContact } from "@/db/schema/techVendors"
+import { AddContactDialog } from "./add-contact-dialog"
+import {
+ importTechVendorContacts,
+ generateContactImportTemplate,
+ parseContactImportFile
+} from "@/lib/tech-vendors/service"
+
+interface TechVendorContactsTableToolbarActionsProps {
+ table: Table<TechVendorContact>
+ vendorId: number
+}
+
+export function TechVendorContactsTableToolbarActions({ table, vendorId }: TechVendorContactsTableToolbarActionsProps) {
+ // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+ // 파일이 선택되었을 때 처리
+ async function onFileChange(event: React.ChangeEvent<HTMLInputElement>) {
+ const file = event.target.files?.[0]
+ if (!file) return
+
+ // 파일 초기화 (동일 파일 재업로드 시에도 onChange가 트리거되도록)
+ event.target.value = ""
+
+ try {
+ // Excel 파일 파싱
+ const contactData = await parseContactImportFile(file)
+
+ if (contactData.length === 0) {
+ toast.error("유효한 데이터가 없습니다. 템플릿 형식을 확인해주세요.")
+ return
+ }
+
+ // 서버로 데이터 전송
+ const result = await importTechVendorContacts(contactData)
+
+ if (result.successCount > 0) {
+ toast.success(`${result.successCount}개 연락처가 성공적으로 추가되었습니다.`)
+ }
+
+ if (result.failedRows.length > 0) {
+ toast.error(`${result.failedRows.length}개 행에서 오류가 발생했습니다.`)
+
+ // 에러 데이터를 Excel로 다운로드
+ const errorWorkbook = new ExcelJS.Workbook()
+ const errorWorksheet = errorWorkbook.addWorksheet("오류내역")
+
+ // 헤더 추가
+ errorWorksheet.columns = [
+ { header: "행번호", key: "row", width: 10 },
+ { header: "벤더이메일", key: "vendorEmail", width: 25 },
+ { header: "담당자명", key: "contactName", width: 20 },
+ { header: "담당자이메일", key: "contactEmail", width: 25 },
+ { header: "오류내용", key: "error", width: 80, style: { alignment: { wrapText: true } , font: { color: { argb: "FFFF0000" } } } },
+ ]
+
+ // 오류 데이터 추가
+ result.failedRows.forEach(failedRow => {
+ errorWorksheet.addRow({
+ row: failedRow.row,
+ error: failedRow.error,
+ vendorEmail: failedRow.vendorEmail,
+ contactName: failedRow.contactName,
+ contactEmail: failedRow.contactEmail,
+ })
+ })
+
+ const buffer = await errorWorkbook.xlsx.writeBuffer()
+ const blob = new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ })
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement("a")
+ link.href = url
+ link.download = "contact-import-errors.xlsx"
+ link.click()
+ URL.revokeObjectURL(url)
+ }
+
+ } catch (error) {
+ toast.error("파일 업로드 중 오류가 발생했습니다.")
+ console.error("Import error:", error)
+ }
+ }
+
+ function handleImportClick() {
+ // 숨겨진 <input type="file" /> 요소를 클릭
+ fileInputRef.current?.click()
+ }
+
+ async function handleTemplateDownload() {
+ try {
+ const templateBlob = await generateContactImportTemplate()
+ const url = URL.createObjectURL(templateBlob)
+ const link = document.createElement("a")
+ link.href = url
+ link.download = "tech-vendor-contacts-template.xlsx"
+ link.click()
+ URL.revokeObjectURL(url)
+ toast.success("템플릿이 다운로드되었습니다.")
+ } catch (error) {
+ toast.error("템플릿 다운로드 중 오류가 발생했습니다.")
+ console.error("Template download error:", error)
+ }
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+
+ <AddContactDialog vendorId={vendorId}/>
+
+ {/** 템플릿 다운로드 버튼 */}
+ <Button variant="outline" size="sm" className="gap-2" onClick={handleTemplateDownload}>
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">템플릿</span>
+ </Button>
+
+ {/** Import 버튼 (파일 업로드) */}
+ <Button variant="outline" size="sm" className="gap-2" onClick={handleImportClick}>
+ <Upload className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Import</span>
+ </Button>
+ {/*
+ 실제로는 숨겨진 input과 연결:
+ - accept=".xlsx,.xls" 등으로 Excel 파일만 업로드 허용
+ */}
+ <input
+ ref={fileInputRef}
+ type="file"
+ accept=".xlsx,.xls"
+ className="hidden"
+ onChange={onFileChange}
+ />
+
+ {/** Export 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "tech-vendor-contacts",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ </div>
+ )
}
\ No newline at end of file diff --git a/lib/tech-vendors/contacts-table/contact-table.tsx b/lib/tech-vendors/contacts-table/contact-table.tsx index cccf490c..6029fe16 100644 --- a/lib/tech-vendors/contacts-table/contact-table.tsx +++ b/lib/tech-vendors/contacts-table/contact-table.tsx @@ -1,87 +1,93 @@ -"use client" - -import * as React from "react" -import type { - DataTableAdvancedFilterField, - DataTableFilterField, - DataTableRowAction, -} from "@/types/table" - -import { toSentenceCase } from "@/lib/utils" -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 { useFeatureFlags } from "./feature-flags-provider" -import { getColumns } from "./contact-table-columns" -import { getTechVendorContacts } from "../service" -import { TechVendorContact } from "@/db/schema/techVendors" -import { TechVendorContactsTableToolbarActions } from "./contact-table-toolbar-actions" - -interface TechVendorContactsTableProps { - promises: Promise< - [ - Awaited<ReturnType<typeof getTechVendorContacts>>, - ] - >, - vendorId:number -} - -export function TechVendorContactsTable({ promises , vendorId}: TechVendorContactsTableProps) { - const { featureFlags } = useFeatureFlags() - - // Suspense로 받아온 데이터 - const [{ data, pageCount }] = React.use(promises) - - const [rowAction, setRowAction] = React.useState<DataTableRowAction<TechVendorContact> | null>(null) - - // getColumns() 호출 시, router를 주입 - const columns = React.useMemo( - () => getColumns({ setRowAction }), - [setRowAction] - ) - - const filterFields: DataTableFilterField<TechVendorContact>[] = [ - - ] - - const advancedFilterFields: DataTableAdvancedFilterField<TechVendorContact>[] = [ - { id: "contactName", label: "Contact Name", type: "text" }, - { id: "contactPosition", label: "Contact Position", type: "text" }, - { id: "contactEmail", label: "Contact Email", type: "text" }, - { id: "contactPhone", label: "Contact Phone", type: "text" }, - { id: "createdAt", label: "Created at", type: "date" }, - { id: "updatedAt", label: "Updated at", type: "date" }, - ] - - const { table } = useDataTable({ - data, - columns, - pageCount, - filterFields, - enablePinning: true, - enableAdvancedFilter: true, - initialState: { - sorting: [{ id: "createdAt", desc: true }], - columnPinning: { right: ["actions"] }, - }, - getRowId: (originalRow) => String(originalRow.id), - shallow: false, - clearOnDefault: true, - }) - - return ( - <> - <DataTable - table={table} - > - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - > - <TechVendorContactsTableToolbarActions table={table} vendorId={vendorId} /> - </DataTableAdvancedToolbar> - </DataTable> - </> - ) +"use client"
+
+import * as React from "react"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+
+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 "./contact-table-columns"
+import { getTechVendorContacts } from "../service"
+import { TechVendorContact } from "@/db/schema/techVendors"
+import { TechVendorContactsTableToolbarActions } from "./contact-table-toolbar-actions"
+import { UpdateContactSheet } from "./update-contact-sheet"
+
+interface TechVendorContactsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getTechVendorContacts>>,
+ ]
+ >,
+ vendorId:number
+}
+
+export function TechVendorContactsTable({ promises , vendorId}: TechVendorContactsTableProps) {
+
+ // Suspense로 받아온 데이터
+ const [{ data, pageCount }] = React.use(promises)
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<TechVendorContact> | null>(null)
+
+ // getColumns() 호출 시, router를 주입
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ const filterFields: DataTableFilterField<TechVendorContact>[] = [
+
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField<TechVendorContact>[] = [
+ { id: "contactName", label: "Contact Name", type: "text" },
+ { id: "contactPosition", label: "Contact Position", type: "text" },
+ { id: "contactEmail", label: "Contact Email", type: "text" },
+ { id: "contactPhone", label: "Contact Phone", type: "text" },
+ { id: "country", label: "Country", type: "text" },
+ { id: "createdAt", label: "Created at", type: "date" },
+ { id: "updatedAt", label: "Updated at", type: "date" },
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable
+ table={table}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <TechVendorContactsTableToolbarActions table={table} vendorId={vendorId} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ <UpdateContactSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ contact={rowAction?.type === "update" ? rowAction.row.original : null}
+ vendorId={vendorId}
+ />
+ </>
+ )
}
\ No newline at end of file diff --git a/lib/tech-vendors/contacts-table/feature-flags-provider.tsx b/lib/tech-vendors/contacts-table/feature-flags-provider.tsx index 81131894..615377d6 100644 --- a/lib/tech-vendors/contacts-table/feature-flags-provider.tsx +++ b/lib/tech-vendors/contacts-table/feature-flags-provider.tsx @@ -1,108 +1,108 @@ -"use client" - -import * as React from "react" -import { useQueryState } from "nuqs" - -import { dataTableConfig, type DataTableConfig } from "@/config/data-table" -import { cn } from "@/lib/utils" -import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip" - -type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] - -interface FeatureFlagsContextProps { - featureFlags: FeatureFlagValue[] - setFeatureFlags: (value: FeatureFlagValue[]) => void -} - -const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({ - featureFlags: [], - setFeatureFlags: () => {}, -}) - -export function useFeatureFlags() { - const context = React.useContext(FeatureFlagsContext) - if (!context) { - throw new Error( - "useFeatureFlags must be used within a FeatureFlagsProvider" - ) - } - return context -} - -interface FeatureFlagsProviderProps { - children: React.ReactNode -} - -export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { - const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( - "flags", - { - defaultValue: [], - parse: (value) => value.split(",") as FeatureFlagValue[], - serialize: (value) => value.join(","), - eq: (a, b) => - a.length === b.length && a.every((value, index) => value === b[index]), - clearOnDefault: true, - shallow: false, - } - ) - - return ( - <FeatureFlagsContext.Provider - value={{ - featureFlags, - setFeatureFlags: (value) => void setFeatureFlags(value), - }} - > - <div className="w-full overflow-x-auto"> - <ToggleGroup - type="multiple" - variant="outline" - size="sm" - value={featureFlags} - onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} - className="w-fit gap-0" - > - {dataTableConfig.featureFlags.map((flag, index) => ( - <Tooltip key={flag.value}> - <ToggleGroupItem - value={flag.value} - className={cn( - "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", - { - "rounded-l-sm border-r-0": index === 0, - "rounded-r-sm": - index === dataTableConfig.featureFlags.length - 1, - } - )} - asChild - > - <TooltipTrigger> - <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> - {flag.label} - </TooltipTrigger> - </ToggleGroupItem> - <TooltipContent - align="start" - side="bottom" - sideOffset={6} - className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" - > - <div>{flag.tooltipTitle}</div> - <div className="text-xs text-muted-foreground"> - {flag.tooltipDescription} - </div> - </TooltipContent> - </Tooltip> - ))} - </ToggleGroup> - </div> - {children} - </FeatureFlagsContext.Provider> - ) -} +"use client"
+
+import * as React from "react"
+import { useQueryState } from "nuqs"
+
+import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
+import { cn } from "@/lib/utils"
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface FeatureFlagsContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useFeatureFlags() {
+ const context = React.useContext(FeatureFlagsContext)
+ if (!context) {
+ throw new Error(
+ "useFeatureFlags must be used within a FeatureFlagsProvider"
+ )
+ }
+ return context
+}
+
+interface FeatureFlagsProviderProps {
+ children: React.ReactNode
+}
+
+export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "flags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ shallow: false,
+ }
+ )
+
+ return (
+ <FeatureFlagsContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit gap-0"
+ >
+ {dataTableConfig.featureFlags.map((flag, index) => (
+ <Tooltip key={flag.value}>
+ <ToggleGroupItem
+ value={flag.value}
+ className={cn(
+ "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90",
+ {
+ "rounded-l-sm border-r-0": index === 0,
+ "rounded-r-sm":
+ index === dataTableConfig.featureFlags.length - 1,
+ }
+ )}
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </FeatureFlagsContext.Provider>
+ )
+}
diff --git a/lib/tech-vendors/contacts-table/update-contact-sheet.tsx b/lib/tech-vendors/contacts-table/update-contact-sheet.tsx new file mode 100644 index 00000000..b75ddd1e --- /dev/null +++ b/lib/tech-vendors/contacts-table/update-contact-sheet.tsx @@ -0,0 +1,217 @@ +"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Checkbox } from "@/components/ui/checkbox"
+import { Loader2 } from "lucide-react"
+
+import type { TechVendorContact } from "@/db/schema/techVendors"
+import { updateTechVendorContactSchema, type UpdateTechVendorContactSchema } from "../validations"
+import { updateTechVendorContact } from "../service"
+
+interface UpdateContactSheetProps
+ extends React.ComponentPropsWithoutRef<typeof Sheet> {
+ contact: TechVendorContact | null
+ vendorId: number
+}
+
+export function UpdateContactSheet({ contact, vendorId, ...props }: UpdateContactSheetProps) {
+ const [isPending, startTransition] = React.useTransition()
+
+ const form = useForm<UpdateTechVendorContactSchema>({
+ resolver: zodResolver(updateTechVendorContactSchema),
+ defaultValues: {
+ contactName: contact?.contactName ?? "",
+ contactPosition: contact?.contactPosition ?? "",
+ contactEmail: contact?.contactEmail ?? "",
+ contactPhone: contact?.contactPhone ?? "",
+ contactCountry: contact?.contactCountry ?? "",
+ isPrimary: contact?.isPrimary ?? false,
+ },
+ })
+
+ React.useEffect(() => {
+ if (contact) {
+ form.reset({
+ contactName: contact.contactName,
+ contactPosition: contact.contactPosition ?? "",
+ contactEmail: contact.contactEmail,
+ contactPhone: contact.contactPhone ?? "",
+ contactCountry: contact.contactCountry ?? "",
+ isPrimary: contact.isPrimary,
+ })
+ }
+ }, [contact, form])
+
+ async function onSubmit(data: UpdateTechVendorContactSchema) {
+ if (!contact) return
+
+ startTransition(async () => {
+ try {
+ const { error } = await updateTechVendorContact({
+ id: contact.id,
+ vendorId: vendorId,
+ ...data
+ })
+
+ if (error) throw new Error(error)
+
+ toast.success("연락처 정보가 업데이트되었습니다!")
+ form.reset()
+ props.onOpenChange?.(false)
+ } catch (err: unknown) {
+ toast.error(String(err))
+ }
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="space-y-4 w-[600px] sm:max-w-[600px]">
+ <SheetHeader>
+ <SheetTitle>연락처 수정</SheetTitle>
+ <SheetDescription>
+ 연락처 정보를 수정하세요. 완료되면 저장 버튼을 클릭하세요.
+ </SheetDescription>
+ </SheetHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ <div className="grid grid-cols-1 gap-4">
+ <FormField
+ control={form.control}
+ name="contactName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>담당자명 *</FormLabel>
+ <FormControl>
+ <Input placeholder="담당자명을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="contactPosition"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>직책</FormLabel>
+ <FormControl>
+ <Input placeholder="직책을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="contactEmail"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>이메일</FormLabel>
+ <FormControl>
+ <Input
+ type="email"
+ placeholder="이메일을 입력하세요"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="contactPhone"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>전화번호</FormLabel>
+ <FormControl>
+ <Input placeholder="전화번호를 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="contactCountry"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>국가</FormLabel>
+ <FormControl>
+ <Input placeholder="국가를 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="isPrimary"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-start space-x-3 space-y-0">
+ <FormControl>
+ <Checkbox
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ <div className="space-y-1 leading-none">
+ <FormLabel>주 담당자</FormLabel>
+ </div>
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <div className="flex justify-end space-x-2">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => props.onOpenChange?.(false)}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={isPending}>
+ {isPending && (
+ <Loader2
+ className="mr-2 h-4 w-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 저장
+ </Button>
+ </div>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+}
\ No newline at end of file diff --git a/lib/tech-vendors/possible-items/add-item-dialog.tsx b/lib/tech-vendors/possible-items/add-item-dialog.tsx new file mode 100644 index 00000000..ef15a5ce --- /dev/null +++ b/lib/tech-vendors/possible-items/add-item-dialog.tsx @@ -0,0 +1,284 @@ +"use client";
+
+import * as React from "react";
+import { Search, X } from "lucide-react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Badge } from "@/components/ui/badge";
+import {
+ getItemsForTechVendor,
+ addTechVendorPossibleItem
+} from "../service";
+
+interface ItemData {
+ id: number;
+ itemCode: string | null;
+ itemList: string | null;
+ workType: string | null;
+ shipTypes?: string | null;
+ subItemList?: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+interface AddItemDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ vendorId: number;
+}
+
+export function AddItemDialog({ open, onOpenChange, vendorId }: AddItemDialogProps) {
+ // 아이템 관련 상태
+ const [items, setItems] = React.useState<ItemData[]>([]);
+ const [filteredItems, setFilteredItems] = React.useState<ItemData[]>([]);
+ const [itemSearch, setItemSearch] = React.useState("");
+ const [selectedItems, setSelectedItems] = React.useState<ItemData[]>([]);
+
+ const [isLoading, setIsLoading] = React.useState(false);
+
+ // 다이얼로그가 열릴 때 아이템 목록 로드
+ React.useEffect(() => {
+ if (open && vendorId) {
+ loadItems();
+ }
+ }, [open, vendorId]);
+
+ // 아이템 검색 필터링
+ React.useEffect(() => {
+ if (!itemSearch) {
+ setFilteredItems(items);
+ } else {
+ const filtered = items.filter(item =>
+ item.itemCode?.toLowerCase().includes(itemSearch.toLowerCase()) ||
+ item.itemList?.toLowerCase().includes(itemSearch.toLowerCase()) ||
+ item.workType?.toLowerCase().includes(itemSearch.toLowerCase())
+ );
+ setFilteredItems(filtered);
+ }
+ }, [items, itemSearch]);
+
+ const loadItems = async () => {
+ try {
+ setIsLoading(true);
+ console.log("Loading items for vendor:", vendorId);
+ const result = await getItemsForTechVendor(vendorId);
+
+ if (result.error) {
+ throw new Error(result.error);
+ }
+
+ console.log("Loaded items:", result.data.length, result.data);
+ // itemCode가 null이 아닌 항목만 필터링
+ const validItems = result.data.filter(item => item.itemCode != null);
+ setItems(validItems);
+ } catch (error) {
+ console.error("Failed to load items:", error);
+ toast.error("아이템 목록을 불러오는데 실패했습니다.");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleItemToggle = (item: ItemData) => {
+ if (!item.itemCode) return; // itemCode가 null인 경우 처리하지 않음
+
+ setSelectedItems(prev => {
+ // itemCode + shipTypes 조합으로 중복 체크
+ const isSelected = prev.some(i =>
+ i.itemCode === item.itemCode && i.shipTypes === item.shipTypes
+ );
+ if (isSelected) {
+ return prev.filter(i =>
+ !(i.itemCode === item.itemCode && i.shipTypes === item.shipTypes)
+ );
+ } else {
+ return [...prev, item];
+ }
+ });
+ };
+
+ const handleSubmit = async () => {
+ if (selectedItems.length === 0) return;
+
+ try {
+ setIsLoading(true);
+ let successCount = 0;
+ let errorCount = 0;
+
+ for (const item of selectedItems) {
+ if (!item.itemCode) continue; // itemCode가 null인 경우 건너뛰기
+
+ const result = await addTechVendorPossibleItem({
+ vendorId: vendorId,
+ itemCode: item.itemCode,
+ workType: item.workType || undefined,
+ shipTypes: item.shipTypes || undefined,
+ itemList: item.itemList || undefined,
+ subItemList: item.subItemList || undefined,
+ });
+
+ if (result.success) {
+ successCount++;
+ } else {
+ errorCount++;
+ console.error("Failed to add item:", item.itemCode, result.error);
+ }
+ }
+
+ if (successCount > 0) {
+ toast.success(
+ `${successCount}개의 아이템이 추가되었습니다.${
+ errorCount > 0 ? ` (${errorCount}개 실패)` : ""
+ }`
+ );
+
+ handleClose();
+ } else {
+ toast.error("아이템 추가에 실패했습니다.");
+ }
+ } catch (error) {
+ console.error("Failed to add items:", error);
+ toast.error("아이템 추가 중 오류가 발생했습니다.");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleClose = () => {
+ onOpenChange(false);
+ setTimeout(() => {
+ setSelectedItems([]);
+ setItemSearch("");
+ setItems([]);
+ setFilteredItems([]);
+ }, 200);
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
+ <DialogHeader>
+ <DialogTitle>아이템 추가</DialogTitle>
+ <DialogDescription>
+ 추가할 아이템을 선택하세요. 복수 선택이 가능합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="flex-1 min-h-0 space-y-4">
+ {/* 검색 */}
+ <div className="space-y-2">
+ <Label htmlFor="item-search">아이템 검색</Label>
+ <div className="relative">
+ <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
+ <Input
+ id="item-search"
+ placeholder="아이템코드, 아이템리스트, 공종으로 검색..."
+ value={itemSearch}
+ onChange={(e) => setItemSearch(e.target.value)}
+ className="pl-10"
+ />
+ </div>
+ </div>
+
+ {/* 선택된 아이템 표시 */}
+ {selectedItems.length > 0 && (
+ <div className="space-y-2">
+ <Label>선택된 아이템 ({selectedItems.length}개)</Label>
+ <div className="flex flex-wrap gap-1 p-2 border rounded-md bg-muted/50 max-h-20 overflow-y-auto">
+ {selectedItems.map((item) => {
+ if (!item.itemCode) return null;
+ const itemKey = `${item.itemCode}${item.shipTypes ? `-${item.shipTypes}` : ''}`;
+ return (
+ <Badge key={`selected-${itemKey}`} variant="default" className="text-xs">
+ {itemKey}
+ <X
+ className="ml-1 h-3 w-3 cursor-pointer"
+ onClick={(e) => {
+ e.stopPropagation();
+ handleItemToggle(item);
+ }}
+ />
+ </Badge>
+ );
+ })}
+ </div>
+ </div>
+ )}
+
+ {/* 아이템 목록 */}
+ <div className="max-h-96 overflow-y-auto border rounded-lg bg-gray-50 p-2">
+ <div className="space-y-2">
+ {isLoading ? (
+ <div className="text-center py-4">아이템 로딩 중...</div>
+ ) : filteredItems.length === 0 && items.length === 0 ? (
+ <div className="text-center py-4 text-muted-foreground">
+ 해당 벤더 타입에 대한 추가 가능한 아이템이 없습니다.
+ </div>
+ ) : filteredItems.length === 0 ? (
+ <div className="text-center py-4 text-muted-foreground">
+ 검색 결과가 없습니다.
+ </div>
+ ) : (
+ filteredItems.map((item) => {
+ if (!item.itemCode) return null; // itemCode가 null인 경우 렌더링하지 않음
+
+ // itemCode + shipTypes 조합으로 선택 여부 체크
+ const isSelected = selectedItems.some(i =>
+ i.itemCode === item.itemCode && i.shipTypes === item.shipTypes
+ );
+ const itemKey = `${item.itemCode}${item.shipTypes ? `-${item.shipTypes}` : ''}`;
+
+ return (
+ <div
+ key={`item-${itemKey}`}
+ className={`p-3 bg-white border rounded-lg cursor-pointer transition-colors ${
+ isSelected
+ ? "bg-primary/10 border-primary hover:bg-primary/20"
+ : "hover:bg-gray-50"
+ }`}
+ onClick={() => handleItemToggle(item)}
+ >
+ <div className="font-medium">
+ {itemKey}
+ </div>
+ <div className="text-sm text-muted-foreground">
+ {item.itemList || "-"}
+ </div>
+ <div className="flex gap-2 mt-1 text-xs">
+ <span>공종: {item.workType || "-"}</span>
+ {item.shipTypes && <span>선종: {item.shipTypes}</span>}
+ {item.subItemList && <span>서브아이템: {item.subItemList}</span>}
+ </div>
+ </div>
+ );
+ })
+ )}
+ </div>
+ </div>
+ </div>
+
+ <div className="flex justify-end gap-2 pt-4 border-t">
+ <Button variant="outline" onClick={handleClose}>
+ 취소
+ </Button>
+ <Button
+ onClick={handleSubmit}
+ disabled={selectedItems.length === 0 || isLoading}
+ >
+ {isLoading ? "추가 중..." : `추가 (${selectedItems.length})`}
+ </Button>
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+}
\ No newline at end of file diff --git a/lib/tech-vendors/possible-items/possible-items-columns.tsx b/lib/tech-vendors/possible-items/possible-items-columns.tsx new file mode 100644 index 00000000..71bcb3b8 --- /dev/null +++ b/lib/tech-vendors/possible-items/possible-items-columns.tsx @@ -0,0 +1,206 @@ +"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis } from "lucide-react"
+
+import { formatDate } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuShortcut,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import type { TechVendorPossibleItem } from "../validations"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TechVendorPossibleItem> | null>>;
+}
+
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<TechVendorPossibleItem>[] {
+ return [
+ // 선택 체크박스
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ },
+
+ // 아이템 코드
+ {
+ accessorKey: "itemCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="아이템 코드" />
+ ),
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">
+ {row.getValue("itemCode")}
+ </div>
+ ),
+ size: 150,
+ },
+
+ // 공종
+ {
+ accessorKey: "workType",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="공종" />
+ ),
+ cell: ({ row }) => {
+ const workType = row.getValue("workType") as string | null
+ return workType ? (
+ <Badge variant="secondary" className="text-xs">
+ {workType}
+ </Badge>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ size: 100,
+ },
+
+ // 아이템명
+ {
+ accessorKey: "itemList",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="아이템명" />
+ ),
+ cell: ({ row }) => {
+ const itemList = row.getValue("itemList") as string | null
+ return (
+ <div className="max-w-[300px]">
+ {itemList || <span className="text-muted-foreground">-</span>}
+ </div>
+ )
+ },
+ size: 300,
+ },
+
+ // 선종 (조선용)
+ {
+ accessorKey: "shipTypes",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="선종" />
+ ),
+ cell: ({ row }) => {
+ const shipTypes = row.getValue("shipTypes") as string | null
+ return shipTypes ? (
+ <Badge variant="outline" className="text-xs">
+ {shipTypes}
+ </Badge>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ size: 120,
+ },
+
+ // 서브아이템 (해양용)
+ {
+ accessorKey: "subItemList",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="서브아이템" />
+ ),
+ cell: ({ row }) => {
+ const subItemList = row.getValue("subItemList") as string | null
+ return (
+ <div className="max-w-[200px]">
+ {subItemList || <span className="text-muted-foreground">-</span>}
+ </div>
+ )
+ },
+ size: 200,
+ },
+
+ // 등록일
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="등록일" />
+ ),
+ cell: ({ row }) => {
+ const date = row.getValue("createdAt") as Date
+ return (
+ <div className="text-sm text-muted-foreground">
+ {formatDate(date)}
+ </div>
+ )
+ },
+ size: 120,
+ },
+
+ // 수정일
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="수정일" />
+ ),
+ cell: ({ row }) => {
+ const date = row.getValue("updatedAt") as Date
+ return (
+ <div className="text-sm text-muted-foreground">
+ {formatDate(date)}
+ </div>
+ )
+ },
+ size: 120,
+ },
+
+ // 액션 메뉴
+ {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ >
+ 삭제
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ },
+ ]
+}
\ No newline at end of file diff --git a/lib/tech-vendors/possible-items/possible-items-table.tsx b/lib/tech-vendors/possible-items/possible-items-table.tsx new file mode 100644 index 00000000..9c024a93 --- /dev/null +++ b/lib/tech-vendors/possible-items/possible-items-table.tsx @@ -0,0 +1,171 @@ +"use client"
+
+import * as React from "react"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+import { toast } from "sonner"
+
+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 {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog"
+
+import { getColumns } from "./possible-items-columns"
+import {
+ getTechVendorPossibleItems,
+ deleteTechVendorPossibleItem,
+} from "../service"
+import type { TechVendorPossibleItem } from "../validations"
+import { PossibleItemsTableToolbarActions } from "./possible-items-toolbar-actions"
+import { AddItemDialog } from "./add-item-dialog"
+
+interface TechVendorPossibleItemsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getTechVendorPossibleItems>>,
+ ]
+ >
+ vendorId: number
+}
+
+export function TechVendorPossibleItemsTable({
+ promises,
+ vendorId,
+}: TechVendorPossibleItemsTableProps) {
+ // Suspense로 받아온 데이터
+ const [{ data, pageCount }] = React.use(promises)
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<TechVendorPossibleItem> | null>(null)
+ const [showAddDialog, setShowAddDialog] = React.useState(false)
+ const [showDeleteAlert, setShowDeleteAlert] = React.useState(false)
+ const [isDeleting, setIsDeleting] = React.useState(false)
+
+ // getColumns() 호출 시, setRowAction을 주입
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ // 단일 아이템 삭제 핸들러
+ async function handleDeleteItem() {
+ if (!rowAction || rowAction.type !== "delete") return
+
+ setIsDeleting(true)
+ try {
+ const { success, error } = await deleteTechVendorPossibleItem(
+ rowAction.row.original.id,
+ vendorId
+ )
+
+ if (!success) {
+ throw new Error(error)
+ }
+
+ toast.success("아이템이 삭제되었습니다")
+ setShowDeleteAlert(false)
+ setRowAction(null)
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "아이템 삭제 중 오류가 발생했습니다")
+ } finally {
+ setIsDeleting(false)
+ }
+ }
+
+ const filterFields: DataTableFilterField<TechVendorPossibleItem>[] = [
+ { id: "itemCode", label: "아이템 코드" },
+ { id: "workType", label: "공종" },
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField<TechVendorPossibleItem>[] = [
+ { id: "itemCode", label: "아이템 코드", type: "text" },
+ { id: "workType", label: "공종", type: "text" },
+ { id: "itemList", label: "아이템명", type: "text" },
+ { id: "shipTypes", label: "선종", type: "text" },
+ { id: "subItemList", label: "서브아이템", type: "text" },
+ { id: "createdAt", label: "등록일", type: "date" },
+ { id: "updatedAt", label: "수정일", type: "date" },
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ // rowAction 상태 변경 감지
+ React.useEffect(() => {
+ if (rowAction?.type === "delete") {
+ setShowDeleteAlert(true)
+ }
+ }, [rowAction])
+
+ return (
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <PossibleItemsTableToolbarActions
+ table={table}
+ vendorId={vendorId}
+ onAdd={() => setShowAddDialog(true)}
+ />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* Add Item Dialog */}
+ <AddItemDialog
+ open={showAddDialog}
+ onOpenChange={setShowAddDialog}
+ vendorId={vendorId}
+ />
+
+ {/* Delete Confirmation Dialog */}
+ <AlertDialog open={showDeleteAlert} onOpenChange={setShowDeleteAlert}>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>아이템 삭제</AlertDialogTitle>
+ <AlertDialogDescription>
+ 이 아이템을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel onClick={() => setRowAction(null)}>
+ 취소
+ </AlertDialogCancel>
+ <AlertDialogAction
+ onClick={handleDeleteItem}
+ disabled={isDeleting}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ {isDeleting ? "삭제 중..." : "삭제"}
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ </>
+ )
+}
\ No newline at end of file diff --git a/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx b/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx new file mode 100644 index 00000000..707d0513 --- /dev/null +++ b/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx @@ -0,0 +1,119 @@ +"use client"
+
+import * as React from "react"
+import type { Table } from "@tanstack/react-table"
+import { Plus, Trash2 } from "lucide-react"
+import { toast } from "sonner"
+
+import { Button } from "@/components/ui/button"
+import { Separator } from "@/components/ui/separator"
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog"
+
+import type { TechVendorPossibleItem } from "../validations"
+import { deleteTechVendorPossibleItemsNew } from "../service"
+
+interface PossibleItemsTableToolbarActionsProps {
+ table: Table<TechVendorPossibleItem>
+ vendorId: number
+ onAdd: () => void
+}
+
+export function PossibleItemsTableToolbarActions({
+ table,
+ vendorId,
+ onAdd,
+}: PossibleItemsTableToolbarActionsProps) {
+ const [showDeleteAlert, setShowDeleteAlert] = React.useState(false)
+ const [isDeleting, setIsDeleting] = React.useState(false)
+
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+
+ async function handleDelete() {
+ setIsDeleting(true)
+ try {
+ const ids = selectedRows.map((row) => row.original.id)
+ const { error } = await deleteTechVendorPossibleItemsNew(ids, vendorId)
+
+ if (error) {
+ throw new Error(error)
+ }
+
+ toast.success(`${ids.length}개의 아이템이 삭제되었습니다`)
+ table.resetRowSelection()
+ setShowDeleteAlert(false)
+ } catch {
+ toast.error("아이템 삭제 중 오류가 발생했습니다")
+ } finally {
+ setIsDeleting(false)
+ }
+ }
+
+ return (
+ <>
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={onAdd}
+ >
+ <Plus className="mr-2 h-4 w-4" />
+ 아이템 추가
+ </Button>
+
+ {selectedRows.length > 0 && (
+ <>
+ <Separator orientation="vertical" className="mx-2 h-4" />
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setShowDeleteAlert(true)}
+ disabled={selectedRows.length === 0}
+ >
+ <Trash2 className="mr-2 h-4 w-4" />
+ 삭제 ({selectedRows.length})
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ 선택된 {selectedRows.length}개 아이템을 삭제합니다
+ </TooltipContent>
+ </Tooltip>
+ </>
+ )}
+ </div>
+
+ <AlertDialog open={showDeleteAlert} onOpenChange={setShowDeleteAlert}>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>아이템 삭제</AlertDialogTitle>
+ <AlertDialogDescription>
+ 선택된 {selectedRows.length}개의 아이템을 삭제하시겠습니까?
+ 이 작업은 되돌릴 수 없습니다.
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel>취소</AlertDialogCancel>
+ <AlertDialogAction
+ onClick={handleDelete}
+ disabled={isDeleting}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ {isDeleting ? "삭제 중..." : "삭제"}
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ </>
+ )
+}
\ No newline at end of file diff --git a/lib/tech-vendors/repository.ts b/lib/tech-vendors/repository.ts index d3c6671c..a273bf50 100644 --- a/lib/tech-vendors/repository.ts +++ b/lib/tech-vendors/repository.ts @@ -1,389 +1,462 @@ -// src/lib/vendors/repository.ts - -import { eq, inArray, count, desc } from "drizzle-orm"; -import db from '@/db/db'; -import { SQL } from "drizzle-orm"; -import { techVendors, techVendorContacts, techVendorPossibleItems, techVendorItemsView, type TechVendor, type TechVendorContact, type TechVendorItem, type TechVendorWithAttachments, techVendorAttachments } from "@/db/schema/techVendors"; -import { itemShipbuilding, itemOffshoreTop, itemOffshoreHull } from "@/db/schema/items"; - -export type NewTechVendorContact = typeof techVendorContacts.$inferInsert -export type NewTechVendorItem = typeof techVendorPossibleItems.$inferInsert - -type PaginationParams = { - offset: number; - limit: number; -}; - -// 메인 벤더 목록 조회 (첨부파일 정보 포함) -export async function selectTechVendorsWithAttachments( - tx: any, - params: { - where?: SQL<unknown>; - orderBy?: SQL<unknown>[]; - } & PaginationParams -) { - const query = tx - .select({ - id: techVendors.id, - vendorName: techVendors.vendorName, - vendorCode: techVendors.vendorCode, - taxId: techVendors.taxId, - address: techVendors.address, - country: techVendors.country, - phone: techVendors.phone, - email: techVendors.email, - website: techVendors.website, - status: techVendors.status, - techVendorType: techVendors.techVendorType, - representativeName: techVendors.representativeName, - representativeEmail: techVendors.representativeEmail, - representativePhone: techVendors.representativePhone, - representativeBirth: techVendors.representativeBirth, - countryEng: techVendors.countryEng, - countryFab: techVendors.countryFab, - agentName: techVendors.agentName, - agentPhone: techVendors.agentPhone, - agentEmail: techVendors.agentEmail, - items: techVendors.items, - createdAt: techVendors.createdAt, - updatedAt: techVendors.updatedAt, - }) - .from(techVendors); - - // where 조건이 있는 경우 - if (params.where) { - query.where(params.where); - } - - // 정렬 조건이 있는 경우 - if (params.orderBy && params.orderBy.length > 0) { - query.orderBy(...params.orderBy); - } else { - // 기본 정렬: 생성일 기준 내림차순 - query.orderBy(desc(techVendors.createdAt)); - } - - // 페이지네이션 적용 - query.offset(params.offset).limit(params.limit); - - const vendors = await query; - - // 첨부파일 정보 가져오기 - const vendorsWithAttachments = await Promise.all( - vendors.map(async (vendor: TechVendor) => { - const attachments = await tx - .select({ - id: techVendorAttachments.id, - fileName: techVendorAttachments.fileName, - filePath: techVendorAttachments.filePath, - }) - .from(techVendorAttachments) - .where(eq(techVendorAttachments.vendorId, vendor.id)); - - // 벤더의 worktype 조회 - const workTypes = await getVendorWorkTypes(tx, vendor.id, vendor.techVendorType); - - return { - ...vendor, - hasAttachments: attachments.length > 0, - attachmentsList: attachments, - workTypes: workTypes.join(', '), // 콤마로 구분해서 저장 - } as TechVendorWithAttachments; - }) - ); - - return vendorsWithAttachments; -} - -// 메인 벤더 목록 수 조회 (첨부파일 정보 포함) -export async function countTechVendorsWithAttachments( - tx: any, - where?: SQL<unknown> -) { - const query = tx.select({ count: count() }).from(techVendors); - - if (where) { - query.where(where); - } - - const result = await query; - return result[0].count; -} - -// 기술영업 벤더 조회 -export async function selectTechVendors( - tx: any, - params: { - where?: SQL<unknown>; - orderBy?: SQL<unknown>[]; - } & PaginationParams -) { - const query = tx.select().from(techVendors); - - if (params.where) { - query.where(params.where); - } - - if (params.orderBy && params.orderBy.length > 0) { - query.orderBy(...params.orderBy); - } else { - query.orderBy(desc(techVendors.createdAt)); - } - - query.offset(params.offset).limit(params.limit); - - return query; -} - -// 기술영업 벤더 수 카운트 -export async function countTechVendors(tx: any, where?: SQL<unknown>) { - const query = tx.select({ count: count() }).from(techVendors); - - if (where) { - query.where(where); - } - - const result = await query; - return result[0].count; -} - -// 벤더 상태별 카운트 -export async function groupByTechVendorStatus(tx: any) { - const result = await tx - .select({ - status: techVendors.status, - count: count(), - }) - .from(techVendors) - .groupBy(techVendors.status); - - return result; -} - -// 벤더 상세 정보 조회 -export async function getTechVendorById(id: number) { - const result = await db - .select() - .from(techVendors) - .where(eq(techVendors.id, id)); - - return result.length > 0 ? result[0] : null; -} - -// 벤더 연락처 정보 조회 -export async function getTechVendorContactsById(id: number) { - const result = await db - .select() - .from(techVendorContacts) - .where(eq(techVendorContacts.id, id)); - - return result.length > 0 ? result[0] : null; -} - -// 신규 벤더 생성 -export async function insertTechVendor( - tx: any, - data: Omit<TechVendor, "id" | "createdAt" | "updatedAt"> -) { - return tx - .insert(techVendors) - .values({ - ...data, - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning(); -} - -// 벤더 정보 업데이트 (단일) -export async function updateTechVendor( - tx: any, - id: string | number, - data: Partial<TechVendor> -) { - return tx - .update(techVendors) - .set({ - ...data, - updatedAt: new Date(), - }) - .where(eq(techVendors.id, Number(id))) - .returning(); -} - -// 벤더 정보 업데이트 (다수) -export async function updateTechVendors( - tx: any, - ids: (string | number)[], - data: Partial<TechVendor> -) { - return tx - .update(techVendors) - .set({ - ...data, - updatedAt: new Date(), - }) - .where(inArray(techVendors.id, ids.map(id => Number(id)))) - .returning(); -} - -// 벤더 연락처 조회 -export async function selectTechVendorContacts( - tx: any, - params: { - where?: SQL<unknown>; - orderBy?: SQL<unknown>[]; - } & PaginationParams -) { - const query = tx.select().from(techVendorContacts); - - if (params.where) { - query.where(params.where); - } - - if (params.orderBy && params.orderBy.length > 0) { - query.orderBy(...params.orderBy); - } else { - query.orderBy(desc(techVendorContacts.createdAt)); - } - - query.offset(params.offset).limit(params.limit); - - return query; -} - -// 벤더 연락처 수 카운트 -export async function countTechVendorContacts(tx: any, where?: SQL<unknown>) { - const query = tx.select({ count: count() }).from(techVendorContacts); - - if (where) { - query.where(where); - } - - const result = await query; - return result[0].count; -} - -// 연락처 생성 -export async function insertTechVendorContact( - tx: any, - data: Omit<TechVendorContact, "id" | "createdAt" | "updatedAt"> -) { - return tx - .insert(techVendorContacts) - .values({ - ...data, - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning(); -} - -// 아이템 목록 조회 -export async function selectTechVendorItems( - tx: any, - params: { - where?: SQL<unknown>; - orderBy?: SQL<unknown>[]; - } & PaginationParams -) { - const query = tx.select().from(techVendorItemsView); - - if (params.where) { - query.where(params.where); - } - - if (params.orderBy && params.orderBy.length > 0) { - query.orderBy(...params.orderBy); - } else { - query.orderBy(desc(techVendorItemsView.createdAt)); - } - - query.offset(params.offset).limit(params.limit); - - return query; -} - -// 아이템 수 카운트 -export async function countTechVendorItems(tx: any, where?: SQL<unknown>) { - const query = tx.select({ count: count() }).from(techVendorItemsView); - - if (where) { - query.where(where); - } - - const result = await query; - return result[0].count; -} - -// 아이템 생성 -export async function insertTechVendorItem( - tx: any, - data: Omit<TechVendorItem, "id" | "createdAt" | "updatedAt"> -) { - return tx - .insert(techVendorPossibleItems) - .values({ - ...data, - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning(); -} - -// 벤더의 worktype 조회 -export async function getVendorWorkTypes( - tx: any, - vendorId: number, - vendorType: string -): Promise<string[]> { - try { - // 벤더의 possible items 조회 - const possibleItems = await tx - .select({ itemCode: techVendorPossibleItems.itemCode }) - .from(techVendorPossibleItems) - .where(eq(techVendorPossibleItems.vendorId, vendorId)); - - if (!possibleItems.length) { - return []; - } - - const itemCodes = possibleItems.map((item: { itemCode: string }) => item.itemCode); - const workTypes: string[] = []; - - // 벤더 타입에 따라 해당하는 아이템 테이블에서 worktype 조회 - if (vendorType.includes('조선')) { - const shipWorkTypes = await tx - .select({ workType: itemShipbuilding.workType }) - .from(itemShipbuilding) - .where(inArray(itemShipbuilding.itemCode, itemCodes)); - - workTypes.push(...shipWorkTypes.map((item: { workType: string | null }) => item.workType).filter(Boolean)); - } - - if (vendorType.includes('해양TOP')) { - const topWorkTypes = await tx - .select({ workType: itemOffshoreTop.workType }) - .from(itemOffshoreTop) - .where(inArray(itemOffshoreTop.itemCode, itemCodes)); - - workTypes.push(...topWorkTypes.map((item: { workType: string | null }) => item.workType).filter(Boolean)); - } - - if (vendorType.includes('해양HULL')) { - const hullWorkTypes = await tx - .select({ workType: itemOffshoreHull.workType }) - .from(itemOffshoreHull) - .where(inArray(itemOffshoreHull.itemCode, itemCodes)); - - workTypes.push(...hullWorkTypes.map((item: { workType: string | null }) => item.workType).filter(Boolean)); - } - - // 중복 제거 후 반환 - const uniqueWorkTypes = [...new Set(workTypes)]; - - return uniqueWorkTypes; - } catch (error) { - return []; - } -} +// src/lib/vendors/repository.ts
+
+import { eq, inArray, count, desc } from "drizzle-orm";
+import db from '@/db/db';
+import { SQL } from "drizzle-orm";
+import { techVendors, techVendorContacts, techVendorPossibleItems, techVendorItemsView, type TechVendor, type TechVendorContact, type TechVendorItem, type TechVendorWithAttachments, techVendorAttachments } from "@/db/schema/techVendors";
+import { itemShipbuilding, itemOffshoreTop, itemOffshoreHull } from "@/db/schema/items";
+
+export type NewTechVendorContact = typeof techVendorContacts.$inferInsert
+export type NewTechVendorItem = typeof techVendorPossibleItems.$inferInsert
+
+type PaginationParams = {
+ offset: number;
+ limit: number;
+};
+
+// 메인 벤더 목록 조회 (첨부파일 정보 포함)
+export async function selectTechVendorsWithAttachments(
+ tx: any,
+ params: {
+ where?: SQL<unknown>;
+ orderBy?: SQL<unknown>[];
+ } & PaginationParams
+) {
+ const query = tx
+ .select({
+ id: techVendors.id,
+ vendorName: techVendors.vendorName,
+ vendorCode: techVendors.vendorCode,
+ taxId: techVendors.taxId,
+ address: techVendors.address,
+ country: techVendors.country,
+ phone: techVendors.phone,
+ email: techVendors.email,
+ website: techVendors.website,
+ status: techVendors.status,
+ techVendorType: techVendors.techVendorType,
+ representativeName: techVendors.representativeName,
+ representativeEmail: techVendors.representativeEmail,
+ representativePhone: techVendors.representativePhone,
+ representativeBirth: techVendors.representativeBirth,
+ countryEng: techVendors.countryEng,
+ countryFab: techVendors.countryFab,
+ agentName: techVendors.agentName,
+ agentPhone: techVendors.agentPhone,
+ agentEmail: techVendors.agentEmail,
+ items: techVendors.items,
+ createdAt: techVendors.createdAt,
+ updatedAt: techVendors.updatedAt,
+ })
+ .from(techVendors);
+
+ // where 조건이 있는 경우
+ if (params.where) {
+ query.where(params.where);
+ }
+
+ // 정렬 조건이 있는 경우
+ if (params.orderBy && params.orderBy.length > 0) {
+ query.orderBy(...params.orderBy);
+ } else {
+ // 기본 정렬: 생성일 기준 내림차순
+ query.orderBy(desc(techVendors.createdAt));
+ }
+
+ // 페이지네이션 적용
+ query.offset(params.offset).limit(params.limit);
+
+ const vendors = await query;
+
+ // 첨부파일 정보 가져오기
+ const vendorsWithAttachments = await Promise.all(
+ vendors.map(async (vendor: TechVendor) => {
+ const attachments = await tx
+ .select({
+ id: techVendorAttachments.id,
+ fileName: techVendorAttachments.fileName,
+ filePath: techVendorAttachments.filePath,
+ })
+ .from(techVendorAttachments)
+ .where(eq(techVendorAttachments.vendorId, vendor.id));
+
+ // 벤더의 worktype 조회
+ const workTypes = await getVendorWorkTypes(tx, vendor.id, vendor.techVendorType);
+
+ return {
+ ...vendor,
+ hasAttachments: attachments.length > 0,
+ attachmentsList: attachments,
+ workTypes: workTypes.join(', '), // 콤마로 구분해서 저장
+ } as TechVendorWithAttachments;
+ })
+ );
+
+ return vendorsWithAttachments;
+}
+
+// 메인 벤더 목록 수 조회 (첨부파일 정보 포함)
+export async function countTechVendorsWithAttachments(
+ tx: any,
+ where?: SQL<unknown>
+) {
+ const query = tx.select({ count: count() }).from(techVendors);
+
+ if (where) {
+ query.where(where);
+ }
+
+ const result = await query;
+ return result[0].count;
+}
+
+// 기술영업 벤더 조회
+export async function selectTechVendors(
+ tx: any,
+ params: {
+ where?: SQL<unknown>;
+ orderBy?: SQL<unknown>[];
+ } & PaginationParams
+) {
+ const query = tx.select().from(techVendors);
+
+ if (params.where) {
+ query.where(params.where);
+ }
+
+ if (params.orderBy && params.orderBy.length > 0) {
+ query.orderBy(...params.orderBy);
+ } else {
+ query.orderBy(desc(techVendors.createdAt));
+ }
+
+ query.offset(params.offset).limit(params.limit);
+
+ return query;
+}
+
+// 기술영업 벤더 수 카운트
+export async function countTechVendors(tx: any, where?: SQL<unknown>) {
+ const query = tx.select({ count: count() }).from(techVendors);
+
+ if (where) {
+ query.where(where);
+ }
+
+ const result = await query;
+ return result[0].count;
+}
+
+// 벤더 상태별 카운트
+export async function groupByTechVendorStatus(tx: any) {
+ const result = await tx
+ .select({
+ status: techVendors.status,
+ count: count(),
+ })
+ .from(techVendors)
+ .groupBy(techVendors.status);
+
+ return result;
+}
+
+// 벤더 상세 정보 조회
+export async function getTechVendorById(id: number) {
+ const result = await db
+ .select()
+ .from(techVendors)
+ .where(eq(techVendors.id, id));
+
+ return result.length > 0 ? result[0] : null;
+}
+
+// 벤더 연락처 정보 조회
+export async function getTechVendorContactsById(id: number) {
+ const result = await db
+ .select()
+ .from(techVendorContacts)
+ .where(eq(techVendorContacts.id, id));
+
+ return result.length > 0 ? result[0] : null;
+}
+
+// 신규 벤더 생성
+export async function insertTechVendor(
+ tx: any,
+ data: Omit<TechVendor, "id" | "createdAt" | "updatedAt">
+) {
+ return tx
+ .insert(techVendors)
+ .values({
+ ...data,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .returning();
+}
+
+// 벤더 정보 업데이트 (단일)
+export async function updateTechVendor(
+ tx: any,
+ id: string | number,
+ data: Partial<TechVendor>
+) {
+ return tx
+ .update(techVendors)
+ .set({
+ ...data,
+ updatedAt: new Date(),
+ })
+ .where(eq(techVendors.id, Number(id)))
+ .returning();
+}
+
+// 벤더 정보 업데이트 (다수)
+export async function updateTechVendors(
+ tx: any,
+ ids: (string | number)[],
+ data: Partial<TechVendor>
+) {
+ return tx
+ .update(techVendors)
+ .set({
+ ...data,
+ updatedAt: new Date(),
+ })
+ .where(inArray(techVendors.id, ids.map(id => Number(id))))
+ .returning();
+}
+
+// 벤더 연락처 조회
+export async function selectTechVendorContacts(
+ tx: any,
+ params: {
+ where?: SQL<unknown>;
+ orderBy?: SQL<unknown>[];
+ } & PaginationParams
+) {
+ const query = tx.select().from(techVendorContacts);
+
+ if (params.where) {
+ query.where(params.where);
+ }
+
+ if (params.orderBy && params.orderBy.length > 0) {
+ query.orderBy(...params.orderBy);
+ } else {
+ query.orderBy(desc(techVendorContacts.createdAt));
+ }
+
+ query.offset(params.offset).limit(params.limit);
+
+ return query;
+}
+
+// 벤더 연락처 수 카운트
+export async function countTechVendorContacts(tx: any, where?: SQL<unknown>) {
+ const query = tx.select({ count: count() }).from(techVendorContacts);
+
+ if (where) {
+ query.where(where);
+ }
+
+ const result = await query;
+ return result[0].count;
+}
+
+// 연락처 생성
+export async function insertTechVendorContact(
+ tx: any,
+ data: Omit<TechVendorContact, "id" | "createdAt" | "updatedAt">
+) {
+ return tx
+ .insert(techVendorContacts)
+ .values({
+ ...data,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .returning();
+}
+
+// 아이템 목록 조회
+export async function selectTechVendorItems(
+ tx: any,
+ params: {
+ where?: SQL<unknown>;
+ orderBy?: SQL<unknown>[];
+ } & PaginationParams
+) {
+ const query = tx.select().from(techVendorItemsView);
+
+ if (params.where) {
+ query.where(params.where);
+ }
+
+ if (params.orderBy && params.orderBy.length > 0) {
+ query.orderBy(...params.orderBy);
+ } else {
+ query.orderBy(desc(techVendorItemsView.createdAt));
+ }
+
+ query.offset(params.offset).limit(params.limit);
+
+ return query;
+}
+
+// 아이템 수 카운트
+export async function countTechVendorItems(tx: any, where?: SQL<unknown>) {
+ const query = tx.select({ count: count() }).from(techVendorItemsView);
+
+ if (where) {
+ query.where(where);
+ }
+
+ const result = await query;
+ return result[0].count;
+}
+
+// 아이템 생성
+export async function insertTechVendorItem(
+ tx: any,
+ data: Omit<TechVendorItem, "id" | "createdAt" | "updatedAt">
+) {
+ return tx
+ .insert(techVendorPossibleItems)
+ .values({
+ ...data,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .returning();
+}
+
+// 벤더의 worktype 조회
+export async function getVendorWorkTypes(
+ tx: any,
+ vendorId: number,
+ vendorType: string
+): Promise<string[]> {
+ try {
+ // 벤더의 possible items 조회 - 모든 필드 가져오기
+ const possibleItems = await tx
+ .select({
+ itemCode: techVendorPossibleItems.itemCode,
+ shipTypes: techVendorPossibleItems.shipTypes,
+ itemList: techVendorPossibleItems.itemList,
+ subItemList: techVendorPossibleItems.subItemList,
+ workType: techVendorPossibleItems.workType
+ })
+ .from(techVendorPossibleItems)
+ .where(eq(techVendorPossibleItems.vendorId, vendorId));
+ console.log("possibleItems", possibleItems);
+ if (!possibleItems.length) {
+ return [];
+ }
+
+ const workTypes: string[] = [];
+
+ // 벤더 타입에 따라 해당하는 아이템 테이블에서 worktype 조회
+ if (vendorType.includes('조선')) {
+ const itemCodes = possibleItems
+ .map((item: { itemCode?: string | null }) => item.itemCode)
+ .filter(Boolean);
+
+ if (itemCodes.length > 0) {
+ const shipWorkTypes = await tx
+ .select({ workType: itemShipbuilding.workType })
+ .from(itemShipbuilding)
+ .where(inArray(itemShipbuilding.itemCode, itemCodes));
+
+ workTypes.push(...shipWorkTypes.map((item: { workType: string | null }) => item.workType).filter(Boolean));
+ }
+ }
+
+ if (vendorType.includes('해양TOP')) {
+ // 1. 아이템코드가 있는 경우
+ const itemCodesTop = possibleItems
+ .map((item: { itemCode?: string | null }) => item.itemCode)
+ .filter(Boolean) as string[];
+
+ if (itemCodesTop.length > 0) {
+ const topWorkTypes = await tx
+ .select({ workType: itemOffshoreTop.workType })
+ .from(itemOffshoreTop)
+ .where(inArray(itemOffshoreTop.itemCode, itemCodesTop));
+
+ workTypes.push(
+ ...topWorkTypes
+ .map((item: { workType: string | null }) => item.workType)
+ .filter(Boolean) as string[]
+ );
+ }
+
+ // 2. 아이템코드가 없는 경우 서브아이템리스트로 매칭
+ const itemsWithoutCodeTop = possibleItems.filter(
+ (item: { itemCode?: string | null; subItemList?: string | null }) =>
+ !item.itemCode && item.subItemList
+ );
+ if (itemsWithoutCodeTop.length > 0) {
+ const subItemListsTop = itemsWithoutCodeTop
+ .map((item: { subItemList?: string | null }) => item.subItemList)
+ .filter(Boolean) as string[];
+
+ if (subItemListsTop.length > 0) {
+ const topWorkTypesBySubItem = await tx
+ .select({ workType: itemOffshoreTop.workType })
+ .from(itemOffshoreTop)
+ .where(inArray(itemOffshoreTop.subItemList, subItemListsTop));
+
+ workTypes.push(
+ ...topWorkTypesBySubItem
+ .map((item: { workType: string | null }) => item.workType)
+ .filter(Boolean) as string[]
+ );
+ }
+ }
+ }
+ if (vendorType.includes('해양HULL')) {
+ // 1. 아이템코드가 있는 경우
+ const itemCodes = possibleItems
+ .map((item: { itemCode?: string | null }) => item.itemCode)
+ .filter(Boolean);
+
+ if (itemCodes.length > 0) {
+ const hullWorkTypes = await tx
+ .select({ workType: itemOffshoreHull.workType })
+ .from(itemOffshoreHull)
+ .where(inArray(itemOffshoreHull.itemCode, itemCodes));
+
+ workTypes.push(...hullWorkTypes.map((item: { workType: string | null }) => item.workType).filter(Boolean));
+ }
+
+ // 2. 아이템코드가 없는 경우 서브아이템리스트로 매칭
+ const itemsWithoutCodeHull = possibleItems.filter(
+ (item: { itemCode?: string | null; subItemList?: string | null }) =>
+ !item.itemCode && item.subItemList
+ );
+
+ if (itemsWithoutCodeHull.length > 0) {
+ const subItemListsHull = itemsWithoutCodeHull
+ .map((item: { subItemList?: string | null }) => item.subItemList)
+ .filter(Boolean) as string[];
+
+ if (subItemListsHull.length > 0) {
+ const hullWorkTypesBySubItem = await tx
+ .select({ workType: itemOffshoreHull.workType })
+ .from(itemOffshoreHull)
+ .where(inArray(itemOffshoreHull.subItemList, subItemListsHull));
+
+ workTypes.push(...hullWorkTypesBySubItem.map((item: { workType: string | null }) => item.workType).filter(Boolean));
+ }
+ }
+ }
+ // 중복 제거 후 반환
+ const uniqueWorkTypes = [...new Set(workTypes)];
+
+ return uniqueWorkTypes;
+ } catch (error) {
+ console.error('getVendorWorkTypes 오류:', error);
+ return [];
+ }
+}
diff --git a/lib/tech-vendors/rfq-history-table/tech-vendor-rfq-history-table-columns.tsx b/lib/tech-vendors/rfq-history-table/tech-vendor-rfq-history-table-columns.tsx index a7eed1d2..9a5c85c1 100644 --- a/lib/tech-vendors/rfq-history-table/tech-vendor-rfq-history-table-columns.tsx +++ b/lib/tech-vendors/rfq-history-table/tech-vendor-rfq-history-table-columns.tsx @@ -101,33 +101,33 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<TechVen // ----------------------------------------------------------------
// 2) actions 컬럼 (Dropdown 메뉴)
// ----------------------------------------------------------------
- const actionsColumn: ColumnDef<TechVendorRfqHistoryRow> = {
- id: "actions",
- enableHiding: false,
- cell: function Cell({ row }) {
- return (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- aria-label="Open menu"
- variant="ghost"
- className="flex size-8 p-0 data-[state=open]:bg-muted"
- >
- <Ellipsis className="size-4" aria-hidden="true" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="w-40">
- <DropdownMenuItem
- onSelect={() => setRowAction({ row, type: "update" })}
- >
- View Details
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- )
- },
- size: 40,
- }
+ // const actionsColumn: ColumnDef<TechVendorRfqHistoryRow> = {
+ // id: "actions",
+ // enableHiding: false,
+ // cell: function Cell({ row }) {
+ // return (
+ // <DropdownMenu>
+ // <DropdownMenuTrigger asChild>
+ // <Button
+ // aria-label="Open menu"
+ // variant="ghost"
+ // className="flex size-8 p-0 data-[state=open]:bg-muted"
+ // >
+ // <Ellipsis className="size-4" aria-hidden="true" />
+ // </Button>
+ // </DropdownMenuTrigger>
+ // <DropdownMenuContent align="end" className="w-40">
+ // <DropdownMenuItem
+ // onSelect={() => setRowAction({ row, type: "update" })}
+ // >
+ // View Details
+ // </DropdownMenuItem>
+ // </DropdownMenuContent>
+ // </DropdownMenu>
+ // )
+ // },
+ // size: 40,
+ // }
// ----------------------------------------------------------------
// 3) 일반 컬럼들
@@ -238,6 +238,6 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<TechVen return [
selectColumn,
...basicColumns,
- actionsColumn,
+ // actionsColumn,
]
}
\ No newline at end of file diff --git a/lib/tech-vendors/service.ts b/lib/tech-vendors/service.ts index cb5aa89f..a5881083 100644 --- a/lib/tech-vendors/service.ts +++ b/lib/tech-vendors/service.ts @@ -1,1890 +1,2616 @@ -"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) - -import { revalidateTag, unstable_noStore } from "next/cache"; -import db from "@/db/db"; -import { techVendorAttachments, techVendorContacts, techVendorPossibleItems, techVendors, techVendorItemsView, type TechVendor, techVendorCandidates } from "@/db/schema/techVendors"; -import { items, itemShipbuilding, itemOffshoreTop, itemOffshoreHull } from "@/db/schema/items"; -import { users } from "@/db/schema/users"; - -import { filterColumns } from "@/lib/filter-columns"; -import { unstable_cache } from "@/lib/unstable-cache"; -import { getErrorMessage } from "@/lib/handle-error"; - -import { - insertTechVendor, - updateTechVendor, - groupByTechVendorStatus, - selectTechVendorContacts, - countTechVendorContacts, - insertTechVendorContact, - selectTechVendorItems, - countTechVendorItems, - insertTechVendorItem, - selectTechVendorsWithAttachments, - countTechVendorsWithAttachments, - updateTechVendors, -} from "./repository"; - -import type { - CreateTechVendorSchema, - UpdateTechVendorSchema, - GetTechVendorsSchema, - GetTechVendorContactsSchema, - CreateTechVendorContactSchema, - GetTechVendorItemsSchema, - CreateTechVendorItemSchema, - GetTechVendorRfqHistorySchema, -} from "./validations"; - -import { asc, desc, ilike, inArray, and, or, eq, isNull, not } from "drizzle-orm"; -import path from "path"; -import { sql } from "drizzle-orm"; -import { decryptWithServerAction } from "@/components/drm/drmUtils"; -import { deleteFile, saveDRMFile } from "../file-stroage"; - -/* ----------------------------------------------------- - 1) 조회 관련 ------------------------------------------------------ */ - -/** - * 복잡한 조건으로 기술영업 Vendor 목록을 조회 (+ pagination) 하고, - * 총 개수에 따라 pageCount를 계산해서 리턴. - * Next.js의 unstable_cache를 사용해 일정 시간 캐시. - */ -export async function getTechVendors(input: GetTechVendorsSchema) { - return unstable_cache( - async () => { - try { - const offset = (input.page - 1) * input.perPage; - - // 1) 고급 필터 (workTypes와 techVendorType 제외 - 별도 처리) - const filteredFilters = input.filters.filter( - filter => filter.id !== "workTypes" && filter.id !== "techVendorType" - ); - - const advancedWhere = filterColumns({ - table: techVendors, - filters: filteredFilters, - joinOperator: input.joinOperator, - }); - - // 2) 글로벌 검색 - let globalWhere; - if (input.search) { - const s = `%${input.search}%`; - globalWhere = or( - ilike(techVendors.vendorName, s), - ilike(techVendors.vendorCode, s), - ilike(techVendors.email, s), - ilike(techVendors.status, s) - ); - } - - // 최종 where 결합 - const finalWhere = and(advancedWhere, globalWhere); - - // 벤더 타입 필터링 로직 추가 - let vendorTypeWhere; - if (input.vendorType) { - // URL의 vendorType 파라미터를 실제 벤더 타입으로 매핑 - const vendorTypeMap = { - "ship": "조선", - "top": "해양TOP", - "hull": "해양HULL" - }; - - const actualVendorType = input.vendorType in vendorTypeMap - ? vendorTypeMap[input.vendorType as keyof typeof vendorTypeMap] - : undefined; - if (actualVendorType) { - // techVendorType 필드는 콤마로 구분된 문자열이므로 LIKE 사용 - vendorTypeWhere = ilike(techVendors.techVendorType, `%${actualVendorType}%`); - } - } - - // 간단 검색 (advancedTable=false) 시 예시 - const simpleWhere = and( - input.vendorName - ? ilike(techVendors.vendorName, `%${input.vendorName}%`) - : undefined, - input.status ? ilike(techVendors.status, input.status) : undefined, - input.country - ? ilike(techVendors.country, `%${input.country}%`) - : undefined - ); - - // TechVendorType 필터링 로직 추가 (고급 필터에서) - let techVendorTypeWhere; - const techVendorTypeFilters = input.filters.filter(filter => filter.id === "techVendorType"); - if (techVendorTypeFilters.length > 0) { - const typeFilter = techVendorTypeFilters[0]; - if (Array.isArray(typeFilter.value) && typeFilter.value.length > 0) { - // 각 타입에 대해 LIKE 조건으로 OR 연결 - const typeConditions = typeFilter.value.map(type => - ilike(techVendors.techVendorType, `%${type}%`) - ); - techVendorTypeWhere = or(...typeConditions); - } - } - - // WorkTypes 필터링 로직 추가 - let workTypesWhere; - const workTypesFilters = input.filters.filter(filter => filter.id === "workTypes"); - if (workTypesFilters.length > 0) { - const workTypeFilter = workTypesFilters[0]; - if (Array.isArray(workTypeFilter.value) && workTypeFilter.value.length > 0) { - // workTypes에 해당하는 벤더 ID들을 서브쿼리로 찾음 - const vendorIdsWithWorkTypes = db - .selectDistinct({ vendorId: techVendorPossibleItems.vendorId }) - .from(techVendorPossibleItems) - .leftJoin(itemShipbuilding, eq(techVendorPossibleItems.itemCode, itemShipbuilding.itemCode)) - .leftJoin(itemOffshoreTop, eq(techVendorPossibleItems.itemCode, itemOffshoreTop.itemCode)) - .leftJoin(itemOffshoreHull, eq(techVendorPossibleItems.itemCode, itemOffshoreHull.itemCode)) - .where( - or( - inArray(itemShipbuilding.workType, workTypeFilter.value), - inArray(itemOffshoreTop.workType, workTypeFilter.value), - inArray(itemOffshoreHull.workType, workTypeFilter.value) - ) - ); - - workTypesWhere = inArray(techVendors.id, vendorIdsWithWorkTypes); - } - } - - // 실제 사용될 where (vendorType, techVendorType, workTypes 필터링 추가) - const where = and(finalWhere, vendorTypeWhere, techVendorTypeWhere, workTypesWhere); - - // 정렬 - const orderBy = - input.sort.length > 0 - ? input.sort.map((item) => - item.desc ? desc(techVendors[item.id]) : asc(techVendors[item.id]) - ) - : [asc(techVendors.createdAt)]; - - // 트랜잭션 내에서 데이터 조회 - const { data, total } = await db.transaction(async (tx) => { - // 1) vendor 목록 조회 (with attachments) - const vendorsData = await selectTechVendorsWithAttachments(tx, { - where, - orderBy, - offset, - limit: input.perPage, - }); - - // 2) 전체 개수 - const total = await countTechVendorsWithAttachments(tx, where); - return { data: vendorsData, total }; - }); - - // 페이지 수 - const pageCount = Math.ceil(total / input.perPage); - - return { data, pageCount }; - } catch (err) { - console.error("Error fetching tech vendors:", err); - // 에러 발생 시 - return { data: [], pageCount: 0 }; - } - }, - [JSON.stringify(input)], // 캐싱 키 - { - revalidate: 3600, - tags: ["tech-vendors"], // revalidateTag("tech-vendors") 호출 시 무효화 - } - )(); -} - -/** - * 기술영업 벤더 상태별 카운트 조회 - */ -export async function getTechVendorStatusCounts() { - return unstable_cache( - async () => { - try { - const initial: Record<TechVendor["status"], number> = { - "PENDING_REVIEW": 0, - "ACTIVE": 0, - "INACTIVE": 0, - "BLACKLISTED": 0, - }; - - const result = await db.transaction(async (tx) => { - const rows = await groupByTechVendorStatus(tx); - type StatusCountRow = { status: TechVendor["status"]; count: number }; - return (rows as StatusCountRow[]).reduce<Record<TechVendor["status"], number>>((acc, { status, count }) => { - acc[status] = count; - return acc; - }, initial); - }); - - return result; - } catch (err) { - return {} as Record<TechVendor["status"], number>; - } - }, - ["tech-vendor-status-counts"], // 캐싱 키 - { - revalidate: 3600, - } - )(); -} - -/** - * 벤더 상세 정보 조회 - */ -export async function getTechVendorById(id: number) { - return unstable_cache( - async () => { - try { - const result = await getTechVendorDetailById(id); - return { data: result }; - } catch (err) { - console.error("기술영업 벤더 상세 조회 오류:", err); - return { data: null }; - } - }, - [`tech-vendor-${id}`], - { - revalidate: 3600, - tags: ["tech-vendors", `tech-vendor-${id}`], - } - )(); -} - -/* ----------------------------------------------------- - 2) 생성(Create) ------------------------------------------------------ */ - -/** - * 첨부파일 저장 헬퍼 함수 - */ -async function storeTechVendorFiles( - tx: any, - vendorId: number, - files: File[], - attachmentType: string -) { - - for (const file of files) { - - const saveResult = await saveDRMFile(file, decryptWithServerAction, `tech-vendors/${vendorId}`) - - // Insert attachment record - await tx.insert(techVendorAttachments).values({ - vendorId, - fileName: file.name, - filePath: saveResult.publicPath, - attachmentType, - }); - } -} - -/** - * 신규 기술영업 벤더 생성 - */ -export async function createTechVendor(input: CreateTechVendorSchema) { - unstable_noStore(); - - try { - // taxId 중복 검사 - const existingVendor = await db - .select({ id: techVendors.id }) - .from(techVendors) - .where(eq(techVendors.taxId, input.taxId)) - .limit(1); - - // 이미 동일한 taxId를 가진 업체가 존재하면 에러 반환 - if (existingVendor.length > 0) { - return { - success: false, - data: null, - error: `이미 등록된 사업자등록번호입니다. (Tax ID ${input.taxId} already exists in the system)` - }; - } - - const result = await db.transaction(async (tx) => { - // 1. 벤더 생성 - const [newVendor] = await insertTechVendor(tx, { - vendorName: input.vendorName, - vendorCode: input.vendorCode || null, - 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, - techVendorType: Array.isArray(input.techVendorType) ? input.techVendorType.join(',') : input.techVendorType, - representativeName: input.representativeName || null, - representativeBirth: input.representativeBirth || null, - representativeEmail: input.representativeEmail || null, - representativePhone: input.representativePhone || null, - items: input.items || null, - status: "ACTIVE" - }); - - // 2. 연락처 정보 등록 - for (const contact of input.contacts) { - await insertTechVendorContact(tx, { - vendorId: newVendor.id, - contactName: contact.contactName, - contactPosition: contact.contactPosition || null, - contactEmail: contact.contactEmail, - contactPhone: contact.contactPhone || null, - isPrimary: contact.isPrimary ?? false, - }); - } - - // 3. 첨부파일 저장 - if (input.files && input.files.length > 0) { - await storeTechVendorFiles(tx, newVendor.id, input.files, "GENERAL"); - } - - return newVendor; - }); - - revalidateTag("tech-vendors"); - - return { - success: true, - data: result, - error: null - }; - } catch (err) { - console.error("기술영업 벤더 생성 오류:", err); - - return { - success: false, - data: null, - error: getErrorMessage(err) - }; - } -} - -/* ----------------------------------------------------- - 3) 업데이트 (단건/복수) ------------------------------------------------------ */ - -/** 단건 업데이트 */ -export async function modifyTechVendor( - input: UpdateTechVendorSchema & { id: string; } -) { - unstable_noStore(); - try { - const updated = await db.transaction(async (tx) => { - // 벤더 정보 업데이트 - const [res] = await updateTechVendor(tx, input.id, { - vendorName: input.vendorName, - vendorCode: input.vendorCode, - address: input.address, - country: input.country, - phone: input.phone, - email: input.email, - website: input.website, - status: input.status, - }); - - return res; - }); - - // 캐시 무효화 - revalidateTag("tech-vendors"); - revalidateTag(`tech-vendor-${input.id}`); - - return { data: updated, error: null }; - } catch (err) { - return { data: null, error: getErrorMessage(err) }; - } -} - -/** 복수 업데이트 */ -export async function modifyTechVendors(input: { - ids: string[]; - status?: TechVendor["status"]; -}) { - unstable_noStore(); - try { - const data = await db.transaction(async (tx) => { - // 여러 협력업체 일괄 업데이트 - const [updated] = await updateTechVendors(tx, input.ids, { - // 예: 상태만 일괄 변경 - status: input.status, - }); - return updated; - }); - - // 캐시 무효화 - revalidateTag("tech-vendors"); - revalidateTag("tech-vendor-status-counts"); - - return { data: null, error: null }; - } catch (err) { - return { data: null, error: getErrorMessage(err) }; - } -} - -/* ----------------------------------------------------- - 4) 연락처 관리 ------------------------------------------------------ */ - -export async function getTechVendorContacts(input: GetTechVendorContactsSchema, id: number) { - return unstable_cache( - async () => { - try { - const offset = (input.page - 1) * input.perPage; - - // 필터링 설정 - const advancedWhere = filterColumns({ - table: techVendorContacts, - filters: input.filters, - joinOperator: input.joinOperator, - }); - - // 검색 조건 - let globalWhere; - if (input.search) { - const s = `%${input.search}%`; - globalWhere = or( - ilike(techVendorContacts.contactName, s), - ilike(techVendorContacts.contactPosition, s), - ilike(techVendorContacts.contactEmail, s), - ilike(techVendorContacts.contactPhone, s) - ); - } - - // 해당 벤더 조건 - const vendorWhere = eq(techVendorContacts.vendorId, id); - - // 최종 조건 결합 - const finalWhere = and(advancedWhere, globalWhere, vendorWhere); - - // 정렬 조건 - const orderBy = - input.sort.length > 0 - ? input.sort.map((item) => - item.desc ? desc(techVendorContacts[item.id]) : asc(techVendorContacts[item.id]) - ) - : [asc(techVendorContacts.createdAt)]; - - // 트랜잭션 내부에서 Repository 호출 - const { data, total } = await db.transaction(async (tx) => { - const data = await selectTechVendorContacts(tx, { - where: finalWhere, - orderBy, - offset, - limit: input.perPage, - }); - const total = await countTechVendorContacts(tx, finalWhere); - return { data, total }; - }); - - const pageCount = Math.ceil(total / input.perPage); - - return { data, pageCount }; - } catch (err) { - // 에러 발생 시 디폴트 - return { data: [], pageCount: 0 }; - } - }, - [JSON.stringify(input), String(id)], // 캐싱 키 - { - revalidate: 3600, - tags: [`tech-vendor-contacts-${id}`], - } - )(); -} - -export async function createTechVendorContact(input: CreateTechVendorContactSchema) { - unstable_noStore(); - try { - await db.transaction(async (tx) => { - // DB Insert - const [newContact] = await insertTechVendorContact(tx, { - vendorId: input.vendorId, - contactName: input.contactName, - contactPosition: input.contactPosition || "", - contactEmail: input.contactEmail, - contactPhone: input.contactPhone || "", - country: input.country || "", - isPrimary: input.isPrimary || false, - }); - - return newContact; - }); - - // 캐시 무효화 - revalidateTag(`tech-vendor-contacts-${input.vendorId}`); - revalidateTag("users"); - - return { data: null, error: null }; - } catch (err) { - return { data: null, error: getErrorMessage(err) }; - } -} - -/* ----------------------------------------------------- - 5) 아이템 관리 ------------------------------------------------------ */ - -export async function getTechVendorItems(input: GetTechVendorItemsSchema, id: number) { - return unstable_cache( - async () => { - try { - const offset = (input.page - 1) * input.perPage; - - // 필터링 설정 - const advancedWhere = filterColumns({ - table: techVendorItemsView, - filters: input.filters, - joinOperator: input.joinOperator, - }); - - // 검색 조건 - let globalWhere; - if (input.search) { - const s = `%${input.search}%`; - globalWhere = or( - ilike(techVendorItemsView.itemCode, s) - ); - } - - // 해당 벤더 조건 - const vendorWhere = eq(techVendorItemsView.vendorId, id); - - // 최종 조건 결합 - const finalWhere = and(advancedWhere, globalWhere, vendorWhere); - - // 정렬 조건 - const orderBy = - input.sort.length > 0 - ? input.sort.map((item) => - item.desc ? desc(techVendorItemsView[item.id]) : asc(techVendorItemsView[item.id]) - ) - : [asc(techVendorItemsView.createdAt)]; - - // 트랜잭션 내부에서 Repository 호출 - const { data, total } = await db.transaction(async (tx) => { - const data = await selectTechVendorItems(tx, { - where: finalWhere, - orderBy, - offset, - limit: input.perPage, - }); - const total = await countTechVendorItems(tx, finalWhere); - return { data, total }; - }); - - const pageCount = Math.ceil(total / input.perPage); - - return { data, pageCount }; - } catch (err) { - // 에러 발생 시 디폴트 - return { data: [], pageCount: 0 }; - } - }, - [JSON.stringify(input), String(id)], // 캐싱 키 - { - revalidate: 3600, - tags: [`tech-vendor-items-${id}`], - } - )(); -} - -export interface ItemDropdownOption { - itemCode: string; - itemList: string; - workType: string | null; - shipTypes: string | null; - subItemList: string | null; -} - -/** - * Vendor Item 추가 시 사용할 아이템 목록 조회 (전체 목록 반환) - * 아이템 코드, 이름, 설명만 간소화해서 반환 - */ -export async function getItemsForTechVendor(vendorId: number) { - return unstable_cache( - async () => { - try { - // 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: techVendorPossibleItems.itemCode, - }) - .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: availableItems, - error: null - }; - } catch (err) { - console.error("Failed to fetch items for tech vendor dropdown:", err); - return { - data: [], - error: "아이템 목록을 불러오는데 실패했습니다.", - }; - } - }, - // 캐시 키를 vendorId 별로 달리 해야 한다. - ["items-for-tech-vendor", String(vendorId)], - { - revalidate: 3600, // 1시간 캐싱 - tags: ["items"], // revalidateTag("items") 호출 시 무효화 - } - )(); -} - -/** - * 벤더 타입과 아이템 코드에 따른 아이템 조회 - */ -export async function getItemsByVendorType(vendorType: string, itemCode: string) { - try { - let items: (typeof itemShipbuilding.$inferSelect | typeof itemOffshoreTop.$inferSelect | typeof itemOffshoreHull.$inferSelect)[] = []; - - switch (vendorType) { - case "조선": - const shipbuildingResults = await db - .select({ - id: itemShipbuilding.id, - itemCode: itemShipbuilding.itemCode, - workType: itemShipbuilding.workType, - shipTypes: itemShipbuilding.shipTypes, - itemList: itemShipbuilding.itemList, - createdAt: itemShipbuilding.createdAt, - updatedAt: itemShipbuilding.updatedAt, - }) - .from(itemShipbuilding) - .where(itemCode ? eq(itemShipbuilding.itemCode, itemCode) : undefined); - items = shipbuildingResults; - break; - - case "해양TOP": - const offshoreTopResults = await db - .select({ - id: itemOffshoreTop.id, - itemCode: itemOffshoreTop.itemCode, - workType: itemOffshoreTop.workType, - itemList: itemOffshoreTop.itemList, - subItemList: itemOffshoreTop.subItemList, - createdAt: itemOffshoreTop.createdAt, - updatedAt: itemOffshoreTop.updatedAt, - }) - .from(itemOffshoreTop) - .where(itemCode ? eq(itemOffshoreTop.itemCode, itemCode) : undefined); - items = offshoreTopResults; - break; - - case "해양HULL": - const offshoreHullResults = await db - .select({ - id: itemOffshoreHull.id, - itemCode: itemOffshoreHull.itemCode, - workType: itemOffshoreHull.workType, - itemList: itemOffshoreHull.itemList, - subItemList: itemOffshoreHull.subItemList, - createdAt: itemOffshoreHull.createdAt, - updatedAt: itemOffshoreHull.updatedAt, - }) - .from(itemOffshoreHull) - .where(itemCode ? eq(itemOffshoreHull.itemCode, itemCode) : undefined); - items = offshoreHullResults; - break; - - default: - items = []; - } - - const result = items.map(item => ({ - ...item, - techVendorType: vendorType - })); - - return { data: result, error: null }; - } catch (err) { - console.error("Error fetching items by vendor type:", err); - return { data: [], error: "Failed to fetch items" }; - } -} - -/** - * 벤더의 possible_items를 조회하고 해당 아이템 코드로 각 타입별 테이블을 조회 - * 벤더 타입이 콤마로 구분된 경우 (예: "조선,해양TOP,해양HULL") 모든 타입의 아이템을 조회 - */ -export async function getVendorItemsByType(vendorId: number, vendorType: string) { - try { - // 벤더의 possible_items 조회 - const possibleItems = await db.query.techVendorPossibleItems.findMany({ - where: eq(techVendorPossibleItems.vendorId, vendorId), - columns: { - itemCode: true - } - }) - - const itemCodes = possibleItems.map(item => item.itemCode) - - if (itemCodes.length === 0) { - return { data: [] } - } - - // 벤더 타입을 콤마로 분리 - const vendorTypes = vendorType.split(',').map(type => type.trim()) - const allItems: Array<Record<string, any> & { techVendorType: "조선" | "해양TOP" | "해양HULL" }> = [] - - // 각 벤더 타입에 따라 해당하는 테이블에서 아이템 조회 - for (const singleType of vendorTypes) { - switch (singleType) { - case "조선": - const shipbuildingItems = await db.query.itemShipbuilding.findMany({ - where: inArray(itemShipbuilding.itemCode, itemCodes) - }) - allItems.push(...shipbuildingItems.map(item => ({ - ...item, - techVendorType: "조선" as const - }))) - break - - case "해양TOP": - const offshoreTopItems = await db.query.itemOffshoreTop.findMany({ - where: inArray(itemOffshoreTop.itemCode, itemCodes) - }) - allItems.push(...offshoreTopItems.map(item => ({ - ...item, - techVendorType: "해양TOP" as const - }))) - break - - case "해양HULL": - const offshoreHullItems = await db.query.itemOffshoreHull.findMany({ - where: inArray(itemOffshoreHull.itemCode, itemCodes) - }) - allItems.push(...offshoreHullItems.map(item => ({ - ...item, - techVendorType: "해양HULL" as const - }))) - break - - default: - console.warn(`Unknown vendor type: ${singleType}`) - break - } - } - - // 중복 허용 - 모든 아이템을 그대로 반환 - return { - data: allItems.sort((a, b) => a.itemCode.localeCompare(b.itemCode)) - } - } catch (err) { - console.error("Error getting vendor items by type:", err) - return { data: [] } - } -} - -export async function createTechVendorItem(input: CreateTechVendorItemSchema) { - unstable_noStore(); - try { - // DB에 이미 존재하는지 확인 - const existingItem = await db - .select({ id: techVendorPossibleItems.id }) - .from(techVendorPossibleItems) - .where( - and( - eq(techVendorPossibleItems.vendorId, input.vendorId), - eq(techVendorPossibleItems.itemCode, input.itemCode) - ) - ) - .limit(1); - - if (existingItem.length > 0) { - return { data: null, error: "이미 추가된 아이템입니다." }; - } - - await db.transaction(async (tx) => { - // DB Insert - const [newItem] = await tx - .insert(techVendorPossibleItems) - .values({ - vendorId: input.vendorId, - itemCode: input.itemCode, - }) - .returning(); - return newItem; - }); - - // 캐시 무효화 - revalidateTag(`tech-vendor-items-${input.vendorId}`); - - return { data: null, error: null }; - } catch (err) { - return { data: null, error: getErrorMessage(err) }; - } -} - -/* ----------------------------------------------------- - 6) 기술영업 벤더 승인/거부 ------------------------------------------------------ */ - -interface ApproveTechVendorsInput { - ids: string[]; -} - -/** - * 기술영업 벤더 승인 (상태를 ACTIVE로 변경) - */ -export async function approveTechVendors(input: ApproveTechVendorsInput) { - unstable_noStore(); - - try { - // 트랜잭션 내에서 협력업체 상태 업데이트 - const result = await db.transaction(async (tx) => { - // 협력업체 상태 업데이트 - const [updated] = await tx - .update(techVendors) - .set({ - status: "ACTIVE", - updatedAt: new Date() - }) - .where(inArray(techVendors.id, input.ids.map(id => parseInt(id)))) - .returning(); - - return updated; - }); - - // 캐시 무효화 - revalidateTag("tech-vendors"); - revalidateTag("tech-vendor-status-counts"); - - return { data: result, error: null }; - } catch (err) { - console.error("Error approving tech vendors:", err); - return { data: null, error: getErrorMessage(err) }; - } -} - -/** - * 기술영업 벤더 거부 (상태를 REJECTED로 변경) - */ -export async function rejectTechVendors(input: ApproveTechVendorsInput) { - unstable_noStore(); - - try { - // 트랜잭션 내에서 협력업체 상태 업데이트 - const result = await db.transaction(async (tx) => { - // 협력업체 상태 업데이트 - const [updated] = await tx - .update(techVendors) - .set({ - status: "INACTIVE", - updatedAt: new Date() - }) - .where(inArray(techVendors.id, input.ids.map(id => parseInt(id)))) - .returning(); - - return updated; - }); - - // 캐시 무효화 - revalidateTag("tech-vendors"); - revalidateTag("tech-vendor-status-counts"); - - return { data: result, error: null }; - } catch (err) { - console.error("Error rejecting tech vendors:", err); - return { data: null, error: getErrorMessage(err) }; - } -} - -/* ----------------------------------------------------- - 7) 엑셀 내보내기 ------------------------------------------------------ */ - -/** - * 벤더 연락처 목록 엑셀 내보내기 - */ -export async function exportTechVendorContacts(vendorId: number) { - try { - const contacts = await db - .select() - .from(techVendorContacts) - .where(eq(techVendorContacts.vendorId, vendorId)) - .orderBy(techVendorContacts.isPrimary, techVendorContacts.contactName); - - return contacts; - } catch (err) { - console.error("기술영업 벤더 연락처 내보내기 오류:", err); - return []; - } -} - -/** - * 벤더 아이템 목록 엑셀 내보내기 - */ -export async function exportTechVendorItems(vendorId: number) { - try { - const items = await db - .select({ - id: techVendorItemsView.vendorItemId, - vendorId: techVendorItemsView.vendorId, - itemCode: techVendorItemsView.itemCode, - createdAt: techVendorItemsView.createdAt, - updatedAt: techVendorItemsView.updatedAt, - }) - .from(techVendorItemsView) - .where(eq(techVendorItemsView.vendorId, vendorId)) - - return items; - } catch (err) { - console.error("기술영업 벤더 아이템 내보내기 오류:", err); - return []; - } -} - -/** - * 벤더 정보 엑셀 내보내기 - */ -export async function exportTechVendorDetails(vendorIds: number[]) { - try { - if (!vendorIds.length) return []; - - // 벤더 기본 정보 조회 - const vendorsData = await db - .select({ - id: techVendors.id, - vendorName: techVendors.vendorName, - vendorCode: techVendors.vendorCode, - taxId: techVendors.taxId, - address: techVendors.address, - country: techVendors.country, - phone: techVendors.phone, - email: techVendors.email, - website: techVendors.website, - status: techVendors.status, - representativeName: techVendors.representativeName, - representativeEmail: techVendors.representativeEmail, - representativePhone: techVendors.representativePhone, - representativeBirth: techVendors.representativeBirth, - items: techVendors.items, - createdAt: techVendors.createdAt, - updatedAt: techVendors.updatedAt, - }) - .from(techVendors) - .where( - vendorIds.length === 1 - ? eq(techVendors.id, vendorIds[0]) - : inArray(techVendors.id, vendorIds) - ); - - // 벤더별 상세 정보를 포함하여 반환 - const vendorsWithDetails = await Promise.all( - vendorsData.map(async (vendor) => { - // 연락처 조회 - const contacts = await exportTechVendorContacts(vendor.id); - - // 아이템 조회 - const items = await exportTechVendorItems(vendor.id); - - return { - ...vendor, - vendorContacts: contacts, - vendorItems: items, - }; - }) - ); - - return vendorsWithDetails; - } catch (err) { - console.error("기술영업 벤더 상세 내보내기 오류:", err); - return []; - } -} - -/** - * 기술영업 벤더 상세 정보 조회 (연락처, 첨부파일 포함) - */ -export async function getTechVendorDetailById(id: number) { - try { - const vendor = await db.select().from(techVendors).where(eq(techVendors.id, id)).limit(1); - - if (!vendor || vendor.length === 0) { - console.error(`Vendor not found with id: ${id}`); - return null; - } - - const contacts = await db.select().from(techVendorContacts).where(eq(techVendorContacts.vendorId, id)); - const attachments = await db.select().from(techVendorAttachments).where(eq(techVendorAttachments.vendorId, id)); - const possibleItems = await db.select().from(techVendorPossibleItems).where(eq(techVendorPossibleItems.vendorId, id)); - - return { - ...vendor[0], - contacts, - attachments, - possibleItems - }; - } catch (error) { - console.error("Error fetching tech vendor detail:", error); - return null; - } -} - -/** - * 기술영업 벤더 첨부파일 다운로드를 위한 서버 액션 - * @param vendorId 기술영업 벤더 ID - * @param fileId 특정 파일 ID (단일 파일 다운로드시) - * @returns 다운로드할 수 있는 임시 URL - */ -export async function downloadTechVendorAttachments(vendorId:number, fileId?:number) { - try { - // API 경로 생성 (단일 파일 또는 모든 파일) - const url = fileId - ? `/api/tech-vendors/attachments/download?id=${fileId}&vendorId=${vendorId}` - : `/api/tech-vendors/attachments/download-all?vendorId=${vendorId}`; - - // fetch 요청 (기본적으로 Blob으로 응답 받기) - const response = await fetch(url, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - throw new Error(`Server responded with ${response.status}: ${response.statusText}`); - } - - // 파일명 가져오기 (Content-Disposition 헤더에서) - const contentDisposition = response.headers.get('content-disposition'); - let fileName = fileId ? `file-${fileId}.zip` : `tech-vendor-${vendorId}-files.zip`; - - if (contentDisposition) { - const matches = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(contentDisposition); - if (matches && matches[1]) { - fileName = matches[1].replace(/['"]/g, ''); - } - } - - // Blob으로 응답 변환 - const blob = await response.blob(); - - // Blob URL 생성 - const blobUrl = window.URL.createObjectURL(blob); - - return { - url: blobUrl, - fileName, - blob - }; - } catch (error) { - console.error('Download API error:', error); - throw error; - } -} - -/** - * 임시 ZIP 파일 정리를 위한 서버 액션 - * @param fileName 정리할 파일명 - */ -export async function cleanupTechTempFiles(fileName: string) { - 'use server'; - - try { - - await deleteFile(`tmp/${fileName}`) - - return { success: true }; - } catch (error) { - console.error('임시 파일 정리 오류:', error); - return { success: false, error: '임시 파일 정리 중 오류가 발생했습니다.' }; - } -} - -export const findVendorById = async (id: number): Promise<TechVendor | null> => { - try { - // 직접 DB에서 조회 - const vendor = await db - .select() - .from(techVendors) - .where(eq(techVendors.id, id)) - .limit(1) - .then(rows => rows[0] || null); - - if (!vendor) { - console.error(`Vendor not found with id: ${id}`); - return null; - } - - return vendor; - } catch (error) { - console.error('Error fetching vendor:', error); - return null; - } -}; - -/* ----------------------------------------------------- - 8) 기술영업 벤더 RFQ 히스토리 조회 ------------------------------------------------------ */ - -/** - * 기술영업 벤더의 RFQ 히스토리 조회 (간단한 버전) - */ -export async function getTechVendorRfqHistory(input: GetTechVendorRfqHistorySchema, id:number) { - try { - - // 먼저 해당 벤더의 견적서가 있는지 확인 - const { techSalesVendorQuotations } = await import("@/db/schema/techSales"); - - const quotationCheck = await db - .select({ count: sql<number>`count(*)`.as("count") }) - .from(techSalesVendorQuotations) - .where(eq(techSalesVendorQuotations.vendorId, id)); - - console.log(`벤더 ${id}의 견적서 개수:`, quotationCheck[0]?.count); - - if (quotationCheck[0]?.count === 0) { - console.log("해당 벤더의 견적서가 없습니다."); - return { data: [], pageCount: 0 }; - } - - const offset = (input.page - 1) * input.perPage; - const { techSalesRfqs } = await import("@/db/schema/techSales"); - const { biddingProjects } = await import("@/db/schema/projects"); - - // 간단한 조회 - let whereCondition = eq(techSalesVendorQuotations.vendorId, id); - - // 검색이 있다면 추가 - if (input.search) { - const s = `%${input.search}%`; - const searchCondition = and( - whereCondition, - or( - ilike(techSalesRfqs.rfqCode, s), - ilike(techSalesRfqs.description, s), - ilike(biddingProjects.pspid, s), - ilike(biddingProjects.projNm, s) - ) - ); - whereCondition = searchCondition; - } - - // 데이터 조회 - 테이블에 필요한 필드들 (프로젝트 타입 추가) - const data = await db - .select({ - id: techSalesRfqs.id, - rfqCode: techSalesRfqs.rfqCode, - description: techSalesRfqs.description, - projectCode: biddingProjects.pspid, - projectName: biddingProjects.projNm, - projectType: biddingProjects.pjtType, // 프로젝트 타입 추가 - status: techSalesRfqs.status, - totalAmount: techSalesVendorQuotations.totalPrice, - currency: techSalesVendorQuotations.currency, - dueDate: techSalesRfqs.dueDate, - createdAt: techSalesRfqs.createdAt, - quotationCode: techSalesVendorQuotations.quotationCode, - submittedAt: techSalesVendorQuotations.submittedAt, - }) - .from(techSalesVendorQuotations) - .innerJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id)) - .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id)) - .where(whereCondition) - .orderBy(desc(techSalesRfqs.createdAt)) - .limit(input.perPage) - .offset(offset); - - console.log("조회된 데이터:", data.length, "개"); - - // 전체 개수 조회 - const totalResult = await db - .select({ count: sql<number>`count(*)`.as("count") }) - .from(techSalesVendorQuotations) - .innerJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id)) - .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id)) - .where(whereCondition); - - const total = totalResult[0]?.count || 0; - const pageCount = Math.ceil(total / input.perPage); - - console.log("기술영업 벤더 RFQ 히스토리 조회 완료", { - id, - dataLength: data.length, - total, - pageCount - }); - - return { data, pageCount }; - } catch (err) { - console.error("기술영업 벤더 RFQ 히스토리 조회 오류:", { - err, - id, - stack: err instanceof Error ? err.stack : undefined - }); - return { data: [], pageCount: 0 }; - } -} - -/** - * 기술영업 벤더 엑셀 import 시 유저 생성 및 아이템 등록 - */ -export async function importTechVendorsFromExcel( - vendors: Array<{ - 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: 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) { - console.log("벤더 처리 시작:", vendor.vendorName); - - try { - // 1. 벤더 생성 - console.log("벤더 생성 시도:", { - vendorName: vendor.vendorName, - email: vendor.email, - techVendorType: vendor.techVendorType - }); - - 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, - 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 } - }); - - // 유저가 존재하지 않는 경우에만 생성 - 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); - console.log("벤더 처리 완료:", vendor.vendorName); - } catch (error) { - console.error("벤더 처리 중 오류 발생:", vendor.vendorName, error); - throw error; - } - } - - console.log("모든 벤더 처리 완료. 생성된 벤더 수:", createdVendors.length); - return createdVendors; - }); - - // 캐시 무효화 - 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 createTechVendorFromSignup(params: { - vendorData: { - vendorName: string - vendorCode?: string - items: string - website?: string - taxId: string - address?: string - email: string - phone?: string - country: string - techVendorType: "조선" | "해양TOP" | "해양HULL" - representativeName?: string - representativeBirth?: string - representativeEmail?: string - representativePhone?: string - } - files?: File[] - contacts: { - contactName: string - contactPosition?: string - contactEmail: string - contactPhone?: string - isPrimary?: boolean - }[] - invitationToken?: string // 초대 토큰 -}) { - unstable_noStore(); - - try { - console.log("기술영업 벤더 회원가입 시작:", params.vendorData.vendorName); - - // 초대 토큰 검증 - let existingVendorId: number | null = null; - if (params.invitationToken) { - const { verifyTechVendorInvitationToken } = await import("@/lib/tech-vendor-invitation-token"); - const tokenPayload = await verifyTechVendorInvitationToken(params.invitationToken); - - if (!tokenPayload) { - throw new Error("유효하지 않은 초대 토큰입니다."); - } - - existingVendorId = tokenPayload.vendorId; - console.log("초대 토큰 검증 성공, 벤더 ID:", existingVendorId); - } - - const result = await db.transaction(async (tx) => { - let vendorResult; - - if (existingVendorId) { - // 기존 벤더 정보 업데이트 - const [updatedVendor] = await tx.update(techVendors) - .set({ - vendorName: params.vendorData.vendorName, - vendorCode: params.vendorData.vendorCode || null, - taxId: params.vendorData.taxId, - country: params.vendorData.country, - address: params.vendorData.address || null, - phone: params.vendorData.phone || null, - email: params.vendorData.email, - website: params.vendorData.website || null, - techVendorType: params.vendorData.techVendorType, - status: "QUOTE_COMPARISON", // 가입 완료 시 QUOTE_COMPARISON으로 변경 - representativeName: params.vendorData.representativeName || null, - representativeEmail: params.vendorData.representativeEmail || null, - representativePhone: params.vendorData.representativePhone || null, - representativeBirth: params.vendorData.representativeBirth || null, - items: params.vendorData.items, - updatedAt: new Date(), - }) - .where(eq(techVendors.id, existingVendorId)) - .returning(); - - vendorResult = updatedVendor; - console.log("기존 벤더 정보 업데이트 완료:", vendorResult.id); - } else { - // 1. 이메일 중복 체크 (새 벤더인 경우) - const existingVendor = await tx.query.techVendors.findFirst({ - where: eq(techVendors.email, params.vendorData.email), - columns: { id: true, vendorName: true } - }); - - if (existingVendor) { - throw new Error(`이미 등록된 이메일입니다: ${params.vendorData.email}`); - } - - // 2. 새 벤더 생성 - const [newVendor] = await tx.insert(techVendors).values({ - vendorName: params.vendorData.vendorName, - vendorCode: params.vendorData.vendorCode || null, - taxId: params.vendorData.taxId, - country: params.vendorData.country, - address: params.vendorData.address || null, - phone: params.vendorData.phone || null, - email: params.vendorData.email, - website: params.vendorData.website || null, - techVendorType: params.vendorData.techVendorType, - status: "ACTIVE", - isQuoteComparison: false, - representativeName: params.vendorData.representativeName || null, - representativeEmail: params.vendorData.representativeEmail || null, - representativePhone: params.vendorData.representativePhone || null, - representativeBirth: params.vendorData.representativeBirth || null, - items: params.vendorData.items, - }).returning(); - - vendorResult = newVendor; - console.log("새 벤더 생성 완료:", vendorResult.id); - } - - // 이 부분은 위에서 이미 처리되었으므로 주석 처리 - - // 3. 연락처 생성 - if (params.contacts && params.contacts.length > 0) { - for (const [index, contact] of params.contacts.entries()) { - await tx.insert(techVendorContacts).values({ - vendorId: vendorResult.id, - contactName: contact.contactName, - contactPosition: contact.contactPosition || null, - contactEmail: contact.contactEmail, - contactPhone: contact.contactPhone || null, - isPrimary: index === 0, // 첫 번째 연락처를 primary로 설정 - }); - } - console.log("연락처 생성 완료:", params.contacts.length, "개"); - } - - // 4. 첨부파일 처리 - if (params.files && params.files.length > 0) { - await storeTechVendorFiles(tx, vendorResult.id, params.files, "GENERAL"); - console.log("첨부파일 저장 완료:", params.files.length, "개"); - } - - // 5. 유저 생성 (techCompanyId 설정) - console.log("유저 생성 시도:", params.vendorData.email); - - const existingUser = await tx.query.users.findFirst({ - where: eq(users.email, params.vendorData.email), - columns: { id: true, techCompanyId: true } - }); - - let userId = null; - if (!existingUser) { - const [newUser] = await tx.insert(users).values({ - name: params.vendorData.vendorName, - email: params.vendorData.email, - techCompanyId: vendorResult.id, // 중요: techCompanyId 설정 - domain: "partners", - }).returning(); - userId = newUser.id; - console.log("유저 생성 성공:", userId); - } else { - // 기존 유저의 techCompanyId 업데이트 - if (!existingUser.techCompanyId) { - await tx.update(users) - .set({ techCompanyId: vendorResult.id }) - .where(eq(users.id, existingUser.id)); - console.log("기존 유저의 techCompanyId 업데이트:", existingUser.id); - } - userId = existingUser.id; - } - - // 6. 후보에서 해당 이메일이 있으면 vendorId 업데이트 및 상태 변경 - if (params.vendorData.email) { - await tx.update(techVendorCandidates) - .set({ - vendorId: vendorResult.id, - status: "INVITED" - }) - .where(eq(techVendorCandidates.contactEmail, params.vendorData.email)); - } - - return { vendor: vendorResult, userId }; - }); - - // 캐시 무효화 - revalidateTag("tech-vendors"); - revalidateTag("tech-vendor-candidates"); - revalidateTag("users"); - - console.log("기술영업 벤더 회원가입 완료:", result); - return { success: true, data: result }; - } catch (error) { - console.error("기술영업 벤더 회원가입 실패:", error); - return { success: false, error: getErrorMessage(error) }; - } -} - -/** - * 단일 기술영업 벤더 추가 (사용자 계정도 함께 생성) - */ -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: string; - representativeName?: string | null; - representativeEmail?: string | null; - representativePhone?: string | null; - representativeBirth?: string | null; - isQuoteComparison?: boolean; -}) { - 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 || null, - 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: Array.isArray(input.techVendorType) ? input.techVendorType.join(',') : input.techVendorType, - status: input.isQuoteComparison ? "PENDING_INVITE" : "ACTIVE", - isQuoteComparison: input.isQuoteComparison || false, - representativeName: input.representativeName || null, - representativeEmail: input.representativeEmail || null, - representativePhone: input.representativePhone || null, - representativeBirth: input.representativeBirth || null, - }).returning(); - - console.log("벤더 생성 성공:", newVendor.id); - - // 3. 견적비교용 벤더인 경우 PENDING_REVIEW 상태로 생성됨 - // 초대는 별도의 초대 버튼을 통해 진행 - console.log("벤더 생성 완료:", newVendor.id, "상태:", newVendor.status); - - // 4. 유저 생성 (techCompanyId 설정) - console.log("유저 생성 시도:", input.email); - - // 이미 존재하는 유저인지 확인 - const existingUser = await tx.query.users.findFirst({ - where: eq(users.email, input.email), - columns: { id: true, techCompanyId: 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 { - // 이미 존재하는 유저의 techCompanyId가 null인 경우 업데이트 - if (!existingUser.techCompanyId) { - await tx.update(users) - .set({ techCompanyId: newVendor.id }) - .where(eq(users.id, existingUser.id)); - console.log("기존 유저의 techCompanyId 업데이트:", existingUser.id); - } - userId = existingUser.id; - console.log("이미 존재하는 유저:", userId); - } - - return { vendor: newVendor, userId }; - }); - - // 캐시 무효화 - revalidateTag("tech-vendors"); - revalidateTag("users"); - - console.log("벤더 추가 완료:", result); - return { success: true, data: result }; - } catch (error) { - console.error("벤더 추가 실패:", error); - return { success: false, error: getErrorMessage(error) }; - } -} - -/** - * 벤더의 possible items 개수 조회 - */ -export async function getTechVendorPossibleItemsCount(vendorId: number): Promise<number> { - try { - const result = await db - .select({ count: sql<number>`count(*)`.as("count") }) - .from(techVendorPossibleItems) - .where(eq(techVendorPossibleItems.vendorId, vendorId)); - - return result[0]?.count || 0; - } catch (err) { - console.error("Error getting tech vendor possible items count:", err); - return 0; - } -} - -/** - * 기술영업 벤더 초대 메일 발송 - */ -export async function inviteTechVendor(params: { - vendorId: number; - subject: string; - message: string; - recipientEmail: string; -}) { - unstable_noStore(); - - try { - console.log("기술영업 벤더 초대 메일 발송 시작:", params.vendorId); - - const result = await db.transaction(async (tx) => { - // 벤더 정보 조회 - const vendor = await tx.query.techVendors.findFirst({ - where: eq(techVendors.id, params.vendorId), - }); - - if (!vendor) { - throw new Error("벤더를 찾을 수 없습니다."); - } - - // 벤더 상태를 INVITED로 변경 (PENDING_INVITE에서) - if (vendor.status !== "PENDING_INVITE") { - throw new Error("초대 가능한 상태가 아닙니다. (PENDING_INVITE 상태만 초대 가능)"); - } - - await tx.update(techVendors) - .set({ - status: "INVITED", - updatedAt: new Date(), - }) - .where(eq(techVendors.id, params.vendorId)); - - // 초대 토큰 생성 - const { createTechVendorInvitationToken, createTechVendorSignupUrl } = await import("@/lib/tech-vendor-invitation-token"); - const { sendEmail } = await import("@/lib/mail/sendEmail"); - - const invitationToken = await createTechVendorInvitationToken({ - vendorId: vendor.id, - vendorName: vendor.vendorName, - email: params.recipientEmail, - }); - - const signupUrl = await createTechVendorSignupUrl(invitationToken); - - // 초대 메일 발송 - await sendEmail({ - to: params.recipientEmail, - subject: params.subject, - template: "tech-vendor-invitation", - context: { - companyName: vendor.vendorName, - language: "ko", - registrationLink: signupUrl, - customMessage: params.message, - } - }); - - console.log("초대 메일 발송 완료:", params.recipientEmail); - - return { vendor, invitationToken, signupUrl }; - }); - - // 캐시 무효화 - revalidateTag("tech-vendors"); - - console.log("기술영업 벤더 초대 완료:", result); - return { success: true, data: result }; - } catch (error) { - console.error("기술영업 벤더 초대 실패:", error); - return { success: false, error: getErrorMessage(error) }; - } -} - +"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택)
+
+import { revalidateTag, unstable_noStore } from "next/cache";
+import db from "@/db/db";
+import { techVendorAttachments, techVendorContacts, techVendorPossibleItems, techVendors, techVendorItemsView, type TechVendor, techVendorCandidates } from "@/db/schema/techVendors";
+import { items, itemShipbuilding, itemOffshoreTop, itemOffshoreHull } from "@/db/schema/items";
+import { users } from "@/db/schema/users";
+import ExcelJS from "exceljs";
+import { filterColumns } from "@/lib/filter-columns";
+import { unstable_cache } from "@/lib/unstable-cache";
+import { getErrorMessage } from "@/lib/handle-error";
+
+import {
+ insertTechVendor,
+ updateTechVendor,
+ groupByTechVendorStatus,
+ selectTechVendorContacts,
+ countTechVendorContacts,
+ insertTechVendorContact,
+ selectTechVendorItems,
+ countTechVendorItems,
+ insertTechVendorItem,
+ selectTechVendorsWithAttachments,
+ countTechVendorsWithAttachments,
+ updateTechVendors,
+} from "./repository";
+
+import type {
+ CreateTechVendorSchema,
+ UpdateTechVendorSchema,
+ GetTechVendorsSchema,
+ GetTechVendorContactsSchema,
+ CreateTechVendorContactSchema,
+ GetTechVendorItemsSchema,
+ CreateTechVendorItemSchema,
+ GetTechVendorRfqHistorySchema,
+ GetTechVendorPossibleItemsSchema,
+ CreateTechVendorPossibleItemSchema,
+ UpdateTechVendorPossibleItemSchema,
+ UpdateTechVendorContactSchema,
+} from "./validations";
+
+import { asc, desc, ilike, inArray, and, or, eq, isNull, not } from "drizzle-orm";
+import path from "path";
+import { sql } from "drizzle-orm";
+import { decryptWithServerAction } from "@/components/drm/drmUtils";
+import { deleteFile, saveDRMFile } from "../file-stroage";
+
+/* -----------------------------------------------------
+ 1) 조회 관련
+----------------------------------------------------- */
+
+/**
+ * 복잡한 조건으로 기술영업 Vendor 목록을 조회 (+ pagination) 하고,
+ * 총 개수에 따라 pageCount를 계산해서 리턴.
+ * Next.js의 unstable_cache를 사용해 일정 시간 캐시.
+ */
+export async function getTechVendors(input: GetTechVendorsSchema) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // 1) 고급 필터 (workTypes와 techVendorType 제외 - 별도 처리)
+ const filteredFilters = input.filters.filter(
+ filter => filter.id !== "workTypes" && filter.id !== "techVendorType"
+ );
+
+ const advancedWhere = filterColumns({
+ table: techVendors,
+ filters: filteredFilters,
+ joinOperator: input.joinOperator,
+ });
+
+ // 2) 글로벌 검색
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(techVendors.vendorName, s),
+ ilike(techVendors.vendorCode, s),
+ ilike(techVendors.email, s),
+ ilike(techVendors.status, s)
+ );
+ }
+
+ // 최종 where 결합
+ const finalWhere = and(advancedWhere, globalWhere);
+
+ // 벤더 타입 필터링 로직 추가
+ let vendorTypeWhere;
+ if (input.vendorType) {
+ // URL의 vendorType 파라미터를 실제 벤더 타입으로 매핑
+ const vendorTypeMap = {
+ "ship": "조선",
+ "top": "해양TOP",
+ "hull": "해양HULL"
+ };
+
+ const actualVendorType = input.vendorType in vendorTypeMap
+ ? vendorTypeMap[input.vendorType as keyof typeof vendorTypeMap]
+ : undefined;
+ if (actualVendorType) {
+ // techVendorType 필드는 콤마로 구분된 문자열이므로 LIKE 사용
+ vendorTypeWhere = ilike(techVendors.techVendorType, `%${actualVendorType}%`);
+ }
+ }
+
+ // 간단 검색 (advancedTable=false) 시 예시
+ const simpleWhere = and(
+ input.vendorName
+ ? ilike(techVendors.vendorName, `%${input.vendorName}%`)
+ : undefined,
+ input.status ? ilike(techVendors.status, input.status) : undefined,
+ input.country
+ ? ilike(techVendors.country, `%${input.country}%`)
+ : undefined
+ );
+
+ // TechVendorType 필터링 로직 추가 (고급 필터에서)
+ let techVendorTypeWhere;
+ const techVendorTypeFilters = input.filters.filter(filter => filter.id === "techVendorType");
+ if (techVendorTypeFilters.length > 0) {
+ const typeFilter = techVendorTypeFilters[0];
+ if (Array.isArray(typeFilter.value) && typeFilter.value.length > 0) {
+ // 각 타입에 대해 LIKE 조건으로 OR 연결
+ const typeConditions = typeFilter.value.map(type =>
+ ilike(techVendors.techVendorType, `%${type}%`)
+ );
+ techVendorTypeWhere = or(...typeConditions);
+ }
+ }
+
+ // WorkTypes 필터링 로직 추가
+ let workTypesWhere;
+ const workTypesFilters = input.filters.filter(filter => filter.id === "workTypes");
+ if (workTypesFilters.length > 0) {
+ const workTypeFilter = workTypesFilters[0];
+ if (Array.isArray(workTypeFilter.value) && workTypeFilter.value.length > 0) {
+ // workTypes에 해당하는 벤더 ID들을 서브쿼리로 찾음
+ const vendorIdsWithWorkTypes = db
+ .selectDistinct({ vendorId: techVendorPossibleItems.vendorId })
+ .from(techVendorPossibleItems)
+ .leftJoin(itemShipbuilding, eq(techVendorPossibleItems.itemCode, itemShipbuilding.itemCode))
+ .leftJoin(itemOffshoreTop, eq(techVendorPossibleItems.itemCode, itemOffshoreTop.itemCode))
+ .leftJoin(itemOffshoreHull, eq(techVendorPossibleItems.itemCode, itemOffshoreHull.itemCode))
+ .where(
+ or(
+ inArray(itemShipbuilding.workType, workTypeFilter.value),
+ inArray(itemOffshoreTop.workType, workTypeFilter.value),
+ inArray(itemOffshoreHull.workType, workTypeFilter.value)
+ )
+ );
+
+ workTypesWhere = inArray(techVendors.id, vendorIdsWithWorkTypes);
+ }
+ }
+
+ // 실제 사용될 where (vendorType, techVendorType, workTypes 필터링 추가)
+ const where = and(finalWhere, vendorTypeWhere, techVendorTypeWhere, workTypesWhere);
+
+ // 정렬
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(techVendors[item.id]) : asc(techVendors[item.id])
+ )
+ : [asc(techVendors.createdAt)];
+
+ // 트랜잭션 내에서 데이터 조회
+ const { data, total } = await db.transaction(async (tx) => {
+ // 1) vendor 목록 조회 (with attachments)
+ const vendorsData = await selectTechVendorsWithAttachments(tx, {
+ where,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ // 2) 전체 개수
+ const total = await countTechVendorsWithAttachments(tx, where);
+ return { data: vendorsData, total };
+ });
+
+ // 페이지 수
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount };
+ } catch (err) {
+ console.error("Error fetching tech vendors:", err);
+ // 에러 발생 시
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input)], // 캐싱 키
+ {
+ revalidate: 3600,
+ tags: ["tech-vendors"], // revalidateTag("tech-vendors") 호출 시 무효화
+ }
+ )();
+}
+
+/**
+ * 기술영업 벤더 상태별 카운트 조회
+ */
+export async function getTechVendorStatusCounts() {
+ return unstable_cache(
+ async () => {
+ try {
+ const initial: Record<TechVendor["status"], number> = {
+ "PENDING_INVITE": 0,
+ "INVITED": 0,
+ "QUOTE_COMPARISON": 0,
+ "ACTIVE": 0,
+ "INACTIVE": 0,
+ "BLACKLISTED": 0,
+ };
+
+ const result = await db.transaction(async (tx) => {
+ const rows = await groupByTechVendorStatus(tx);
+ type StatusCountRow = { status: TechVendor["status"]; count: number };
+ return (rows as StatusCountRow[]).reduce<Record<TechVendor["status"], number>>((acc, { status, count }) => {
+ acc[status] = count;
+ return acc;
+ }, initial);
+ });
+
+ return result;
+ } catch (err) {
+ return {} as Record<TechVendor["status"], number>;
+ }
+ },
+ ["tech-vendor-status-counts"], // 캐싱 키
+ {
+ revalidate: 3600,
+ }
+ )();
+}
+
+/**
+ * 벤더 상세 정보 조회
+ */
+export async function getTechVendorById(id: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ const result = await getTechVendorDetailById(id);
+ return { data: result };
+ } catch (err) {
+ console.error("기술영업 벤더 상세 조회 오류:", err);
+ return { data: null };
+ }
+ },
+ [`tech-vendor-${id}`],
+ {
+ revalidate: 3600,
+ tags: ["tech-vendors", `tech-vendor-${id}`],
+ }
+ )();
+}
+
+/* -----------------------------------------------------
+ 2) 생성(Create)
+----------------------------------------------------- */
+
+/**
+ * 첨부파일 저장 헬퍼 함수
+ */
+async function storeTechVendorFiles(
+ tx: any,
+ vendorId: number,
+ files: File[],
+ attachmentType: string
+) {
+
+ for (const file of files) {
+
+ const saveResult = await saveDRMFile(file, decryptWithServerAction, `tech-vendors/${vendorId}`)
+
+ // Insert attachment record
+ await tx.insert(techVendorAttachments).values({
+ vendorId,
+ fileName: file.name,
+ filePath: saveResult.publicPath,
+ attachmentType,
+ });
+ }
+}
+
+/**
+ * 신규 기술영업 벤더 생성
+ */
+export async function createTechVendor(input: CreateTechVendorSchema) {
+ unstable_noStore();
+
+ try {
+ // 이메일 중복 검사
+ const existingVendorByEmail = await db
+ .select({ id: techVendors.id, vendorName: techVendors.vendorName })
+ .from(techVendors)
+ .where(eq(techVendors.email, input.email))
+ .limit(1);
+
+ // 이미 동일한 이메일을 가진 업체가 존재하면 에러 반환
+ if (existingVendorByEmail.length > 0) {
+ return {
+ success: false,
+ data: null,
+ error: `이미 등록된 이메일입니다. (업체명: ${existingVendorByEmail[0].vendorName})`
+ };
+ }
+
+ // taxId 중복 검사
+ const existingVendorByTaxId = await db
+ .select({ id: techVendors.id })
+ .from(techVendors)
+ .where(eq(techVendors.taxId, input.taxId))
+ .limit(1);
+
+ // 이미 동일한 taxId를 가진 업체가 존재하면 에러 반환
+ if (existingVendorByTaxId.length > 0) {
+ return {
+ success: false,
+ data: null,
+ error: `이미 등록된 사업자등록번호입니다. (Tax ID ${input.taxId} already exists in the system)`
+ };
+ }
+
+ const result = await db.transaction(async (tx) => {
+ // 1. 벤더 생성
+ const [newVendor] = await insertTechVendor(tx, {
+ vendorName: input.vendorName,
+ vendorCode: input.vendorCode || null,
+ 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,
+ techVendorType: Array.isArray(input.techVendorType) ? input.techVendorType.join(',') : input.techVendorType,
+ representativeName: input.representativeName || null,
+ representativeBirth: input.representativeBirth || null,
+ representativeEmail: input.representativeEmail || null,
+ representativePhone: input.representativePhone || null,
+ items: input.items || null,
+ status: "ACTIVE",
+ isQuoteComparison: false,
+ });
+
+ // 2. 연락처 정보 등록
+ for (const contact of input.contacts) {
+ await insertTechVendorContact(tx, {
+ vendorId: newVendor.id,
+ contactName: contact.contactName,
+ contactPosition: contact.contactPosition || null,
+ contactEmail: contact.contactEmail,
+ contactPhone: contact.contactPhone || null,
+ isPrimary: contact.isPrimary ?? false,
+ contactCountry: contact.contactCountry || null,
+ });
+ }
+
+ // 3. 첨부파일 저장
+ if (input.files && input.files.length > 0) {
+ await storeTechVendorFiles(tx, newVendor.id, input.files, "GENERAL");
+ }
+
+ return newVendor;
+ });
+
+ revalidateTag("tech-vendors");
+
+ return {
+ success: true,
+ data: result,
+ error: null
+ };
+ } catch (err) {
+ console.error("기술영업 벤더 생성 오류:", err);
+
+ return {
+ success: false,
+ data: null,
+ error: getErrorMessage(err)
+ };
+ }
+}
+
+/* -----------------------------------------------------
+ 3) 업데이트 (단건/복수)
+----------------------------------------------------- */
+
+/** 단건 업데이트 */
+export async function modifyTechVendor(
+ input: UpdateTechVendorSchema & { id: string; }
+) {
+ unstable_noStore();
+ try {
+ const updated = await db.transaction(async (tx) => {
+ // 벤더 정보 업데이트
+ const [res] = await updateTechVendor(tx, input.id, {
+ vendorName: input.vendorName,
+ vendorCode: input.vendorCode,
+ address: input.address,
+ country: input.country,
+ countryEng: input.countryEng,
+ countryFab: input.countryFab,
+ phone: input.phone,
+ email: input.email,
+ website: input.website,
+ status: input.status,
+ // 에이전트 정보 추가
+ agentName: input.agentName,
+ agentEmail: input.agentEmail,
+ agentPhone: input.agentPhone,
+ // 대표자 정보 추가
+ representativeName: input.representativeName,
+ representativeEmail: input.representativeEmail,
+ representativePhone: input.representativePhone,
+ representativeBirth: input.representativeBirth,
+ // techVendorType 처리
+ techVendorType: Array.isArray(input.techVendorType) ? input.techVendorType.join(',') : input.techVendorType,
+ });
+
+ return res;
+ });
+
+ // 캐시 무효화
+ revalidateTag("tech-vendors");
+ revalidateTag(`tech-vendor-${input.id}`);
+
+ return { data: updated, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/** 복수 업데이트 */
+export async function modifyTechVendors(input: {
+ ids: string[];
+ status?: TechVendor["status"];
+}) {
+ unstable_noStore();
+ try {
+ const data = await db.transaction(async (tx) => {
+ // 여러 협력업체 일괄 업데이트
+ const [updated] = await updateTechVendors(tx, input.ids, {
+ // 예: 상태만 일괄 변경
+ status: input.status,
+ });
+ return updated;
+ });
+
+ // 캐시 무효화
+ revalidateTag("tech-vendors");
+ revalidateTag("tech-vendor-status-counts");
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/* -----------------------------------------------------
+ 4) 연락처 관리
+----------------------------------------------------- */
+
+export async function getTechVendorContacts(input: GetTechVendorContactsSchema, id: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // 필터링 설정
+ const advancedWhere = filterColumns({
+ table: techVendorContacts,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ // 검색 조건
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(techVendorContacts.contactName, s),
+ ilike(techVendorContacts.contactPosition, s),
+ ilike(techVendorContacts.contactEmail, s),
+ ilike(techVendorContacts.contactPhone, s)
+ );
+ }
+
+ // 해당 벤더 조건
+ const vendorWhere = eq(techVendorContacts.vendorId, id);
+
+ // 최종 조건 결합
+ const finalWhere = and(advancedWhere, globalWhere, vendorWhere);
+
+ // 정렬 조건
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(techVendorContacts[item.id]) : asc(techVendorContacts[item.id])
+ )
+ : [asc(techVendorContacts.createdAt)];
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectTechVendorContacts(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+ const total = await countTechVendorContacts(tx, finalWhere);
+ return { data, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount };
+ } catch (err) {
+ // 에러 발생 시 디폴트
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input), String(id)], // 캐싱 키
+ {
+ revalidate: 3600,
+ tags: [`tech-vendor-contacts-${id}`],
+ }
+ )();
+}
+
+export async function createTechVendorContact(input: CreateTechVendorContactSchema) {
+ unstable_noStore();
+ try {
+ await db.transaction(async (tx) => {
+ // DB Insert
+ const [newContact] = await insertTechVendorContact(tx, {
+ vendorId: input.vendorId,
+ contactName: input.contactName,
+ contactPosition: input.contactPosition || "",
+ contactEmail: input.contactEmail,
+ contactPhone: input.contactPhone || "",
+ contactCountry: input.contactCountry || "",
+ isPrimary: input.isPrimary || false,
+ });
+
+ return newContact;
+ });
+
+ // 캐시 무효화
+ revalidateTag(`tech-vendor-contacts-${input.vendorId}`);
+ revalidateTag("users");
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+export async function updateTechVendorContact(input: UpdateTechVendorContactSchema & { id: number; vendorId: number }) {
+ unstable_noStore();
+ try {
+ const [updatedContact] = await db
+ .update(techVendorContacts)
+ .set({
+ contactName: input.contactName,
+ contactPosition: input.contactPosition || null,
+ contactEmail: input.contactEmail,
+ contactPhone: input.contactPhone || null,
+ contactCountry: input.contactCountry || null,
+ isPrimary: input.isPrimary || false,
+ updatedAt: new Date(),
+ })
+ .where(eq(techVendorContacts.id, input.id))
+ .returning();
+
+ // 캐시 무효화
+ revalidateTag(`tech-vendor-contacts-${input.vendorId}`);
+ revalidateTag("users");
+
+ return { data: updatedContact, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+export async function deleteTechVendorContact(contactId: number, vendorId: number) {
+ unstable_noStore();
+ try {
+ const [deletedContact] = await db
+ .delete(techVendorContacts)
+ .where(eq(techVendorContacts.id, contactId))
+ .returning();
+
+ // 캐시 무효화
+ revalidateTag(`tech-vendor-contacts-${contactId}`);
+ revalidateTag(`tech-vendor-contacts-${vendorId}`);
+
+ return { data: deletedContact, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/* -----------------------------------------------------
+ 5) 아이템 관리
+----------------------------------------------------- */
+
+export async function getTechVendorItems(input: GetTechVendorItemsSchema, id: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // 필터링 설정
+ const advancedWhere = filterColumns({
+ table: techVendorItemsView,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ // 검색 조건
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(techVendorItemsView.itemCode, s)
+ );
+ }
+
+ // 해당 벤더 조건
+ const vendorWhere = eq(techVendorItemsView.vendorId, id);
+
+ // 최종 조건 결합
+ const finalWhere = and(advancedWhere, globalWhere, vendorWhere);
+
+ // 정렬 조건
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(techVendorItemsView[item.id]) : asc(techVendorItemsView[item.id])
+ )
+ : [asc(techVendorItemsView.createdAt)];
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectTechVendorItems(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+ const total = await countTechVendorItems(tx, finalWhere);
+ return { data, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount };
+ } catch (err) {
+ // 에러 발생 시 디폴트
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input), String(id)], // 캐싱 키
+ {
+ revalidate: 3600,
+ tags: [`tech-vendor-items-${id}`],
+ }
+ )();
+}
+
+export interface ItemDropdownOption {
+ itemCode: string;
+ itemList: string;
+ workType: string | null;
+ shipTypes: string | null;
+ subItemList: string | null;
+}
+
+/**
+ * Vendor Item 추가 시 사용할 아이템 목록 조회 (전체 목록 반환)
+ * 아이템 코드, 이름, 설명만 간소화해서 반환
+ */
+export async function getItemsForTechVendor(vendorId: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ // 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: techVendorPossibleItems.itemCode,
+ })
+ .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: availableItems,
+ error: null
+ };
+ } catch (err) {
+ console.error("Failed to fetch items for tech vendor dropdown:", err);
+ return {
+ data: [],
+ error: "아이템 목록을 불러오는데 실패했습니다.",
+ };
+ }
+ },
+ // 캐시 키를 vendorId 별로 달리 해야 한다.
+ ["items-for-tech-vendor", String(vendorId)],
+ {
+ revalidate: 3600, // 1시간 캐싱
+ tags: ["items"], // revalidateTag("items") 호출 시 무효화
+ }
+ )();
+}
+
+/**
+ * 벤더 타입과 아이템 코드에 따른 아이템 조회
+ */
+export async function getItemsByVendorType(vendorType: string, itemCode: string) {
+ try {
+ let items: (typeof itemShipbuilding.$inferSelect | typeof itemOffshoreTop.$inferSelect | typeof itemOffshoreHull.$inferSelect)[] = [];
+
+ switch (vendorType) {
+ case "조선":
+ const shipbuildingResults = await db
+ .select({
+ id: itemShipbuilding.id,
+ itemCode: itemShipbuilding.itemCode,
+ workType: itemShipbuilding.workType,
+ shipTypes: itemShipbuilding.shipTypes,
+ itemList: itemShipbuilding.itemList,
+ createdAt: itemShipbuilding.createdAt,
+ updatedAt: itemShipbuilding.updatedAt,
+ })
+ .from(itemShipbuilding)
+ .where(itemCode ? eq(itemShipbuilding.itemCode, itemCode) : undefined);
+ items = shipbuildingResults;
+ break;
+
+ case "해양TOP":
+ const offshoreTopResults = await db
+ .select({
+ id: itemOffshoreTop.id,
+ itemCode: itemOffshoreTop.itemCode,
+ workType: itemOffshoreTop.workType,
+ itemList: itemOffshoreTop.itemList,
+ subItemList: itemOffshoreTop.subItemList,
+ createdAt: itemOffshoreTop.createdAt,
+ updatedAt: itemOffshoreTop.updatedAt,
+ })
+ .from(itemOffshoreTop)
+ .where(itemCode ? eq(itemOffshoreTop.itemCode, itemCode) : undefined);
+ items = offshoreTopResults;
+ break;
+
+ case "해양HULL":
+ const offshoreHullResults = await db
+ .select({
+ id: itemOffshoreHull.id,
+ itemCode: itemOffshoreHull.itemCode,
+ workType: itemOffshoreHull.workType,
+ itemList: itemOffshoreHull.itemList,
+ subItemList: itemOffshoreHull.subItemList,
+ createdAt: itemOffshoreHull.createdAt,
+ updatedAt: itemOffshoreHull.updatedAt,
+ })
+ .from(itemOffshoreHull)
+ .where(itemCode ? eq(itemOffshoreHull.itemCode, itemCode) : undefined);
+ items = offshoreHullResults;
+ break;
+
+ default:
+ items = [];
+ }
+
+ const result = items.map(item => ({
+ ...item,
+ techVendorType: vendorType
+ }));
+
+ return { data: result, error: null };
+ } catch (err) {
+ console.error("Error fetching items by vendor type:", err);
+ return { data: [], error: "Failed to fetch items" };
+ }
+}
+
+/**
+ * 벤더의 possible_items를 조회하고 해당 아이템 코드로 각 타입별 테이블을 조회
+ * 벤더 타입이 콤마로 구분된 경우 (예: "조선,해양TOP,해양HULL") 모든 타입의 아이템을 조회
+ */
+export async function getVendorItemsByType(vendorId: number, vendorType: string) {
+ try {
+ // 벤더의 possible_items 조회
+ const possibleItems = await db.query.techVendorPossibleItems.findMany({
+ where: eq(techVendorPossibleItems.vendorId, vendorId),
+ columns: {
+ itemCode: true
+ }
+ })
+
+ const itemCodes = possibleItems.map(item => item.itemCode)
+
+ if (itemCodes.length === 0) {
+ return { data: [] }
+ }
+
+ // 벤더 타입을 콤마로 분리
+ const vendorTypes = vendorType.split(',').map(type => type.trim())
+ const allItems: Array<Record<string, any> & { techVendorType: "조선" | "해양TOP" | "해양HULL" }> = []
+
+ // 각 벤더 타입에 따라 해당하는 테이블에서 아이템 조회
+ for (const singleType of vendorTypes) {
+ switch (singleType) {
+ case "조선":
+ const shipbuildingItems = await db.query.itemShipbuilding.findMany({
+ where: inArray(itemShipbuilding.itemCode, itemCodes)
+ })
+ allItems.push(...shipbuildingItems.map(item => ({
+ ...item,
+ techVendorType: "조선" as const
+ })))
+ break
+
+ case "해양TOP":
+ const offshoreTopItems = await db.query.itemOffshoreTop.findMany({
+ where: inArray(itemOffshoreTop.itemCode, itemCodes)
+ })
+ allItems.push(...offshoreTopItems.map(item => ({
+ ...item,
+ techVendorType: "해양TOP" as const
+ })))
+ break
+
+ case "해양HULL":
+ const offshoreHullItems = await db.query.itemOffshoreHull.findMany({
+ where: inArray(itemOffshoreHull.itemCode, itemCodes)
+ })
+ allItems.push(...offshoreHullItems.map(item => ({
+ ...item,
+ techVendorType: "해양HULL" as const
+ })))
+ break
+
+ default:
+ console.warn(`Unknown vendor type: ${singleType}`)
+ break
+ }
+ }
+
+ // 중복 허용 - 모든 아이템을 그대로 반환
+ return {
+ data: allItems.sort((a, b) => a.itemCode.localeCompare(b.itemCode))
+ }
+ } catch (err) {
+ console.error("Error getting vendor items by type:", err)
+ return { data: [] }
+ }
+}
+
+export async function createTechVendorItem(input: CreateTechVendorItemSchema) {
+ unstable_noStore();
+ try {
+ // DB에 이미 존재하는지 확인
+ const existingItem = await db
+ .select({ id: techVendorPossibleItems.id })
+ .from(techVendorPossibleItems)
+ .where(
+ and(
+ eq(techVendorPossibleItems.vendorId, input.vendorId),
+ eq(techVendorPossibleItems.itemCode, input.itemCode)
+ )
+ )
+ .limit(1);
+
+ if (existingItem.length > 0) {
+ return { data: null, error: "이미 추가된 아이템입니다." };
+ }
+
+ await db.transaction(async (tx) => {
+ // DB Insert
+ const [newItem] = await tx
+ .insert(techVendorPossibleItems)
+ .values({
+ vendorId: input.vendorId,
+ itemCode: input.itemCode,
+ })
+ .returning();
+ return newItem;
+ });
+
+ // 캐시 무효화
+ revalidateTag(`tech-vendor-items-${input.vendorId}`);
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/* -----------------------------------------------------
+ 6) 기술영업 벤더 승인/거부
+----------------------------------------------------- */
+
+interface ApproveTechVendorsInput {
+ ids: string[];
+}
+
+/**
+ * 기술영업 벤더 승인 (상태를 ACTIVE로 변경)
+ */
+export async function approveTechVendors(input: ApproveTechVendorsInput) {
+ unstable_noStore();
+
+ try {
+ // 트랜잭션 내에서 협력업체 상태 업데이트
+ const result = await db.transaction(async (tx) => {
+ // 협력업체 상태 업데이트
+ const [updated] = await tx
+ .update(techVendors)
+ .set({
+ status: "ACTIVE",
+ updatedAt: new Date()
+ })
+ .where(inArray(techVendors.id, input.ids.map(id => parseInt(id))))
+ .returning();
+
+ return updated;
+ });
+
+ // 캐시 무효화
+ revalidateTag("tech-vendors");
+ revalidateTag("tech-vendor-status-counts");
+
+ return { data: result, error: null };
+ } catch (err) {
+ console.error("Error approving tech vendors:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 벤더 거부 (상태를 REJECTED로 변경)
+ */
+export async function rejectTechVendors(input: ApproveTechVendorsInput) {
+ unstable_noStore();
+
+ try {
+ // 트랜잭션 내에서 협력업체 상태 업데이트
+ const result = await db.transaction(async (tx) => {
+ // 협력업체 상태 업데이트
+ const [updated] = await tx
+ .update(techVendors)
+ .set({
+ status: "INACTIVE",
+ updatedAt: new Date()
+ })
+ .where(inArray(techVendors.id, input.ids.map(id => parseInt(id))))
+ .returning();
+
+ return updated;
+ });
+
+ // 캐시 무효화
+ revalidateTag("tech-vendors");
+ revalidateTag("tech-vendor-status-counts");
+
+ return { data: result, error: null };
+ } catch (err) {
+ console.error("Error rejecting tech vendors:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/* -----------------------------------------------------
+ 7) 엑셀 내보내기
+----------------------------------------------------- */
+
+/**
+ * 벤더 연락처 목록 엑셀 내보내기
+ */
+export async function exportTechVendorContacts(vendorId: number) {
+ try {
+ const contacts = await db
+ .select()
+ .from(techVendorContacts)
+ .where(eq(techVendorContacts.vendorId, vendorId))
+ .orderBy(techVendorContacts.isPrimary, techVendorContacts.contactName);
+
+ return contacts;
+ } catch (err) {
+ console.error("기술영업 벤더 연락처 내보내기 오류:", err);
+ return [];
+ }
+}
+
+/**
+ * 벤더 아이템 목록 엑셀 내보내기
+ */
+export async function exportTechVendorItems(vendorId: number) {
+ try {
+ const items = await db
+ .select({
+ id: techVendorItemsView.vendorItemId,
+ vendorId: techVendorItemsView.vendorId,
+ itemCode: techVendorItemsView.itemCode,
+ createdAt: techVendorItemsView.createdAt,
+ updatedAt: techVendorItemsView.updatedAt,
+ })
+ .from(techVendorItemsView)
+ .where(eq(techVendorItemsView.vendorId, vendorId))
+
+ return items;
+ } catch (err) {
+ console.error("기술영업 벤더 아이템 내보내기 오류:", err);
+ return [];
+ }
+}
+
+/**
+ * 벤더 정보 엑셀 내보내기
+ */
+export async function exportTechVendorDetails(vendorIds: number[]) {
+ try {
+ if (!vendorIds.length) return [];
+
+ // 벤더 기본 정보 조회
+ const vendorsData = await db
+ .select({
+ id: techVendors.id,
+ vendorName: techVendors.vendorName,
+ vendorCode: techVendors.vendorCode,
+ taxId: techVendors.taxId,
+ address: techVendors.address,
+ country: techVendors.country,
+ phone: techVendors.phone,
+ email: techVendors.email,
+ website: techVendors.website,
+ status: techVendors.status,
+ representativeName: techVendors.representativeName,
+ representativeEmail: techVendors.representativeEmail,
+ representativePhone: techVendors.representativePhone,
+ representativeBirth: techVendors.representativeBirth,
+ items: techVendors.items,
+ createdAt: techVendors.createdAt,
+ updatedAt: techVendors.updatedAt,
+ })
+ .from(techVendors)
+ .where(
+ vendorIds.length === 1
+ ? eq(techVendors.id, vendorIds[0])
+ : inArray(techVendors.id, vendorIds)
+ );
+
+ // 벤더별 상세 정보를 포함하여 반환
+ const vendorsWithDetails = await Promise.all(
+ vendorsData.map(async (vendor) => {
+ // 연락처 조회
+ const contacts = await exportTechVendorContacts(vendor.id);
+
+ // 아이템 조회
+ const items = await exportTechVendorItems(vendor.id);
+
+ return {
+ ...vendor,
+ vendorContacts: contacts,
+ vendorItems: items,
+ };
+ })
+ );
+
+ return vendorsWithDetails;
+ } catch (err) {
+ console.error("기술영업 벤더 상세 내보내기 오류:", err);
+ return [];
+ }
+}
+
+/**
+ * 기술영업 벤더 상세 정보 조회 (연락처, 첨부파일 포함)
+ */
+export async function getTechVendorDetailById(id: number) {
+ try {
+ const vendor = await db.select().from(techVendors).where(eq(techVendors.id, id)).limit(1);
+
+ if (!vendor || vendor.length === 0) {
+ console.error(`Vendor not found with id: ${id}`);
+ return null;
+ }
+
+ const contacts = await db.select().from(techVendorContacts).where(eq(techVendorContacts.vendorId, id));
+ const attachments = await db.select().from(techVendorAttachments).where(eq(techVendorAttachments.vendorId, id));
+ const possibleItems = await db.select().from(techVendorPossibleItems).where(eq(techVendorPossibleItems.vendorId, id));
+
+ return {
+ ...vendor[0],
+ contacts,
+ attachments,
+ possibleItems
+ };
+ } catch (error) {
+ console.error("Error fetching tech vendor detail:", error);
+ return null;
+ }
+}
+
+/**
+ * 기술영업 벤더 첨부파일 다운로드를 위한 서버 액션
+ * @param vendorId 기술영업 벤더 ID
+ * @param fileId 특정 파일 ID (단일 파일 다운로드시)
+ * @returns 다운로드할 수 있는 임시 URL
+ */
+export async function downloadTechVendorAttachments(vendorId:number, fileId?:number) {
+ try {
+ // API 경로 생성 (단일 파일 또는 모든 파일)
+ const url = fileId
+ ? `/api/tech-vendors/attachments/download?id=${fileId}&vendorId=${vendorId}`
+ : `/api/tech-vendors/attachments/download-all?vendorId=${vendorId}`;
+
+ // fetch 요청 (기본적으로 Blob으로 응답 받기)
+ const response = await fetch(url, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Server responded with ${response.status}: ${response.statusText}`);
+ }
+
+ // 파일명 가져오기 (Content-Disposition 헤더에서)
+ const contentDisposition = response.headers.get('content-disposition');
+ let fileName = fileId ? `file-${fileId}.zip` : `tech-vendor-${vendorId}-files.zip`;
+
+ if (contentDisposition) {
+ const matches = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(contentDisposition);
+ if (matches && matches[1]) {
+ fileName = matches[1].replace(/['"]/g, '');
+ }
+ }
+
+ // Blob으로 응답 변환
+ const blob = await response.blob();
+
+ // Blob URL 생성
+ const blobUrl = window.URL.createObjectURL(blob);
+
+ return {
+ url: blobUrl,
+ fileName,
+ blob
+ };
+ } catch (error) {
+ console.error('Download API error:', error);
+ throw error;
+ }
+}
+
+/**
+ * 임시 ZIP 파일 정리를 위한 서버 액션
+ * @param fileName 정리할 파일명
+ */
+export async function cleanupTechTempFiles(fileName: string) {
+ 'use server';
+
+ try {
+
+ await deleteFile(`tmp/${fileName}`)
+
+ return { success: true };
+ } catch (error) {
+ console.error('임시 파일 정리 오류:', error);
+ return { success: false, error: '임시 파일 정리 중 오류가 발생했습니다.' };
+ }
+}
+
+export const findVendorById = async (id: number): Promise<TechVendor | null> => {
+ try {
+ // 직접 DB에서 조회
+ const vendor = await db
+ .select()
+ .from(techVendors)
+ .where(eq(techVendors.id, id))
+ .limit(1)
+ .then(rows => rows[0] || null);
+
+ if (!vendor) {
+ console.error(`Vendor not found with id: ${id}`);
+ return null;
+ }
+
+ return vendor;
+ } catch (error) {
+ console.error('Error fetching vendor:', error);
+ return null;
+ }
+};
+
+/* -----------------------------------------------------
+ 8) 기술영업 벤더 RFQ 히스토리 조회
+----------------------------------------------------- */
+
+/**
+ * 기술영업 벤더의 RFQ 히스토리 조회 (간단한 버전)
+ */
+export async function getTechVendorRfqHistory(input: GetTechVendorRfqHistorySchema, id:number) {
+ try {
+
+ // 먼저 해당 벤더의 견적서가 있는지 확인
+ const { techSalesVendorQuotations } = await import("@/db/schema/techSales");
+
+ const quotationCheck = await db
+ .select({ count: sql<number>`count(*)`.as("count") })
+ .from(techSalesVendorQuotations)
+ .where(eq(techSalesVendorQuotations.vendorId, id));
+
+ console.log(`벤더 ${id}의 견적서 개수:`, quotationCheck[0]?.count);
+
+ if (quotationCheck[0]?.count === 0) {
+ console.log("해당 벤더의 견적서가 없습니다.");
+ return { data: [], pageCount: 0 };
+ }
+
+ const offset = (input.page - 1) * input.perPage;
+ const { techSalesRfqs } = await import("@/db/schema/techSales");
+ const { biddingProjects } = await import("@/db/schema/projects");
+
+ // 간단한 조회
+ let whereCondition = eq(techSalesVendorQuotations.vendorId, id);
+
+ // 검색이 있다면 추가
+ if (input.search) {
+ const s = `%${input.search}%`;
+ const searchCondition = and(
+ whereCondition,
+ or(
+ ilike(techSalesRfqs.rfqCode, s),
+ ilike(techSalesRfqs.description, s),
+ ilike(biddingProjects.pspid, s),
+ ilike(biddingProjects.projNm, s)
+ )
+ );
+ whereCondition = searchCondition || whereCondition;
+ }
+
+ // 데이터 조회 - 테이블에 필요한 필드들 (프로젝트 타입 추가)
+ const data = await db
+ .select({
+ id: techSalesRfqs.id,
+ rfqCode: techSalesRfqs.rfqCode,
+ description: techSalesRfqs.description,
+ projectCode: biddingProjects.pspid,
+ projectName: biddingProjects.projNm,
+ projectType: biddingProjects.pjtType, // 프로젝트 타입 추가
+ status: techSalesRfqs.status,
+ totalAmount: techSalesVendorQuotations.totalPrice,
+ currency: techSalesVendorQuotations.currency,
+ dueDate: techSalesRfqs.dueDate,
+ createdAt: techSalesRfqs.createdAt,
+ quotationCode: techSalesVendorQuotations.quotationCode,
+ submittedAt: techSalesVendorQuotations.submittedAt,
+ })
+ .from(techSalesVendorQuotations)
+ .innerJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
+ .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id))
+ .where(whereCondition)
+ .orderBy(desc(techSalesRfqs.createdAt))
+ .limit(input.perPage)
+ .offset(offset);
+
+ console.log("조회된 데이터:", data.length, "개");
+
+ // 전체 개수 조회
+ const totalResult = await db
+ .select({ count: sql<number>`count(*)`.as("count") })
+ .from(techSalesVendorQuotations)
+ .innerJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
+ .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id))
+ .where(whereCondition);
+
+ const total = totalResult[0]?.count || 0;
+ const pageCount = Math.ceil(total / input.perPage);
+
+ console.log("기술영업 벤더 RFQ 히스토리 조회 완료", {
+ id,
+ dataLength: data.length,
+ total,
+ pageCount
+ });
+
+ return { data, pageCount };
+ } catch (err) {
+ console.error("기술영업 벤더 RFQ 히스토리 조회 오류:", {
+ err,
+ id,
+ stack: err instanceof Error ? err.stack : undefined
+ });
+ return { data: [], pageCount: 0 };
+ }
+}
+
+/**
+ * 기술영업 벤더 엑셀 import 시 유저 생성 및 담당자 등록
+ */
+export async function importTechVendorsFromExcel(
+ vendors: Array<{
+ 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: string;
+ representativeName?: string | null;
+ representativeEmail?: string | null;
+ representativePhone?: string | null;
+ representativeBirth?: string | null;
+ items: string;
+ contacts?: Array<{
+ contactName: string;
+ contactPosition?: string;
+ contactEmail: string;
+ contactPhone?: string;
+ contactCountry?: string | null;
+ isPrimary?: boolean;
+ }>;
+ }>,
+) {
+ unstable_noStore();
+
+ try {
+ console.log("Import 시작 - 벤더 수:", vendors.length);
+ console.log("첫 번째 벤더 데이터:", vendors[0]);
+
+ const result = await db.transaction(async (tx) => {
+ const createdVendors = [];
+ const skippedVendors = [];
+ const errors = [];
+
+ for (const vendor of vendors) {
+ console.log("벤더 처리 시작:", vendor.vendorName);
+
+ try {
+ // 0. 이메일 타입 검사
+ // - 문자열이 아니거나, '@' 미포함, 혹은 객체(예: 하이퍼링크 등)인 경우 모두 거절
+ const isEmailString = typeof vendor.email === "string";
+ const isEmailContainsAt = isEmailString && vendor.email.includes("@");
+ // 하이퍼링크 등 객체로 넘어온 경우 (예: { href: "...", ... } 등) 방지
+ const isEmailPlainString = isEmailString && Object.prototype.toString.call(vendor.email) === "[object String]";
+
+ if (!isEmailPlainString || !isEmailContainsAt) {
+ console.log("이메일 형식이 올바르지 않습니다:", vendor.email);
+ errors.push({
+ vendorName: vendor.vendorName,
+ email: vendor.email,
+ error: "이메일 형식이 올바르지 않습니다"
+ });
+ continue;
+ }
+ // 1. 이메일로 기존 벤더 중복 체크
+ const existingVendor = await tx.query.techVendors.findFirst({
+ where: eq(techVendors.email, vendor.email),
+ columns: { id: true, vendorName: true, email: true }
+ });
+
+ if (existingVendor) {
+ console.log("이미 존재하는 벤더 스킵:", vendor.vendorName, vendor.email);
+ skippedVendors.push({
+ vendorName: vendor.vendorName,
+ email: vendor.email,
+ reason: `이미 등록된 이메일입니다 (기존 업체: ${existingVendor.vendorName})`
+ });
+ continue;
+ }
+
+ // 2. 벤더 생성
+ console.log("벤더 생성 시도:", {
+ vendorName: vendor.vendorName,
+ email: vendor.email,
+ techVendorType: vendor.techVendorType
+ });
+
+ 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,
+ status: "ACTIVE",
+ representativeName: vendor.representativeName || null,
+ representativeEmail: vendor.representativeEmail || null,
+ representativePhone: vendor.representativePhone || null,
+ representativeBirth: vendor.representativeBirth || null,
+ }).returning();
+
+ console.log("벤더 생성 성공:", newVendor.id);
+
+ // 2. 담당자 생성 (최소 1명 이상 등록)
+ if (vendor.contacts && vendor.contacts.length > 0) {
+ console.log("담당자 생성 시도:", vendor.contacts.length, "명");
+
+ for (const contact of vendor.contacts) {
+ await tx.insert(techVendorContacts).values({
+ vendorId: newVendor.id,
+ contactName: contact.contactName,
+ contactPosition: contact.contactPosition || null,
+ contactEmail: contact.contactEmail,
+ contactPhone: contact.contactPhone || null,
+ contactCountry: contact.contactCountry || null,
+ isPrimary: contact.isPrimary || false,
+ });
+ console.log("담당자 생성 성공:", contact.contactName, contact.contactEmail);
+ }
+
+ // // 벤더 이메일을 주 담당자의 이메일로 업데이트
+ // const primaryContact = vendor.contacts.find(c => c.isPrimary) || vendor.contacts[0];
+ // if (primaryContact && primaryContact.contactEmail !== vendor.email) {
+ // await tx.update(techVendors)
+ // .set({ email: primaryContact.contactEmail })
+ // .where(eq(techVendors.id, newVendor.id));
+ // console.log("벤더 이메일 업데이트:", primaryContact.contactEmail);
+ // }
+ }
+ // else {
+ // // 담당자 정보가 없는 경우 벤더 정보로 기본 담당자 생성
+ // console.log("기본 담당자 생성");
+ // await tx.insert(techVendorContacts).values({
+ // vendorId: newVendor.id,
+ // contactName: vendor.representativeName || vendor.vendorName || "기본 담당자",
+ // contactPosition: null,
+ // contactEmail: vendor.email,
+ // contactPhone: vendor.representativePhone || vendor.phone || null,
+ // contactCountry: vendor.country || null,
+ // isPrimary: true,
+ // });
+ // console.log("기본 담당자 생성 성공:", vendor.email);
+ // }
+
+ // 3. 유저 생성 (이메일이 있는 경우)
+ if (vendor.email) {
+ console.log("유저 생성 시도:", vendor.email);
+
+ // 이미 존재하는 유저인지 확인
+ const existingUser = await tx.query.users.findFirst({
+ where: eq(users.email, vendor.email),
+ columns: { id: true }
+ });
+
+ if (!existingUser) {
+ // 유저가 존재하지 않는 경우 생성
+ await tx.insert(users).values({
+ name: vendor.vendorName,
+ email: vendor.email,
+ techCompanyId: newVendor.id,
+ domain: "partners",
+ });
+ console.log("유저 생성 성공");
+ } else {
+ // 이미 존재하는 유저라면 techCompanyId 업데이트
+ await tx.update(users)
+ .set({ techCompanyId: newVendor.id })
+ .where(eq(users.id, existingUser.id));
+ console.log("이미 존재하는 유저, techCompanyId 업데이트:", existingUser.id);
+ }
+ }
+
+ createdVendors.push(newVendor);
+ console.log("벤더 처리 완료:", vendor.vendorName);
+ } catch (error) {
+ console.error("벤더 처리 중 오류 발생:", vendor.vendorName, error);
+ errors.push({
+ vendorName: vendor.vendorName,
+ email: vendor.email,
+ error: error instanceof Error ? error.message : "알 수 없는 오류"
+ });
+ // 개별 벤더 오류는 전체 트랜잭션을 롤백하지 않도록 continue
+ continue;
+ }
+ }
+
+ console.log("모든 벤더 처리 완료:", {
+ 생성됨: createdVendors.length,
+ 스킵됨: skippedVendors.length,
+ 오류: errors.length
+ });
+
+ return {
+ createdVendors,
+ skippedVendors,
+ errors,
+ totalProcessed: vendors.length,
+ successCount: createdVendors.length,
+ skipCount: skippedVendors.length,
+ errorCount: errors.length
+ };
+ });
+
+ // 캐시 무효화
+ revalidateTag("tech-vendors");
+ revalidateTag("tech-vendor-contacts");
+ revalidateTag("users");
+
+ console.log("Import 완료 - 결과:", result);
+
+ // 결과 메시지 생성
+ const messages = [];
+ if (result.successCount > 0) {
+ messages.push(`${result.successCount}개 벤더 생성 성공`);
+ }
+ if (result.skipCount > 0) {
+ messages.push(`${result.skipCount}개 벤더 중복으로 스킵`);
+ }
+ if (result.errorCount > 0) {
+ messages.push(`${result.errorCount}개 벤더 처리 중 오류`);
+ }
+
+ return {
+ success: true,
+ data: result,
+ message: messages.join(", "),
+ details: {
+ created: result.createdVendors,
+ skipped: result.skippedVendors,
+ errors: result.errors
+ }
+ };
+ } 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 createTechVendorFromSignup(params: {
+ vendorData: {
+ vendorName: string
+ vendorCode?: string
+ items: string
+ website?: string
+ taxId: string
+ address?: string
+ email: string
+ phone?: string
+ country: string
+ techVendorType: "조선" | "해양TOP" | "해양HULL" | ("조선" | "해양TOP" | "해양HULL")[]
+ representativeName?: string
+ representativeBirth?: string
+ representativeEmail?: string
+ representativePhone?: string
+ }
+ files?: File[]
+ contacts: {
+ contactName: string
+ contactPosition?: string
+ contactEmail: string
+ contactPhone?: string
+ isPrimary?: boolean
+ }[]
+ selectedItemCodes?: string[] // 선택된 아이템 코드들
+ invitationToken?: string // 초대 토큰
+}) {
+ unstable_noStore();
+
+ try {
+ console.log("기술영업 벤더 회원가입 시작:", params.vendorData.vendorName);
+
+ // 초대 토큰 검증
+ let existingVendorId: number | null = null;
+ if (params.invitationToken) {
+ const { verifyTechVendorInvitationToken } = await import("@/lib/tech-vendor-invitation-token");
+ const tokenPayload = await verifyTechVendorInvitationToken(params.invitationToken);
+
+ if (!tokenPayload) {
+ throw new Error("유효하지 않은 초대 토큰입니다.");
+ }
+
+ existingVendorId = tokenPayload.vendorId;
+ console.log("초대 토큰 검증 성공, 벤더 ID:", existingVendorId);
+ }
+
+ const result = await db.transaction(async (tx) => {
+ let vendorResult;
+
+ if (existingVendorId) {
+ // 기존 벤더 정보 업데이트
+ const [updatedVendor] = await tx.update(techVendors)
+ .set({
+ vendorName: params.vendorData.vendorName,
+ vendorCode: params.vendorData.vendorCode || null,
+ taxId: params.vendorData.taxId,
+ country: params.vendorData.country,
+ address: params.vendorData.address || null,
+ phone: params.vendorData.phone || null,
+ email: params.vendorData.email,
+ website: params.vendorData.website || null,
+ techVendorType: Array.isArray(params.vendorData.techVendorType)
+ ? params.vendorData.techVendorType[0]
+ : params.vendorData.techVendorType,
+ status: "QUOTE_COMPARISON", // 가입 완료 시 QUOTE_COMPARISON으로 변경
+ representativeName: params.vendorData.representativeName || null,
+ representativeEmail: params.vendorData.representativeEmail || null,
+ representativePhone: params.vendorData.representativePhone || null,
+ representativeBirth: params.vendorData.representativeBirth || null,
+ items: params.vendorData.items,
+ updatedAt: new Date(),
+ })
+ .where(eq(techVendors.id, existingVendorId))
+ .returning();
+
+ vendorResult = updatedVendor;
+ console.log("기존 벤더 정보 업데이트 완료:", vendorResult.id);
+ } else {
+ // 1. 이메일 중복 체크 (새 벤더인 경우)
+ const existingVendor = await tx.query.techVendors.findFirst({
+ where: eq(techVendors.email, params.vendorData.email),
+ columns: { id: true, vendorName: true }
+ });
+
+ if (existingVendor) {
+ throw new Error(`이미 등록된 이메일입니다: ${params.vendorData.email} (기존 업체: ${existingVendor.vendorName})`);
+ }
+
+ // 2. 새 벤더 생성
+ const [newVendor] = await tx.insert(techVendors).values({
+ vendorName: params.vendorData.vendorName,
+ vendorCode: params.vendorData.vendorCode || null,
+ taxId: params.vendorData.taxId,
+ country: params.vendorData.country,
+ address: params.vendorData.address || null,
+ phone: params.vendorData.phone || null,
+ email: params.vendorData.email,
+ website: params.vendorData.website || null,
+ techVendorType: Array.isArray(params.vendorData.techVendorType)
+ ? params.vendorData.techVendorType[0]
+ : params.vendorData.techVendorType,
+ status: "QUOTE_COMPARISON",
+ isQuoteComparison: false,
+ representativeName: params.vendorData.representativeName || null,
+ representativeEmail: params.vendorData.representativeEmail || null,
+ representativePhone: params.vendorData.representativePhone || null,
+ representativeBirth: params.vendorData.representativeBirth || null,
+ items: params.vendorData.items,
+ }).returning();
+
+ vendorResult = newVendor;
+ console.log("새 벤더 생성 완료:", vendorResult.id);
+ }
+
+ // 이 부분은 위에서 이미 처리되었으므로 주석 처리
+
+ // 3. 연락처 생성
+ if (params.contacts && params.contacts.length > 0) {
+ for (const [index, contact] of params.contacts.entries()) {
+ await tx.insert(techVendorContacts).values({
+ vendorId: vendorResult.id,
+ contactName: contact.contactName,
+ contactPosition: contact.contactPosition || null,
+ contactEmail: contact.contactEmail,
+ contactPhone: contact.contactPhone || null,
+ isPrimary: index === 0, // 첫 번째 연락처를 primary로 설정
+ });
+ }
+ console.log("연락처 생성 완료:", params.contacts.length, "개");
+ }
+
+ // 4. 선택된 아이템들을 tech_vendor_possible_items에 저장
+ if (params.selectedItemCodes && params.selectedItemCodes.length > 0) {
+ for (const itemCode of params.selectedItemCodes) {
+ await tx.insert(techVendorPossibleItems).values({
+ vendorId: vendorResult.id,
+ vendorCode: vendorResult.vendorCode,
+ vendorEmail: vendorResult.email,
+ itemCode: itemCode,
+ workType: null,
+ shipTypes: null,
+ itemList: null,
+ subItemList: null,
+ });
+ }
+ console.log("선택된 아이템 저장 완료:", params.selectedItemCodes.length, "개");
+ }
+
+ // 4. 첨부파일 처리
+ if (params.files && params.files.length > 0) {
+ await storeTechVendorFiles(tx, vendorResult.id, params.files, "GENERAL");
+ console.log("첨부파일 저장 완료:", params.files.length, "개");
+ }
+
+ // 5. 유저 생성 (techCompanyId 설정)
+ console.log("유저 생성 시도:", params.vendorData.email);
+
+ const existingUser = await tx.query.users.findFirst({
+ where: eq(users.email, params.vendorData.email),
+ columns: { id: true, techCompanyId: true }
+ });
+
+ let userId = null;
+ if (!existingUser) {
+ const [newUser] = await tx.insert(users).values({
+ name: params.vendorData.vendorName,
+ email: params.vendorData.email,
+ techCompanyId: vendorResult.id, // 중요: techCompanyId 설정
+ domain: "partners",
+ }).returning();
+ userId = newUser.id;
+ console.log("유저 생성 성공:", userId);
+ } else {
+ // 기존 유저의 techCompanyId 업데이트
+ if (!existingUser.techCompanyId) {
+ await tx.update(users)
+ .set({ techCompanyId: vendorResult.id })
+ .where(eq(users.id, existingUser.id));
+ console.log("기존 유저의 techCompanyId 업데이트:", existingUser.id);
+ }
+ userId = existingUser.id;
+ }
+
+ // 6. 후보에서 해당 이메일이 있으면 vendorId 업데이트 및 상태 변경
+ if (params.vendorData.email) {
+ await tx.update(techVendorCandidates)
+ .set({
+ vendorId: vendorResult.id,
+ status: "INVITED"
+ })
+ .where(eq(techVendorCandidates.contactEmail, params.vendorData.email));
+ }
+
+ return { vendor: vendorResult, userId };
+ });
+
+ // 캐시 무효화
+ revalidateTag("tech-vendors");
+ revalidateTag("tech-vendor-candidates");
+ revalidateTag("users");
+
+ console.log("기술영업 벤더 회원가입 완료:", result);
+ return { success: true, data: result };
+ } catch (error) {
+ console.error("기술영업 벤더 회원가입 실패:", error);
+ return { success: false, error: getErrorMessage(error) };
+ }
+}
+
+/**
+ * 단일 기술영업 벤더 추가 (사용자 계정도 함께 생성)
+ */
+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: string;
+ representativeName?: string | null;
+ representativeEmail?: string | null;
+ representativePhone?: string | null;
+ representativeBirth?: string | null;
+ isQuoteComparison?: boolean;
+}) {
+ 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 || null,
+ 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: Array.isArray(input.techVendorType) ? input.techVendorType.join(',') : input.techVendorType,
+ status: input.isQuoteComparison ? "PENDING_INVITE" : "ACTIVE",
+ isQuoteComparison: input.isQuoteComparison || false,
+ representativeName: input.representativeName || null,
+ representativeEmail: input.representativeEmail || null,
+ representativePhone: input.representativePhone || null,
+ representativeBirth: input.representativeBirth || null,
+ }).returning();
+
+ console.log("벤더 생성 성공:", newVendor.id);
+
+ // 3. 견적비교용 벤더인 경우 PENDING_REVIEW 상태로 생성됨
+ // 초대는 별도의 초대 버튼을 통해 진행
+ console.log("벤더 생성 완료:", newVendor.id, "상태:", newVendor.status);
+
+ // 4. 견적비교용 벤더(isQuoteComparison)가 아닌 경우에만 유저 생성
+ let userId = null;
+ if (!input.isQuoteComparison) {
+ console.log("유저 생성 시도:", input.email);
+
+ // 이미 존재하는 유저인지 확인
+ const existingUser = await tx.query.users.findFirst({
+ where: eq(users.email, input.email),
+ columns: { id: true, techCompanyId: true }
+ });
+
+ // 유저가 존재하지 않는 경우에만 생성
+ 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 {
+ // 이미 존재하는 유저의 techCompanyId가 null인 경우 업데이트
+ if (!existingUser.techCompanyId) {
+ await tx.update(users)
+ .set({ techCompanyId: newVendor.id })
+ .where(eq(users.id, existingUser.id));
+ console.log("기존 유저의 techCompanyId 업데이트:", existingUser.id);
+ }
+ userId = existingUser.id;
+ console.log("이미 존재하는 유저:", userId);
+ }
+ } else {
+ console.log("견적비교용 벤더이므로 유저를 생성하지 않습니다.");
+ }
+
+ return { vendor: newVendor, userId };
+ });
+
+ // 캐시 무효화
+ revalidateTag("tech-vendors");
+ revalidateTag("users");
+
+ console.log("벤더 추가 완료:", result);
+ return { success: true, data: result };
+ } catch (error) {
+ console.error("벤더 추가 실패:", error);
+ return { success: false, error: getErrorMessage(error) };
+ }
+}
+
+/**
+ * 벤더의 possible items 개수 조회
+ */
+export async function getTechVendorPossibleItemsCount(vendorId: number): Promise<number> {
+ try {
+ const result = await db
+ .select({ count: sql<number>`count(*)`.as("count") })
+ .from(techVendorPossibleItems)
+ .where(eq(techVendorPossibleItems.vendorId, vendorId));
+
+ return result[0]?.count || 0;
+ } catch (err) {
+ console.error("Error getting tech vendor possible items count:", err);
+ return 0;
+ }
+}
+
+/**
+ * 기술영업 벤더 초대 메일 발송
+ */
+export async function inviteTechVendor(params: {
+ vendorId: number;
+ subject: string;
+ message: string;
+ recipientEmail: string;
+}) {
+ unstable_noStore();
+
+ try {
+ console.log("기술영업 벤더 초대 메일 발송 시작:", params.vendorId);
+
+ const result = await db.transaction(async (tx) => {
+ // 벤더 정보 조회
+ const vendor = await tx.query.techVendors.findFirst({
+ where: eq(techVendors.id, params.vendorId),
+ });
+
+ if (!vendor) {
+ throw new Error("벤더를 찾을 수 없습니다.");
+ }
+
+ // 벤더 상태를 INVITED로 변경 (PENDING_INVITE에서)
+ if (vendor.status !== "PENDING_INVITE") {
+ throw new Error("초대 가능한 상태가 아닙니다. (PENDING_INVITE 상태만 초대 가능)");
+ }
+
+ await tx.update(techVendors)
+ .set({
+ status: "INVITED",
+ updatedAt: new Date(),
+ })
+ .where(eq(techVendors.id, params.vendorId));
+
+ // 초대 토큰 생성
+ const { createTechVendorInvitationToken, createTechVendorSignupUrl } = await import("@/lib/tech-vendor-invitation-token");
+ const { sendEmail } = await import("@/lib/mail/sendEmail");
+
+ const invitationToken = await createTechVendorInvitationToken({
+ vendorType: vendor.techVendorType as "조선" | "해양TOP" | "해양HULL" | ("조선" | "해양TOP" | "해양HULL")[],
+ vendorId: vendor.id,
+ vendorName: vendor.vendorName,
+ email: params.recipientEmail,
+ });
+
+ const signupUrl = await createTechVendorSignupUrl(invitationToken);
+
+ // 초대 메일 발송
+ await sendEmail({
+ to: params.recipientEmail,
+ subject: params.subject,
+ template: "tech-vendor-invitation",
+ context: {
+ companyName: vendor.vendorName,
+ language: "ko",
+ registrationLink: signupUrl,
+ customMessage: params.message,
+ }
+ });
+
+ console.log("초대 메일 발송 완료:", params.recipientEmail);
+
+ return { vendor, invitationToken, signupUrl };
+ });
+
+ // 캐시 무효화
+ revalidateTag("tech-vendors");
+
+ console.log("기술영업 벤더 초대 완료:", result);
+ return { success: true, data: result };
+ } catch (error) {
+ console.error("기술영업 벤더 초대 실패:", error);
+ return { success: false, error: getErrorMessage(error) };
+ }
+}
+
+/* -----------------------------------------------------
+ Possible Items 관련 함수들
+----------------------------------------------------- */
+
+/**
+ * 특정 벤더의 possible items 조회 (페이지네이션 포함)
+ */
+export async function getTechVendorPossibleItems(input: GetTechVendorPossibleItemsSchema, vendorId: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage
+
+ // 고급 필터 처리
+ const advancedWhere = filterColumns({
+ table: techVendorPossibleItems,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ })
+
+ // 글로벌 검색
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(techVendorPossibleItems.itemCode, s),
+ ilike(techVendorPossibleItems.workType, s),
+ ilike(techVendorPossibleItems.itemList, s),
+ ilike(techVendorPossibleItems.shipTypes, s),
+ ilike(techVendorPossibleItems.subItemList, s)
+ );
+ }
+
+ // 벤더 ID 조건
+ const vendorWhere = eq(techVendorPossibleItems.vendorId, vendorId)
+
+ // 개별 필터들
+ const individualFilters = []
+ if (input.itemCode) {
+ individualFilters.push(ilike(techVendorPossibleItems.itemCode, `%${input.itemCode}%`))
+ }
+ if (input.workType) {
+ individualFilters.push(ilike(techVendorPossibleItems.workType, `%${input.workType}%`))
+ }
+ if (input.itemList) {
+ individualFilters.push(ilike(techVendorPossibleItems.itemList, `%${input.itemList}%`))
+ }
+ if (input.shipTypes) {
+ individualFilters.push(ilike(techVendorPossibleItems.shipTypes, `%${input.shipTypes}%`))
+ }
+ if (input.subItemList) {
+ individualFilters.push(ilike(techVendorPossibleItems.subItemList, `%${input.subItemList}%`))
+ }
+
+ // 최종 where 조건
+ const finalWhere = and(
+ vendorWhere,
+ advancedWhere,
+ globalWhere,
+ ...(individualFilters.length > 0 ? individualFilters : [])
+ )
+
+ // 정렬
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) => {
+ // techVendorType은 실제 테이블 컬럼이 아니므로 제외
+ if (item.id === 'techVendorType') return desc(techVendorPossibleItems.createdAt)
+ const column = (techVendorPossibleItems as any)[item.id]
+ return item.desc ? desc(column) : asc(column)
+ })
+ : [desc(techVendorPossibleItems.createdAt)]
+
+ // 데이터 조회
+ const data = await db
+ .select()
+ .from(techVendorPossibleItems)
+ .where(finalWhere)
+ .orderBy(...orderBy)
+ .limit(input.perPage)
+ .offset(offset)
+
+ // 전체 개수 조회
+ const totalResult = await db
+ .select({ count: sql<number>`count(*)`.as("count") })
+ .from(techVendorPossibleItems)
+ .where(finalWhere)
+
+ const total = totalResult[0]?.count || 0
+ const pageCount = Math.ceil(total / input.perPage)
+
+ return { data, pageCount }
+ } catch (err) {
+ console.error("Error fetching tech vendor possible items:", err)
+ return { data: [], pageCount: 0 }
+ }
+ },
+ [JSON.stringify(input), String(vendorId)],
+ {
+ revalidate: 3600,
+ tags: [`tech-vendor-possible-items-${vendorId}`],
+ }
+ )()
+}
+
+export async function createTechVendorPossibleItemNew(input: CreateTechVendorPossibleItemSchema) {
+ unstable_noStore()
+
+ try {
+ // 중복 체크
+ const existing = await db
+ .select({ id: techVendorPossibleItems.id })
+ .from(techVendorPossibleItems)
+ .where(
+ and(
+ eq(techVendorPossibleItems.vendorId, input.vendorId),
+ eq(techVendorPossibleItems.itemCode, input.itemCode)
+ )
+ )
+ .limit(1)
+
+ if (existing.length > 0) {
+ return { data: null, error: "이미 등록된 아이템입니다." }
+ }
+
+ const [newItem] = await db
+ .insert(techVendorPossibleItems)
+ .values({
+ vendorId: input.vendorId,
+ itemCode: input.itemCode,
+ workType: input.workType,
+ shipTypes: input.shipTypes,
+ itemList: input.itemList,
+ subItemList: input.subItemList,
+ })
+ .returning()
+
+ revalidateTag(`tech-vendor-possible-items-${input.vendorId}`)
+ return { data: newItem, error: null }
+ } catch (err) {
+ console.error("Error creating tech vendor possible item:", err)
+ return { data: null, error: getErrorMessage(err) }
+ }
+}
+
+export async function updateTechVendorPossibleItemNew(input: UpdateTechVendorPossibleItemSchema) {
+ unstable_noStore()
+
+ try {
+ const [updatedItem] = await db
+ .update(techVendorPossibleItems)
+ .set({
+ itemCode: input.itemCode,
+ workType: input.workType,
+ shipTypes: input.shipTypes,
+ itemList: input.itemList,
+ subItemList: input.subItemList,
+ updatedAt: new Date(),
+ })
+ .where(eq(techVendorPossibleItems.id, input.id))
+ .returning()
+
+ revalidateTag(`tech-vendor-possible-items-${input.vendorId}`)
+ return { data: updatedItem, error: null }
+ } catch (err) {
+ console.error("Error updating tech vendor possible item:", err)
+ return { data: null, error: getErrorMessage(err) }
+ }
+}
+
+export async function deleteTechVendorPossibleItemsNew(ids: number[], vendorId: number) {
+ unstable_noStore()
+
+ try {
+ await db
+ .delete(techVendorPossibleItems)
+ .where(inArray(techVendorPossibleItems.id, ids))
+
+ revalidateTag(`tech-vendor-possible-items-${vendorId}`)
+ return { data: null, error: null }
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) }
+ }
+}
+
+export async function addTechVendorPossibleItem(input: {
+ vendorId: number;
+ itemCode?: string;
+ workType?: string;
+ shipTypes?: string;
+ itemList?: string;
+ subItemList?: string;
+}) {
+ unstable_noStore();
+ try {
+ if (!input.itemCode) {
+ return { success: false, error: "아이템 코드는 필수입니다." };
+ }
+
+ const [newItem] = await db
+ .insert(techVendorPossibleItems)
+ .values({
+ vendorId: input.vendorId,
+ itemCode: input.itemCode,
+ workType: input.workType || null,
+ shipTypes: input.shipTypes || null,
+ itemList: input.itemList || null,
+ subItemList: input.subItemList || null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .returning();
+
+ revalidateTag(`tech-vendor-possible-items-${input.vendorId}`);
+
+ return { success: true, data: newItem };
+ } catch (err) {
+ return { success: false, error: getErrorMessage(err) };
+ }
+}
+
+export async function deleteTechVendorPossibleItem(itemId: number, vendorId: number) {
+ unstable_noStore();
+ try {
+ const [deletedItem] = await db
+ .delete(techVendorPossibleItems)
+ .where(eq(techVendorPossibleItems.id, itemId))
+ .returning();
+
+ revalidateTag(`tech-vendor-possible-items-${vendorId}`);
+
+ return { success: true, data: deletedItem };
+ } catch (err) {
+ return { success: false, error: getErrorMessage(err) };
+ }
+}
+
+
+
+//기술영업 담당자 연락처 관련 함수들
+
+export interface ImportContactData {
+ vendorEmail: string // 벤더 대표이메일 (유니크)
+ contactName: string
+ contactPosition?: string
+ contactEmail: string
+ contactPhone?: string
+ contactCountry?: string
+ isPrimary?: boolean
+}
+
+export interface ImportResult {
+ success: boolean
+ totalRows: number
+ successCount: number
+ failedRows: Array<{
+ row: number
+ error: string
+ vendorEmail: string
+ contactName: string
+ contactEmail: string
+ }>
+}
+
+/**
+ * 벤더 대표이메일로 벤더 찾기
+ */
+async function getTechVendorByEmail(email: string) {
+ const vendor = await db
+ .select({
+ id: techVendors.id,
+ vendorName: techVendors.vendorName,
+ email: techVendors.email,
+ })
+ .from(techVendors)
+ .where(eq(techVendors.email, email))
+ .limit(1)
+
+ return vendor[0] || null
+}
+
+/**
+ * 연락처 이메일 중복 체크
+ */
+async function checkContactEmailExists(vendorId: number, contactEmail: string) {
+ const existing = await db
+ .select()
+ .from(techVendorContacts)
+ .where(
+ and(
+ eq(techVendorContacts.vendorId, vendorId),
+ eq(techVendorContacts.contactEmail, contactEmail)
+ )
+ )
+ .limit(1)
+
+ return existing.length > 0
+}
+
+/**
+ * 벤더 연락처 일괄 import
+ */
+export async function importTechVendorContacts(
+ data: ImportContactData[]
+): Promise<ImportResult> {
+ const result: ImportResult = {
+ success: true,
+ totalRows: data.length,
+ successCount: 0,
+ failedRows: [],
+ }
+
+ for (let i = 0; i < data.length; i++) {
+ const row = data[i]
+ const rowNumber = i + 1
+
+ try {
+ // 1. 벤더 이메일로 벤더 찾기
+ if (!row.vendorEmail || !row.vendorEmail.trim()) {
+ result.failedRows.push({
+ row: rowNumber,
+ error: "벤더 대표이메일은 필수입니다.",
+ vendorEmail: row.vendorEmail,
+ contactName: row.contactName,
+ contactEmail: row.contactEmail,
+ })
+ continue
+ }
+
+ const vendor = await getTechVendorByEmail(row.vendorEmail.trim())
+ if (!vendor) {
+ result.failedRows.push({
+ row: rowNumber,
+ error: `벤더 대표이메일 '${row.vendorEmail}'을(를) 찾을 수 없습니다.`,
+ vendorEmail: row.vendorEmail,
+ contactName: row.contactName,
+ contactEmail: row.contactEmail,
+ })
+ continue
+ }
+
+ // 2. 연락처 이메일 중복 체크
+ const isDuplicate = await checkContactEmailExists(vendor.id, row.contactEmail)
+ if (isDuplicate) {
+ result.failedRows.push({
+ row: rowNumber,
+ error: `이미 존재하는 연락처 이메일입니다: ${row.contactEmail}`,
+ vendorEmail: row.vendorEmail,
+ contactName: row.contactName,
+ contactEmail: row.contactEmail,
+ })
+ continue
+ }
+
+ // 3. 연락처 생성
+ await db.insert(techVendorContacts).values({
+ vendorId: vendor.id,
+ contactName: row.contactName,
+ contactPosition: row.contactPosition || null,
+ contactEmail: row.contactEmail,
+ contactPhone: row.contactPhone || null,
+ contactCountry: row.contactCountry || null,
+ isPrimary: row.isPrimary || false,
+ })
+
+ result.successCount++
+ } catch (error) {
+ result.failedRows.push({
+ row: rowNumber,
+ error: error instanceof Error ? error.message : "알 수 없는 오류",
+ vendorEmail: row.vendorEmail,
+ contactName: row.contactName,
+ contactEmail: row.contactEmail,
+ })
+ }
+ }
+
+ // 캐시 무효화
+ revalidateTag("tech-vendor-contacts")
+
+ return result
+}
+
+/**
+ * 벤더 연락처 import 템플릿 생성
+ */
+export async function generateContactImportTemplate(): Promise<Blob> {
+ const workbook = new ExcelJS.Workbook()
+ const worksheet = workbook.addWorksheet("벤더연락처_템플릿")
+
+ // 헤더 설정
+ worksheet.columns = [
+ { header: "벤더대표이메일*", key: "vendorEmail", width: 25 },
+ { header: "담당자명*", key: "contactName", width: 20 },
+ { header: "직책", key: "contactPosition", width: 15 },
+ { header: "담당자이메일*", key: "contactEmail", width: 25 },
+ { header: "담당자연락처", key: "contactPhone", width: 15 },
+ { header: "담당자국가", key: "contactCountry", width: 15 },
+ { header: "주담당자여부", key: "isPrimary", width: 12 },
+ ]
+
+ // 헤더 스타일 설정
+ const headerRow = worksheet.getRow(1)
+ headerRow.font = { bold: true }
+ headerRow.fill = {
+ type: "pattern",
+ pattern: "solid",
+ fgColor: { argb: "FFE0E0E0" },
+ }
+
+ // 예시 데이터 추가
+ worksheet.addRow({
+ vendorEmail: "example@company.com",
+ contactName: "홍길동",
+ contactPosition: "대표",
+ contactEmail: "hong@company.com",
+ contactPhone: "010-1234-5678",
+ contactCountry: "대한민국",
+ isPrimary: "Y",
+ })
+
+ worksheet.addRow({
+ vendorEmail: "example@company.com",
+ contactName: "김철수",
+ contactPosition: "과장",
+ contactEmail: "kim@company.com",
+ contactPhone: "010-9876-5432",
+ contactCountry: "대한민국",
+ isPrimary: "N",
+ })
+
+ const buffer = await workbook.xlsx.writeBuffer()
+ return new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ })
+}
+
+/**
+ * Excel 파일에서 연락처 데이터 파싱
+ */
+export async function parseContactImportFile(file: File): Promise<ImportContactData[]> {
+ const arrayBuffer = await file.arrayBuffer()
+ const workbook = new ExcelJS.Workbook()
+ await workbook.xlsx.load(arrayBuffer)
+
+ const worksheet = workbook.worksheets[0]
+ if (!worksheet) {
+ throw new Error("Excel 파일에 워크시트가 없습니다.")
+ }
+
+ const data: ImportContactData[] = []
+
+ worksheet.eachRow((row, index) => {
+ console.log(`행 ${index} 처리 중:`, row.values)
+ // 헤더 행 건너뛰기 (1행)
+ if (index === 1) return
+
+ const values = row.values as (string | null)[]
+ if (!values || values.length < 4) return
+
+ const vendorEmail = values[1]?.toString().trim()
+ const contactName = values[2]?.toString().trim()
+ const contactPosition = values[3]?.toString().trim()
+ const contactEmail = values[4]?.toString().trim()
+ const contactPhone = values[5]?.toString().trim()
+ const contactCountry = values[6]?.toString().trim()
+ const isPrimary = values[7]?.toString().trim()
+
+ // 필수 필드 검증
+ if (!vendorEmail || !contactName || !contactEmail) {
+ return
+ }
+
+ data.push({
+ vendorEmail,
+ contactName,
+ contactPosition: contactPosition || undefined,
+ contactEmail,
+ contactPhone: contactPhone || undefined,
+ contactCountry: contactCountry || undefined,
+ isPrimary: isPrimary === "Y" || isPrimary === "y",
+ })
+
+ // rowNumber++
+ })
+
+ return data
+}
\ 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 index 22c03bcc..e89f5d6b 100644 --- a/lib/tech-vendors/table/add-vendor-dialog.tsx +++ b/lib/tech-vendors/table/add-vendor-dialog.tsx @@ -255,7 +255,7 @@ export function AddVendorDialog({ onSuccess }: AddVendorDialogProps) { className="w-4 h-4 mt-1"
/>
</FormControl>
- <div className="space-y-1 leading-none">
+ <div className="space-y-1 leading-none ml-2">
<FormLabel className="cursor-pointer">
견적비교용 벤더
</FormLabel>
@@ -361,6 +361,52 @@ export function AddVendorDialog({ onSuccess }: AddVendorDialogProps) { </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>
diff --git a/lib/tech-vendors/table/attachmentButton.tsx b/lib/tech-vendors/table/attachmentButton.tsx index 12dc6f77..2754c9f0 100644 --- a/lib/tech-vendors/table/attachmentButton.tsx +++ b/lib/tech-vendors/table/attachmentButton.tsx @@ -1,76 +1,76 @@ -'use client'; - -import React from 'react'; -import { Button } from '@/components/ui/button'; -import { PaperclipIcon } from 'lucide-react'; -import { Badge } from '@/components/ui/badge'; -import { toast } from 'sonner'; -import { type VendorAttach } from '@/db/schema/vendors'; -import { downloadTechVendorAttachments } from '../service'; - -interface AttachmentsButtonProps { - vendorId: number; - hasAttachments: boolean; - attachmentsList?: VendorAttach[]; -} - -export function AttachmentsButton({ vendorId, hasAttachments, attachmentsList = [] }: AttachmentsButtonProps) { - if (!hasAttachments) return null; - - const handleDownload = async () => { - try { - toast.loading('첨부파일을 준비하는 중...'); - - // 서버 액션 호출 - const result = await downloadTechVendorAttachments(vendorId); - - // 로딩 토스트 닫기 - toast.dismiss(); - - if (!result || !result.url) { - toast.error('다운로드 준비 중 오류가 발생했습니다.'); - return; - } - - // 파일 다운로드 트리거 - toast.success('첨부파일 다운로드가 시작되었습니다.'); - - // 다운로드 링크 열기 - const a = document.createElement('a'); - a.href = result.url; - a.download = result.fileName || '첨부파일.zip'; - a.style.display = 'none'; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - - } catch (error) { - toast.dismiss(); - toast.error('첨부파일 다운로드에 실패했습니다.'); - console.error('첨부파일 다운로드 오류:', error); - } - }; - - return ( - <> - {attachmentsList && attachmentsList.length > 0 && - <Button - variant="ghost" - size="icon" - onClick={handleDownload} - title={`${attachmentsList.length}개 파일 다운로드`} - > - <PaperclipIcon className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - {/* {attachmentsList.length > 1 && ( - <Badge - variant="secondary" - className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.425rem] leading-none flex items-center justify-center" - > - {attachmentsList.length} - </Badge> - )} */} - </Button> - } - </> - ); -} +'use client';
+
+import React from 'react';
+import { Button } from '@/components/ui/button';
+import { PaperclipIcon } from 'lucide-react';
+import { Badge } from '@/components/ui/badge';
+import { toast } from 'sonner';
+import { type VendorAttach } from '@/db/schema/vendors';
+import { downloadTechVendorAttachments } from '../service';
+
+interface AttachmentsButtonProps {
+ vendorId: number;
+ hasAttachments: boolean;
+ attachmentsList?: VendorAttach[];
+}
+
+export function AttachmentsButton({ vendorId, hasAttachments, attachmentsList = [] }: AttachmentsButtonProps) {
+ if (!hasAttachments) return null;
+
+ const handleDownload = async () => {
+ try {
+ toast.loading('첨부파일을 준비하는 중...');
+
+ // 서버 액션 호출
+ const result = await downloadTechVendorAttachments(vendorId);
+
+ // 로딩 토스트 닫기
+ toast.dismiss();
+
+ if (!result || !result.url) {
+ toast.error('다운로드 준비 중 오류가 발생했습니다.');
+ return;
+ }
+
+ // 파일 다운로드 트리거
+ toast.success('첨부파일 다운로드가 시작되었습니다.');
+
+ // 다운로드 링크 열기
+ const a = document.createElement('a');
+ a.href = result.url;
+ a.download = result.fileName || '첨부파일.zip';
+ a.style.display = 'none';
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+
+ } catch (error) {
+ toast.dismiss();
+ toast.error('첨부파일 다운로드에 실패했습니다.');
+ console.error('첨부파일 다운로드 오류:', error);
+ }
+ };
+
+ return (
+ <>
+ {attachmentsList && attachmentsList.length > 0 &&
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={handleDownload}
+ title={`${attachmentsList.length}개 파일 다운로드`}
+ >
+ <PaperclipIcon className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {/* {attachmentsList.length > 1 && (
+ <Badge
+ variant="secondary"
+ className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.425rem] leading-none flex items-center justify-center"
+ >
+ {attachmentsList.length}
+ </Badge>
+ )} */}
+ </Button>
+ }
+ </>
+ );
+}
diff --git a/lib/tech-vendors/table/excel-template-download.tsx b/lib/tech-vendors/table/excel-template-download.tsx index b6011e2c..3de9ab33 100644 --- a/lib/tech-vendors/table/excel-template-download.tsx +++ b/lib/tech-vendors/table/excel-template-download.tsx @@ -1,150 +1,232 @@ -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: '조선,해양TOP', - 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: '해양HULL', - 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; - } +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: 'contactName', width: 20 },
+ { header: '담당자직책', key: 'contactPosition', width: 15 },
+ { header: '담당자이메일', key: 'contactEmail', width: 25 },
+ { header: '담당자연락처', key: 'contactPhone', width: 15 },
+ { header: '담당자국가', key: 'contactCountry', 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' }
+ };
+
+ // 샘플 데이터 추가
+ worksheet.addRow([
+ 'ABC 조선소', // 업체명
+ 'ABC001', // 업체코드
+ '123-45-67890', // 사업자등록번호
+ '대한민국', // 국가
+ 'South Korea', // 영문국가명
+ '대한민국', // 제조국
+ '김대리', // 에이전트명
+ '02-123-4567', // 에이전트연락처
+ 'agent@abc.co.kr', // 에이전트이메일
+ '서울시 강남구 테헤란로 123', // 주소
+ '02-123-4567', // 전화번호
+ 'contact@abc.co.kr', // 이메일
+ 'https://www.abc.co.kr', // 웹사이트
+ '조선', // 벤더타입
+ '홍길동', // 대표자명
+ 'ceo@abc.co.kr', // 대표자이메일
+ '02-123-4567', // 대표자연락처
+ '1970-01-01', // 대표자생년월일
+ '박담당', // 담당자명
+ '과장', // 담당자직책
+ 'contact@abc.co.kr', // 담당자이메일
+ '010-1234-5678', // 담당자연락처
+ '대한민국', // 담당자국가
+ '선박부품, 엔진부품' // 아이템
+ ]);
+
+ // 설명을 위한 시트 추가
+ const instructionSheet = workbook.addWorksheet('입력 가이드');
+ instructionSheet.columns = [
+ { header: '컬럼명', key: 'column', width: 20 },
+ { header: '필수여부', key: 'required', width: 10 },
+ { header: '설명', key: 'description', width: 50 },
+ ];
+
+ // 가이드 헤더 스타일
+ const guideHeaderRow = instructionSheet.getRow(1);
+ guideHeaderRow.font = { bold: true };
+ guideHeaderRow.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFE0E0E0' }
+ };
+
+ // 입력 가이드 데이터
+ const guideData = [
+ ['업체명', '필수', '벤더 업체명을 입력하세요'],
+ ['업체코드', '선택', '벤더 고유 코드 (없으면 자동 생성)'],
+ ['사업자등록번호', '필수', '벤더의 사업자등록번호'],
+ ['국가', '선택', '벤더 소재 국가'],
+ ['영문국가명', '선택', '벤더 소재 국가의 영문명'],
+ ['제조국', '선택', '제품 제조 국가'],
+ ['에이전트명', '선택', '담당 에이전트 이름'],
+ ['에이전트연락처', '선택', '담당 에이전트 연락처'],
+ ['에이전트이메일', '선택', '담당 에이전트 이메일'],
+ ['주소', '선택', '벤더 주소'],
+ ['전화번호', '선택', '벤더 대표 전화번호'],
+ ['이메일', '필수', '벤더 대표 이메일 (대표 담당자가 없으면 이 이메일이 기본 담당자가 됩니다)'],
+ ['웹사이트', '선택', '벤더 웹사이트 URL'],
+ ['벤더타입', '필수', '벤더 유형 (조선, 해양TOP, 해양HULL 중 선택)'],
+ ['대표자명', '선택', '벤더 대표자 이름'],
+ ['대표자이메일', '선택', '벤더 대표자 이메일'],
+ ['대표자연락처', '선택', '벤더 대표자 연락처'],
+ ['대표자생년월일', '선택', '벤더 대표자 생년월일 (YYYY-MM-DD 형식)'],
+ ['담당자명', '선택', '주 담당자 이름 (없으면 대표자 또는 업체명으로 기본 담당자 생성)'],
+ ['담당자직책', '선택', '주 담당자 직책'],
+ ['담당자이메일', '선택', '주 담당자 이메일 (있으면 벤더 이메일보다 우선)'],
+ ['담당자연락처', '선택', '주 담당자 연락처'],
+ ['담당자국가', '선택', '주 담당자 소재 국가'],
+ ['아이템', '선택', '벤더가 제공하는 아이템 (쉼표로 구분)'],
+ ];
+
+ guideData.forEach(row => {
+ instructionSheet.addRow(row);
+ });
+ 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: '조선,해양TOP',
+ 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: '해양HULL',
+ 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/feature-flags-provider.tsx b/lib/tech-vendors/table/feature-flags-provider.tsx index 81131894..615377d6 100644 --- a/lib/tech-vendors/table/feature-flags-provider.tsx +++ b/lib/tech-vendors/table/feature-flags-provider.tsx @@ -1,108 +1,108 @@ -"use client" - -import * as React from "react" -import { useQueryState } from "nuqs" - -import { dataTableConfig, type DataTableConfig } from "@/config/data-table" -import { cn } from "@/lib/utils" -import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip" - -type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] - -interface FeatureFlagsContextProps { - featureFlags: FeatureFlagValue[] - setFeatureFlags: (value: FeatureFlagValue[]) => void -} - -const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({ - featureFlags: [], - setFeatureFlags: () => {}, -}) - -export function useFeatureFlags() { - const context = React.useContext(FeatureFlagsContext) - if (!context) { - throw new Error( - "useFeatureFlags must be used within a FeatureFlagsProvider" - ) - } - return context -} - -interface FeatureFlagsProviderProps { - children: React.ReactNode -} - -export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { - const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( - "flags", - { - defaultValue: [], - parse: (value) => value.split(",") as FeatureFlagValue[], - serialize: (value) => value.join(","), - eq: (a, b) => - a.length === b.length && a.every((value, index) => value === b[index]), - clearOnDefault: true, - shallow: false, - } - ) - - return ( - <FeatureFlagsContext.Provider - value={{ - featureFlags, - setFeatureFlags: (value) => void setFeatureFlags(value), - }} - > - <div className="w-full overflow-x-auto"> - <ToggleGroup - type="multiple" - variant="outline" - size="sm" - value={featureFlags} - onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} - className="w-fit gap-0" - > - {dataTableConfig.featureFlags.map((flag, index) => ( - <Tooltip key={flag.value}> - <ToggleGroupItem - value={flag.value} - className={cn( - "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", - { - "rounded-l-sm border-r-0": index === 0, - "rounded-r-sm": - index === dataTableConfig.featureFlags.length - 1, - } - )} - asChild - > - <TooltipTrigger> - <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> - {flag.label} - </TooltipTrigger> - </ToggleGroupItem> - <TooltipContent - align="start" - side="bottom" - sideOffset={6} - className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" - > - <div>{flag.tooltipTitle}</div> - <div className="text-xs text-muted-foreground"> - {flag.tooltipDescription} - </div> - </TooltipContent> - </Tooltip> - ))} - </ToggleGroup> - </div> - {children} - </FeatureFlagsContext.Provider> - ) -} +"use client"
+
+import * as React from "react"
+import { useQueryState } from "nuqs"
+
+import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
+import { cn } from "@/lib/utils"
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface FeatureFlagsContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useFeatureFlags() {
+ const context = React.useContext(FeatureFlagsContext)
+ if (!context) {
+ throw new Error(
+ "useFeatureFlags must be used within a FeatureFlagsProvider"
+ )
+ }
+ return context
+}
+
+interface FeatureFlagsProviderProps {
+ children: React.ReactNode
+}
+
+export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "flags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ shallow: false,
+ }
+ )
+
+ return (
+ <FeatureFlagsContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit gap-0"
+ >
+ {dataTableConfig.featureFlags.map((flag, index) => (
+ <Tooltip key={flag.value}>
+ <ToggleGroupItem
+ value={flag.value}
+ className={cn(
+ "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90",
+ {
+ "rounded-l-sm border-r-0": index === 0,
+ "rounded-r-sm":
+ index === dataTableConfig.featureFlags.length - 1,
+ }
+ )}
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </FeatureFlagsContext.Provider>
+ )
+}
diff --git a/lib/tech-vendors/table/import-button.tsx b/lib/tech-vendors/table/import-button.tsx index ba01e150..1d3bf242 100644 --- a/lib/tech-vendors/table/import-button.tsx +++ b/lib/tech-vendors/table/import-button.tsx @@ -1,313 +1,381 @@ -"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> - </> - ); +"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"],
+ "담당자명": ["contactName"],
+ "담당자직책": ["contactPosition"],
+ "담당자이메일": ["contactEmail"],
+ "담당자연락처": ["contactPhone"],
+ "담당자국가": ["contactCountry"],
+ "아이템": ["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, string | null | undefined>[] = [];
+
+ worksheet.eachRow((row, rowNumber) => {
+ if (rowNumber > headerRowIndex) {
+ const rowData: Record<string, string | null | undefined> = {};
+ 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 파일에 가져올 데이터가 없습니다.");
+ }
+
+ setProgress(70);
+
+ // 벤더 데이터 처리
+ const vendors = dataRows.map(row => {
+ const vendorEmail = row["이메일"] || row["email"] || "";
+ const contactName = row["담당자명"] || row["contactName"] || "";
+ const contactEmail = row["담당자이메일"] || row["contactEmail"] || "";
+
+ // 담당자 정보 처리: 담당자가 없으면 벤더 이메일을 기본 담당자로 사용
+ const contacts = [];
+
+ if (contactName && contactEmail) {
+ // 명시적인 담당자가 있는 경우
+ contacts.push({
+ contactName: contactName,
+ contactPosition: row["담당자직책"] || row["contactPosition"] || "",
+ contactEmail: contactEmail,
+ contactPhone: row["담당자연락처"] || row["contactPhone"] || "",
+ country: row["담당자국가"] || row["contactCountry"] || null,
+ isPrimary: true
+ });
+ } else if (vendorEmail) {
+ // 담당자 정보가 없으면 벤더 정보를 기본 담당자로 사용
+ const representativeName = row["대표자명"] || row["representativeName"];
+ contacts.push({
+ contactName: representativeName || row["업체명"] || row["vendorName"] || "기본 담당자",
+ contactPosition: "기본 담당자",
+ contactEmail: vendorEmail,
+ contactPhone: row["대표자연락처"] || row["representativePhone"] || row["전화번호"] || row["phone"] || "",
+ country: row["국가"] || row["country"] || null,
+ isPrimary: true
+ });
+ }
+
+ return {
+ vendorName: row["업체명"] || row["vendorName"] || "",
+ vendorCode: row["업체코드"] || row["vendorCode"] || null,
+ email: vendorEmail,
+ 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"] || "",
+ contacts: contacts
+ };
+ });
+
+ setProgress(90);
+ toast.info(`${vendors.length}개 벤더 데이터를 서버로 전송 중...`);
+
+ // 벤더 데이터 가져오기 실행
+ const result = await importTechVendorsFromExcel(vendors);
+
+ setProgress(100);
+
+ if (result.success) {
+ // 상세한 결과 메시지 표시
+ if (result.message) {
+ toast.success(`가져오기 완료: ${result.message}`);
+ } else {
+ toast.success(`${vendors.length}개의 기술영업 벤더가 성공적으로 가져와졌습니다.`);
+ }
+
+ // 스킵된 벤더가 있으면 경고 메시지 추가
+ if (result.details?.skipped && result.details.skipped.length > 0) {
+ setTimeout(() => {
+ const skippedList = result.details.skipped
+ .map(item => `${item.vendorName} (${item.email}): ${item.reason}`)
+ .slice(0, 3) // 최대 3개만 표시
+ .join('\n');
+ const moreText = result.details.skipped.length > 3 ? `\n... 외 ${result.details.skipped.length - 3}개` : '';
+ toast.warning(`중복으로 스킵된 벤더:\n${skippedList}${moreText}`);
+ }, 1000);
+ }
+
+ // 오류가 있으면 오류 메시지 추가
+ if (result.details?.errors && result.details.errors.length > 0) {
+ setTimeout(() => {
+ const errorList = result.details.errors
+ .map(item => `${item.vendorName} (${item.email}): ${item.error}`)
+ .slice(0, 3) // 최대 3개만 표시
+ .join('\n');
+ const moreText = result.details.errors.length > 3 ? `\n... 외 ${result.details.errors.length - 3}개` : '';
+ toast.error(`처리 중 오류 발생:\n${errorList}${moreText}`);
+ }, 2000);
+ }
+ } 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-vendor-possible-items-view-dialog.tsx b/lib/tech-vendors/table/tech-vendor-possible-items-view-dialog.tsx deleted file mode 100644 index b2b9c990..00000000 --- a/lib/tech-vendors/table/tech-vendor-possible-items-view-dialog.tsx +++ /dev/null @@ -1,201 +0,0 @@ -"use client"
-
-import * as React from "react"
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogDescription,
- DialogFooter,
-} from "@/components/ui/dialog"
-import { Button } from "@/components/ui/button"
-import { Badge } from "@/components/ui/badge"
-import { Package, FileText, X } from "lucide-react"
-import { getVendorItemsByType } from "../service"
-
-interface VendorPossibleItem {
- id: number;
- itemCode: string;
- itemList: string;
- workType: string | null;
- shipTypes?: string | null; // 조선용
- subItemList?: string | null; // 해양용
- techVendorType: "조선" | "해양TOP" | "해양HULL";
-}
-
-interface TechVendorPossibleItemsViewDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- vendor: {
- id: number;
- vendorName?: string | null;
- vendorCode?: string | null;
- techVendorType?: string | null;
- } | null;
-}
-
-export function TechVendorPossibleItemsViewDialog({
- open,
- onOpenChange,
- vendor,
-}: TechVendorPossibleItemsViewDialogProps) {
- const [items, setItems] = React.useState<VendorPossibleItem[]>([]);
- const [loading, setLoading] = React.useState(false);
-
- console.log("TechVendorPossibleItemsViewDialog render:", { open, vendor });
-
- React.useEffect(() => {
- console.log("TechVendorPossibleItemsViewDialog useEffect:", { open, vendorId: vendor?.id });
- if (open && vendor?.id && vendor?.techVendorType) {
- loadItems();
- }
- }, [open, vendor?.id, vendor?.techVendorType]);
-
- const loadItems = async () => {
- if (!vendor?.id || !vendor?.techVendorType) return;
-
- console.log("Loading items for vendor:", vendor.id, vendor.techVendorType);
- setLoading(true);
- try {
- const result = await getVendorItemsByType(vendor.id, vendor.techVendorType);
- console.log("Items loaded:", result);
- if (result.data) {
- setItems(result.data);
- }
- } catch (error) {
- console.error("Failed to load items:", error);
- } finally {
- setLoading(false);
- }
- };
-
- const getTypeLabel = (type: string) => {
- switch (type) {
- case "조선":
- return "조선";
- case "해양TOP":
- return "해양TOP";
- case "해양HULL":
- return "해양HULL";
- default:
- return type;
- }
- };
-
- const getTypeColor = (type: string) => {
- switch (type) {
- case "조선":
- return "bg-blue-100 text-blue-800";
- case "해양TOP":
- return "bg-green-100 text-green-800";
- case "해양HULL":
- return "bg-purple-100 text-purple-800";
- default:
- return "bg-gray-100 text-gray-800";
- }
- };
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-none w-[1200px]">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- 벤더 Possible Items 조회
- <Badge variant="outline" className="ml-2">
- {vendor?.vendorName || `Vendor #${vendor?.id}`}
- </Badge>
- {vendor?.techVendorType && (
- <Badge variant="secondary" className={getTypeColor(vendor.techVendorType)}>
- {getTypeLabel(vendor.techVendorType)}
- </Badge>
- )}
- </DialogTitle>
- <DialogDescription>
- 해당 벤더가 공급 가능한 아이템 목록을 확인할 수 있습니다.
- </DialogDescription>
- </DialogHeader>
-
- <div className="overflow-x-auto w-full">
- <div className="space-y-4">
- {loading ? (
- <div className="flex items-center justify-center py-8">
- <div className="text-center space-y-2">
- <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
- <p className="text-sm text-muted-foreground">아이템을 불러오는 중...</p>
- </div>
- </div>
- ) : items.length === 0 ? (
- <div className="flex flex-col items-center justify-center py-12 text-center">
- <FileText className="h-12 w-12 text-muted-foreground mb-3" />
- <h3 className="text-lg font-medium mb-1">등록된 아이템이 없습니다</h3>
- <p className="text-sm text-muted-foreground">
- 이 벤더에 등록된 아이템이 없습니다.
- </p>
- </div>
- ) : (
- <>
- {/* 헤더 행 (라벨) */}
- <div className="flex items-center gap-2 border-b pb-2 font-medium text-sm">
- <div className="w-[50px] text-center">No.</div>
- <div className="w-[120px] pl-2">타입</div>
- <div className="w-[200px] ">자재 그룹</div>
- <div className="w-[150px] ">공종</div>
- <div className="w-[300px] ">자재명</div>
- <div className="w-[150px] ">선종/자재명(상세)</div>
- </div>
-
- {/* 아이템 행들 */}
- <div className="max-h-[50vh] overflow-y-auto pr-1 space-y-2">
- {items.map((item, index) => (
- <div
- key={item.id}
- className="flex items-center gap-2 group hover:bg-gray-50 p-2 rounded-md transition-colors border"
- >
- <div className="w-[50px] text-center text-sm font-medium text-muted-foreground">
- {index + 1}
- </div>
- <div className="w-[120px] pl-2">
- <Badge variant="secondary" className={`text-xs ${getTypeColor(item.techVendorType)}`}>
- {getTypeLabel(item.techVendorType)}
- </Badge>
- </div>
- <div className="w-[200px] pl-2 font-mono text-sm">
- {item.itemCode}
- </div>
- <div className="w-[150px] pl-2 text-sm">
- {item.workType || '-'}
- </div>
- <div className="w-[300px] pl-2 font-medium">
- {item.itemList}
- </div>
- <div className="w-[150px] pl-2 text-sm">
- {item.techVendorType === '조선' ? item.shipTypes : item.subItemList}
- </div>
- </div>
- ))}
- </div>
-
- <div className="flex justify-between items-center pt-2 border-t">
- <div className="flex items-center gap-2">
- <Package className="h-4 w-4 text-muted-foreground" />
- <span className="text-sm text-muted-foreground">
- 총 {items.length}개 아이템
- </span>
- </div>
- </div>
- </>
- )}
- </div>
- </div>
-
- <DialogFooter className="mt-6">
- <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
- <X className="mr-2 h-4 w-4" />
- 닫기
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
-}
\ No newline at end of file diff --git a/lib/tech-vendors/table/tech-vendors-filter-sheet.tsx b/lib/tech-vendors/table/tech-vendors-filter-sheet.tsx new file mode 100644 index 00000000..c6beb7a9 --- /dev/null +++ b/lib/tech-vendors/table/tech-vendors-filter-sheet.tsx @@ -0,0 +1,617 @@ +"use client"
+
+import { useEffect, useTransition, useState, useRef } from "react"
+import { z } from "zod"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Search, X } from "lucide-react"
+import { customAlphabet } from "nanoid"
+import { parseAsStringEnum, useQueryState } from "nuqs"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { Badge } from "@/components/ui/badge"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { cn } from "@/lib/utils"
+import { getFiltersStateParser } from "@/lib/parsers"
+import { Checkbox } from "@/components/ui/checkbox"
+
+// nanoid 생성기
+const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6)
+
+// 필터 스키마 정의 (기술영업 벤더에 맞게 수정)
+const filterSchema = z.object({
+ vendorCode: z.string().optional(),
+ vendorName: z.string().optional(),
+ country: z.string().optional(),
+ status: z.string().optional(),
+ techVendorType: z.array(z.string()).optional(),
+ workTypes: z.array(z.string()).optional(),
+})
+
+// 상태 옵션 정의
+const statusOptions = [
+ { value: "ACTIVE", label: "활성 상태" },
+ { value: "INACTIVE", label: "비활성 상태" },
+ { value: "BLACKLISTED", label: "거래 금지" },
+ { value: "PENDING_INVITE", label: "초대 대기" },
+ { value: "INVITED", label: "초대 완료" },
+ { value: "QUOTE_COMPARISON", label: "견적 비교" },
+]
+
+// 벤더 타입 옵션
+const vendorTypeOptions = [
+ { value: "조선", label: "조선" },
+ { value: "해양TOP", label: "해양TOP" },
+ { value: "해양HULL", label: "해양HULL" },
+]
+
+// 공종 옵션
+const workTypeOptions = [
+ // 조선 workTypes
+ { value: "기장", label: "기장" },
+ { value: "전장", label: "전장" },
+ { value: "선실", label: "선실" },
+ { value: "배관", label: "배관" },
+ { value: "철의", label: "철의" },
+ { value: "선체", label: "선체" },
+ // 해양TOP workTypes
+ { value: "TM", label: "TM" },
+ { value: "TS", label: "TS" },
+ { value: "TE", label: "TE" },
+ { value: "TP", label: "TP" },
+ // 해양HULL workTypes
+ { value: "HA", label: "HA" },
+ { value: "HE", label: "HE" },
+ { value: "HH", label: "HH" },
+ { value: "HM", label: "HM" },
+ { value: "NC", label: "NC" },
+ { value: "HO", label: "HO" },
+ { value: "HP", label: "HP" },
+]
+
+type FilterFormValues = z.infer<typeof filterSchema>
+
+interface TechVendorsFilterSheetProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSearch?: () => void;
+ isLoading?: boolean;
+}
+
+export function TechVendorsFilterSheet({
+ isOpen,
+ onSearch,
+ isLoading = false
+}: TechVendorsFilterSheetProps) {
+
+
+ const [isPending, startTransition] = useTransition()
+
+ // 초기화 상태 추가 - 폼 초기화 중에는 상태 변경을 방지
+ const [isInitializing, setIsInitializing] = useState(false)
+ // 마지막으로 적용된 필터를 추적하기 위한 ref
+ const lastAppliedFilters = useRef<string>("")
+
+ // nuqs로 URL 상태 관리 - 파라미터명을 'filters'로 변경하여 searchParamsCache와 일치
+ const [filters] = useQueryState(
+ "filters",
+ getFiltersStateParser().withDefault([])
+ )
+
+ // joinOperator 설정
+ const [joinOperator, setJoinOperator] = useQueryState(
+ "joinOperator",
+ parseAsStringEnum(["and", "or"]).withDefault("and")
+ )
+
+ // 폼 상태 초기화
+ const form = useForm<FilterFormValues>({
+ resolver: zodResolver(filterSchema),
+ defaultValues: {
+ vendorCode: "",
+ vendorName: "",
+ country: "",
+ status: "",
+ techVendorType: [],
+ workTypes: [],
+ },
+ })
+
+ // URL 필터에서 초기 폼 상태 설정
+ useEffect(() => {
+ const currentFiltersString = JSON.stringify(filters);
+
+ if (isOpen && filters && filters.length > 0 && currentFiltersString !== lastAppliedFilters.current) {
+ setIsInitializing(true);
+
+ const formValues = { ...form.getValues() };
+ let formUpdated = false;
+
+ filters.forEach(filter => {
+ if (filter.id === "techVendorType" && Array.isArray(filter.value)) {
+ formValues.techVendorType = filter.value;
+ formUpdated = true;
+ } else if (filter.id === "workTypes" && Array.isArray(filter.value)) {
+ formValues.workTypes = filter.value;
+ formUpdated = true;
+ } else if (filter.id in formValues) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (formValues as any)[filter.id] = filter.value;
+ formUpdated = true;
+ }
+ });
+
+ if (formUpdated) {
+ form.reset(formValues);
+ lastAppliedFilters.current = currentFiltersString;
+ }
+
+ setIsInitializing(false);
+ }
+ }, [filters, isOpen, form])
+
+ // 현재 적용된 필터 카운트
+ const getActiveFilterCount = () => {
+ return filters?.length || 0
+ }
+
+ // 폼 제출 핸들러
+ async function onSubmit(data: FilterFormValues) {
+ if (isInitializing) return;
+
+ startTransition(async () => {
+ try {
+ const newFilters = []
+
+ if (data.vendorCode?.trim()) {
+ newFilters.push({
+ id: "vendorCode",
+ value: data.vendorCode.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ })
+ }
+
+ if (data.vendorName?.trim()) {
+ newFilters.push({
+ id: "vendorName",
+ value: data.vendorName.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ })
+ }
+
+ if (data.country?.trim()) {
+ newFilters.push({
+ id: "country",
+ value: data.country.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ })
+ }
+
+ if (data.status?.trim()) {
+ newFilters.push({
+ id: "status",
+ value: data.status.trim(),
+ type: "select",
+ operator: "eq",
+ rowId: generateId()
+ })
+ }
+
+ if (data.techVendorType && data.techVendorType.length > 0) {
+ newFilters.push({
+ id: "techVendorType",
+ value: data.techVendorType,
+ type: "multi-select",
+ operator: "eq",
+ rowId: generateId()
+ })
+ }
+
+ if (data.workTypes && data.workTypes.length > 0) {
+ newFilters.push({
+ id: "workTypes",
+ value: data.workTypes,
+ type: "multi-select",
+ operator: "eq",
+ rowId: generateId()
+ })
+ }
+
+ // URL 수동 업데이트
+ const currentUrl = new URL(window.location.href);
+ const params = new URLSearchParams(currentUrl.search);
+
+ // 기존 필터 관련 파라미터 제거
+ params.delete('filters');
+ params.delete('joinOperator');
+ params.delete('page');
+
+ // 새로운 필터 추가
+ if (newFilters.length > 0) {
+ params.set('filters', JSON.stringify(newFilters));
+ params.set('joinOperator', joinOperator);
+ }
+
+ // 페이지를 1로 설정
+ params.set('page', '1');
+
+ const newUrl = `${currentUrl.pathname}?${params.toString()}`;
+
+ // 페이지 완전 새로고침
+ window.location.href = newUrl;
+
+ // 마지막 적용된 필터 업데이트
+ lastAppliedFilters.current = JSON.stringify(newFilters);
+
+ if (onSearch) {
+ onSearch();
+ }
+ } catch (error) {
+ console.error("벤더 필터 적용 오류:", error);
+ }
+ })
+ }
+
+ // 필터 초기화 핸들러
+ async function handleReset() {
+ try {
+ setIsInitializing(true);
+
+ form.reset({
+ vendorCode: "",
+ vendorName: "",
+ country: "",
+ status: "",
+ techVendorType: [],
+ workTypes: [],
+ });
+
+ const currentUrl = new URL(window.location.href);
+ const params = new URLSearchParams(currentUrl.search);
+
+ params.delete('filters');
+ params.delete('joinOperator');
+ params.set('page', '1');
+
+ const newUrl = `${currentUrl.pathname}?${params.toString()}`;
+ window.location.href = newUrl;
+
+ lastAppliedFilters.current = "";
+ setIsInitializing(false);
+ } catch (error) {
+ console.error("벤더 필터 초기화 오류:", error);
+ setIsInitializing(false);
+ }
+ }
+
+ if (!isOpen) {
+ return null;
+ }
+
+ return (
+ <div className="flex flex-col h-full max-h-full bg-[#F5F7FB] px-6 sm:px-8" style={{backgroundColor:"#F5F7FB", paddingLeft:"2rem", paddingRight:"2rem"}}>
+ {/* Filter Panel Header */}
+ <div className="flex items-center justify-between px-6 min-h-[60px] shrink-0">
+ <h3 className="text-lg font-semibold whitespace-nowrap">벤더 검색 필터</h3>
+ <div className="flex items-center gap-2">
+ {getActiveFilterCount() > 0 && (
+ <Badge variant="secondary" className="px-2 py-1">
+ {getActiveFilterCount()}개 필터 적용됨
+ </Badge>
+ )}
+ </div>
+ </div>
+
+ {/* Join Operator Selection */}
+ <div className="px-6 shrink-0">
+ <label className="text-sm font-medium">조건 결합 방식</label>
+ <Select
+ value={joinOperator}
+ onValueChange={(value: "and" | "or") => setJoinOperator(value)}
+ disabled={isInitializing}
+ >
+ <SelectTrigger className="h-8 w-[180px] mt-2 bg-white">
+ <SelectValue placeholder="조건 결합 방식" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="and">모든 조건 충족 (AND)</SelectItem>
+ <SelectItem value="or">하나라도 충족 (OR)</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col h-full min-h-0">
+ {/* Scrollable content area */}
+ <div className="flex-1 min-h-0 overflow-y-auto px-6 pb-4">
+ <div className="space-y-4 pt-2">
+ {/* 벤더코드 */}
+ <FormField
+ control={form.control}
+ name="vendorCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>벤더코드</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder="벤더코드 입력"
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("vendorCode", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 벤더명 */}
+ <FormField
+ control={form.control}
+ name="vendorName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>벤더명</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder="벤더명 입력"
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("vendorName", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 국가 */}
+ <FormField
+ control={form.control}
+ name="country"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>국가</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder="국가 입력"
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("country", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 상태 */}
+ <FormField
+ control={form.control}
+ name="status"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>상태</FormLabel>
+ <Select
+ value={field.value}
+ onValueChange={field.onChange}
+ disabled={isInitializing}
+ >
+ <FormControl>
+ <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
+ <div className="flex justify-between w-full">
+ <SelectValue placeholder="상태 선택" />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="h-4 w-4 -mr-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("status", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {statusOptions.map(option => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 벤더 타입 */}
+ <FormField
+ control={form.control}
+ name="techVendorType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>벤더 타입</FormLabel>
+ <div className="space-y-2">
+ {vendorTypeOptions.map((option) => (
+ <div key={option.value} className="flex items-center space-x-2">
+ <Checkbox
+ id={`vendorType-${option.value}`}
+ checked={field.value?.includes(option.value) || false}
+ onCheckedChange={(checked) => {
+ const updatedValue = checked
+ ? [...(field.value || []), option.value]
+ : (field.value || []).filter((value) => value !== option.value);
+ field.onChange(updatedValue);
+ }}
+ disabled={isInitializing}
+ />
+ <label
+ htmlFor={`vendorType-${option.value}`}
+ className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+ >
+ {option.label}
+ </label>
+ </div>
+ ))}
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 공종 */}
+ <FormField
+ control={form.control}
+ name="workTypes"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>공종</FormLabel>
+ <div className="grid grid-cols-2 gap-2">
+ {workTypeOptions.map((option) => (
+ <div key={option.value} className="flex items-center space-x-2">
+ <Checkbox
+ id={`workType-${option.value}`}
+ checked={field.value?.includes(option.value) || false}
+ onCheckedChange={(checked) => {
+ const updatedValue = checked
+ ? [...(field.value || []), option.value]
+ : (field.value || []).filter((value) => value !== option.value);
+ field.onChange(updatedValue);
+ }}
+ disabled={isInitializing}
+ />
+ <label
+ htmlFor={`workType-${option.value}`}
+ className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+ >
+ {option.label}
+ </label>
+ </div>
+ ))}
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+
+ {/* Action buttons */}
+ <div className="shrink-0 border-t bg-white px-6 py-4">
+ <div className="flex gap-2">
+ <Button
+ type="submit"
+ className="flex-1"
+ disabled={isPending || isInitializing || isLoading}
+ >
+ {isPending ? (
+ <>
+ <Search className="mr-2 h-4 w-4 animate-spin" />
+ 검색 중...
+ </>
+ ) : (
+ <>
+ <Search className="mr-2 h-4 w-4" />
+ 검색
+ </>
+ )}
+ </Button>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleReset}
+ disabled={isPending || isInitializing || isLoading}
+ >
+ 초기화
+ </Button>
+ </div>
+ </div>
+ </form>
+ </Form>
+ </div>
+ )
+}
\ 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 052794ce..5184e3f3 100644 --- a/lib/tech-vendors/table/tech-vendors-table-columns.tsx +++ b/lib/tech-vendors/table/tech-vendors-table-columns.tsx @@ -1,414 +1,376 @@ -"use client" - -import * as React from "react" -import { type DataTableRowAction } from "@/types/table" -import { type ColumnDef } from "@tanstack/react-table" -import { Ellipsis, Package } from "lucide-react" -import { toast } from "sonner" - -import { getErrorMessage } from "@/lib/handle-error" -import { formatDate } from "@/lib/utils" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { Checkbox } from "@/components/ui/checkbox" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { useRouter } from "next/navigation" - -import { TechVendor, techVendors } from "@/db/schema/techVendors" -import { modifyTechVendor } from "../service" -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import { techVendorColumnsConfig } from "@/config/techVendorColumnsConfig" -import { Separator } from "@/components/ui/separator" -import { getVendorStatusIcon } from "../utils" - -// 타입 정의 추가 -type StatusType = (typeof techVendors.status.enumValues)[number]; -type BadgeVariantType = "default" | "secondary" | "destructive" | "outline"; -type StatusConfig = { - variant: BadgeVariantType; - className: string; -}; -type StatusDisplayMap = { - [key in StatusType]: string; -}; - -type NextRouter = ReturnType<typeof useRouter>; - -interface GetColumnsProps { - setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TechVendor> | null>>; - router: NextRouter; - openItemsDialog: (vendor: TechVendor) => void; -} - - - - -/** - * tanstack table 컬럼 정의 (중첩 헤더 버전) - */ -export function getColumns({ setRowAction, router, openItemsDialog }: GetColumnsProps): ColumnDef<TechVendor>[] { - // ---------------------------------------------------------------- - // 1) select 컬럼 (체크박스) - // ---------------------------------------------------------------- - const selectColumn: ColumnDef<TechVendor> = { - id: "select", - header: ({ table }) => ( - <Checkbox - checked={ - table.getIsAllPageRowsSelected() || - (table.getIsSomePageRowsSelected() && "indeterminate") - } - onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} - aria-label="Select all" - className="translate-y-0.5" - /> - ), - cell: ({ row }) => ( - <Checkbox - checked={row.getIsSelected()} - onCheckedChange={(value) => row.toggleSelected(!!value)} - aria-label="Select row" - className="translate-y-0.5" - /> - ), - size: 40, - enableSorting: false, - enableHiding: false, - } - - // ---------------------------------------------------------------- - // 2) actions 컬럼 (Dropdown 메뉴) - // ---------------------------------------------------------------- - const actionsColumn: ColumnDef<TechVendor> = { - id: "actions", - enableHiding: false, - cell: function Cell({ row }) { - const [isUpdatePending, startUpdateTransition] = React.useTransition() - - return ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - aria-label="Open menu" - variant="ghost" - className="flex size-8 p-0 data-[state=open]:bg-muted" - > - <Ellipsis className="size-4" aria-hidden="true" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="w-56"> - <DropdownMenuItem - onSelect={() => setRowAction({ row, type: "update" })} - > - 레코드 편집 - </DropdownMenuItem> - - <DropdownMenuItem - onSelect={() => { - // 1) 만약 rowAction을 열고 싶다면 - // setRowAction({ row, type: "update" }) - - // 2) 자세히 보기 페이지로 클라이언트 라우팅 - router.push(`/evcp/tech-vendors/${row.original.id}/info`); - }} - > - 상세보기 - </DropdownMenuItem> - <DropdownMenuItem - onSelect={() => { - // 새창으로 열기 위해 window.open() 사용 - window.open(`/evcp/tech-vendors/${row.original.id}/info`, '_blank'); - }} - > - 상세보기(새창) - </DropdownMenuItem> - - <Separator /> - <DropdownMenuSub> - <DropdownMenuSubTrigger>Status</DropdownMenuSubTrigger> - <DropdownMenuSubContent> - <DropdownMenuRadioGroup - value={row.original.status} - onValueChange={(value) => { - startUpdateTransition(() => { - toast.promise( - modifyTechVendor({ - id: String(row.original.id), - status: value as TechVendor["status"], - vendorName: row.original.vendorName, // Required field from UpdateVendorSchema - }), - { - loading: "Updating...", - success: "Label updated", - error: (err) => getErrorMessage(err), - } - ) - }) - }} - > - {techVendors.status.enumValues.map((status) => ( - <DropdownMenuRadioItem - key={status} - value={status} - className="capitalize" - disabled={isUpdatePending} - > - {status} - </DropdownMenuRadioItem> - ))} - </DropdownMenuRadioGroup> - </DropdownMenuSubContent> - </DropdownMenuSub> - - - </DropdownMenuContent> - </DropdownMenu> - ) - }, - size: 40, - } - - // ---------------------------------------------------------------- - // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 - // ---------------------------------------------------------------- - // 3-1) groupMap: { [groupName]: ColumnDef<TechVendor>[] } - const groupMap: Record<string, ColumnDef<TechVendor>[]> = {} - - techVendorColumnsConfig.forEach((cfg) => { - // 만약 group가 없으면 "_noGroup" 처리 - const groupName = cfg.group || "_noGroup" - - if (!groupMap[groupName]) { - groupMap[groupName] = [] - } - - // child column 정의 - const childCol: ColumnDef<TechVendor> = { - accessorKey: cfg.id, - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={cfg.label} /> - ), - meta: { - excelHeader: cfg.excelHeader, - group: cfg.group, - type: cfg.type, - }, - cell: ({ row, cell }) => { - // Status 컬럼 렌더링 개선 - 아이콘과 더 선명한 배경색 사용 - if (cfg.id === "status") { - const statusVal = row.original.status; - if (!statusVal) return null; - - // Status badge variant mapping - 더 뚜렷한 색상으로 변경 - const getStatusConfig = (status: StatusType): StatusConfig & { iconColor: string } => { - switch (status) { - case "ACTIVE": - return { - variant: "default", - className: "bg-emerald-100 text-emerald-800 border-emerald-300 font-semibold", - iconColor: "text-emerald-600" - }; - case "INACTIVE": - return { - variant: "default", - className: "bg-gray-100 text-gray-800 border-gray-300", - iconColor: "text-gray-600" - }; - - case "PENDING_INVITE": - return { - variant: "default", - className: "bg-blue-100 text-blue-800 border-blue-300", - iconColor: "text-blue-600" - }; - case "INVITED": - return { - variant: "default", - className: "bg-green-100 text-green-800 border-green-300", - iconColor: "text-green-600" - }; - case "QUOTE_COMPARISON": - return { - variant: "default", - className: "bg-purple-100 text-purple-800 border-purple-300", - iconColor: "text-purple-600" - }; - case "BLACKLISTED": - return { - variant: "destructive", - className: "bg-slate-800 text-white border-slate-900", - iconColor: "text-white" - }; - default: - return { - variant: "default", - className: "bg-gray-100 text-gray-800 border-gray-300", - iconColor: "text-gray-600" - }; - } - }; - - // 상태 표시 텍스트 - const getStatusDisplay = (status: StatusType): string => { - const statusMap: StatusDisplayMap = { - "ACTIVE": "활성 상태", - "INACTIVE": "비활성 상태", - "BLACKLISTED": "거래 금지", - "PENDING_INVITE": "초대 대기", - "INVITED": "초대 완료", - "QUOTE_COMPARISON": "견적 비교" - }; - - return statusMap[status] || status; - }; - - const statusConfig = getStatusConfig(statusVal); - const displayText = getStatusDisplay(statusVal); - const StatusIcon = getVendorStatusIcon(statusVal); - - return ( - <div className="flex items-center gap-2"> - <Badge - variant={statusConfig.variant} - className={statusConfig.className} - > - <StatusIcon className={`mr-1 h-3.5 w-3.5 ${statusConfig.iconColor}`} /> - {displayText} - </Badge> - </div> - ); - } - // TechVendorType 컬럼을 badge로 표시 - if (cfg.id === "techVendorType") { - const techVendorType = row.original.techVendorType as string | null | undefined; - - // 벤더 타입 파싱 개선 - null/undefined 안전 처리 - let types: string[] = []; - if (!techVendorType) { - types = []; - } else if (techVendorType.startsWith('[') && techVendorType.endsWith(']')) { - // JSON 배열 형태 - try { - const parsed = JSON.parse(techVendorType); - types = Array.isArray(parsed) ? parsed.filter(Boolean) : [techVendorType]; - } catch { - types = [techVendorType]; - } - } else if (techVendorType.includes(',')) { - // 콤마로 구분된 문자열 - types = techVendorType.split(',').map(t => t.trim()).filter(Boolean); - } else { - // 단일 문자열 - types = [techVendorType.trim()].filter(Boolean); - } - - // 벤더 타입 정렬 - 조선 > 해양top > 해양hull 순 - const typeOrder = ["조선", "해양top", "해양hull"]; - types.sort((a, b) => { - const indexA = typeOrder.indexOf(a); - const indexB = typeOrder.indexOf(b); - - // 정의된 순서에 있는 경우 우선순위 적용 - if (indexA !== -1 && indexB !== -1) { - return indexA - indexB; - } - return a.localeCompare(b); - }); - - return ( - <div className="flex flex-wrap gap-1"> - {types.length > 0 ? types.map((type, index) => ( - <Badge key={`${type}-${index}`} variant="secondary" className="text-xs"> - {type} - </Badge> - )) : ( - <span className="text-muted-foreground">-</span> - )} - </div> - ); - } - - // 날짜 컬럼 포맷팅 - if (cfg.type === "date" && cell.getValue()) { - return formatDate(cell.getValue() as Date); - } - - return cell.getValue(); - }, - }; - - groupMap[groupName].push(childCol); - }); - - // 3-2) groupMap -> columns (그룹별 -> 중첩 헤더 ColumnDef[] 배열 변환) - const columns: ColumnDef<TechVendor>[] = [ - selectColumn, // 1) 체크박스 - ]; - - // 3-3) 그룹이 있는 컬럼들은 중첩 헤더로, 없는 것들은 그냥 컬럼으로 - Object.entries(groupMap).forEach(([groupName, childColumns]) => { - if (groupName === "_noGroup") { - // 그룹이 없는 컬럼들은 그냥 추가 - columns.push(...childColumns); - } else { - // 그룹이 있는 컬럼들은 헤더 아래 자식으로 중첩 - columns.push({ - id: groupName, - header: groupName, // 그룹명을 헤더로 - columns: childColumns, // 그룹에 속한 컬럼들을 자식으로 - }); - } - }); - - // Possible Items 컬럼 추가 (액션 컬럼 직전에) - const possibleItemsColumn: ColumnDef<TechVendor> = { - id: "possibleItems", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="자재 그룹" /> - ), - cell: ({ row }) => { - const vendor = row.original; - - const handleClick = () => { - openItemsDialog(vendor); - }; - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label="View possible items" - > - <Package className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - <span className="sr-only"> - Possible Items 보기 - </span> - </Button> - ); - }, - enableSorting: false, - enableResizing: false, - size: 80, - meta: { - excelHeader: "Possible Items" - }, - }; - - columns.push(possibleItemsColumn); - columns.push(actionsColumn); // 마지막에 액션 컬럼 추가 - - return columns; +"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis, Package } from "lucide-react"
+import { toast } from "sonner"
+
+import { getErrorMessage } from "@/lib/handle-error"
+import { formatDate } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { useRouter } from "next/navigation"
+
+import { TechVendor, techVendors } from "@/db/schema/techVendors"
+import { modifyTechVendor } from "../service"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { techVendorColumnsConfig } from "@/config/techVendorColumnsConfig"
+import { Separator } from "@/components/ui/separator"
+import { getVendorStatusIcon } from "../utils"
+
+// 타입 정의 추가
+type StatusType = (typeof techVendors.status.enumValues)[number];
+type BadgeVariantType = "default" | "secondary" | "destructive" | "outline";
+type StatusConfig = {
+ variant: BadgeVariantType;
+ className: string;
+};
+type StatusDisplayMap = {
+ [key in StatusType]: string;
+};
+
+type NextRouter = ReturnType<typeof useRouter>;
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TechVendor> | null>>;
+ router: NextRouter;
+}
+
+
+
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef<TechVendor>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<TechVendor> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) actions 컬럼 (Dropdown 메뉴)
+ // ----------------------------------------------------------------
+ const actionsColumn: ColumnDef<TechVendor> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-56">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "update" })}
+ >
+ 레코드 편집
+ </DropdownMenuItem>
+
+ <DropdownMenuItem
+ onSelect={() => {
+ // 1) 만약 rowAction을 열고 싶다면
+ // setRowAction({ row, type: "update" })
+
+ // 2) 자세히 보기 페이지로 클라이언트 라우팅
+ router.push(`/evcp/tech-vendors/${row.original.id}/info`);
+ }}
+ >
+ 상세보기
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onSelect={() => {
+ // 새창으로 열기 위해 window.open() 사용
+ window.open(`/evcp/tech-vendors/${row.original.id}/info`, '_blank');
+ }}
+ >
+ 상세보기(새창)
+ </DropdownMenuItem>
+
+ <Separator />
+ <DropdownMenuSub>
+ <DropdownMenuSubTrigger>Status</DropdownMenuSubTrigger>
+ <DropdownMenuSubContent>
+ <DropdownMenuRadioGroup
+ value={row.original.status}
+ onValueChange={(value) => {
+ startUpdateTransition(() => {
+ toast.promise(
+ modifyTechVendor({
+ id: String(row.original.id),
+ status: value as TechVendor["status"],
+ vendorName: row.original.vendorName, // Required field from UpdateVendorSchema
+ }),
+ {
+ loading: "Updating...",
+ success: "Label updated",
+ error: (err) => getErrorMessage(err),
+ }
+ )
+ })
+ }}
+ >
+ {techVendors.status.enumValues.map((status) => (
+ <DropdownMenuRadioItem
+ key={status}
+ value={status}
+ className="capitalize"
+ disabled={isUpdatePending}
+ >
+ {status}
+ </DropdownMenuRadioItem>
+ ))}
+ </DropdownMenuRadioGroup>
+ </DropdownMenuSubContent>
+ </DropdownMenuSub>
+
+
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ }
+
+ // ----------------------------------------------------------------
+ // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // ----------------------------------------------------------------
+ // 3-1) groupMap: { [groupName]: ColumnDef<TechVendor>[] }
+ const groupMap: Record<string, ColumnDef<TechVendor>[]> = {}
+
+ techVendorColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<TechVendor> = {
+ accessorKey: cfg.id,
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
+ ),
+ meta: {
+ excelHeader: cfg.excelHeader,
+ group: cfg.group,
+ type: cfg.type,
+ },
+ cell: ({ row, cell }) => {
+ // Status 컬럼 렌더링 개선 - 아이콘과 더 선명한 배경색 사용
+ if (cfg.id === "status") {
+ const statusVal = row.original.status;
+ if (!statusVal) return null;
+
+ // Status badge variant mapping - 더 뚜렷한 색상으로 변경
+ const getStatusConfig = (status: StatusType): StatusConfig & { iconColor: string } => {
+ switch (status) {
+ case "ACTIVE":
+ return {
+ variant: "default",
+ className: "bg-emerald-100 text-emerald-800 border-emerald-300 font-semibold",
+ iconColor: "text-emerald-600"
+ };
+ case "INACTIVE":
+ return {
+ variant: "default",
+ className: "bg-gray-100 text-gray-800 border-gray-300",
+ iconColor: "text-gray-600"
+ };
+
+ case "PENDING_INVITE":
+ return {
+ variant: "default",
+ className: "bg-blue-100 text-blue-800 border-blue-300",
+ iconColor: "text-blue-600"
+ };
+ case "INVITED":
+ return {
+ variant: "default",
+ className: "bg-green-100 text-green-800 border-green-300",
+ iconColor: "text-green-600"
+ };
+ case "QUOTE_COMPARISON":
+ return {
+ variant: "default",
+ className: "bg-purple-100 text-purple-800 border-purple-300",
+ iconColor: "text-purple-600"
+ };
+ case "BLACKLISTED":
+ return {
+ variant: "destructive",
+ className: "bg-slate-800 text-white border-slate-900",
+ iconColor: "text-white"
+ };
+ default:
+ return {
+ variant: "default",
+ className: "bg-gray-100 text-gray-800 border-gray-300",
+ iconColor: "text-gray-600"
+ };
+ }
+ };
+
+ // 상태 표시 텍스트
+ const getStatusDisplay = (status: StatusType): string => {
+ const statusMap: StatusDisplayMap = {
+ "ACTIVE": "활성 상태",
+ "INACTIVE": "비활성 상태",
+ "BLACKLISTED": "거래 금지",
+ "PENDING_INVITE": "초대 대기",
+ "INVITED": "초대 완료",
+ "QUOTE_COMPARISON": "견적 비교"
+ };
+
+ return statusMap[status] || status;
+ };
+
+ const statusConfig = getStatusConfig(statusVal);
+ const displayText = getStatusDisplay(statusVal);
+ const StatusIcon = getVendorStatusIcon(statusVal);
+
+ return (
+ <div className="flex items-center gap-2">
+ <Badge
+ variant={statusConfig.variant}
+ className={statusConfig.className}
+ >
+ <StatusIcon className={`mr-1 h-3.5 w-3.5 ${statusConfig.iconColor}`} />
+ {displayText}
+ </Badge>
+ </div>
+ );
+ }
+ // TechVendorType 컬럼을 badge로 표시
+ if (cfg.id === "techVendorType") {
+ const techVendorType = row.original.techVendorType as string | null | undefined;
+
+ // 벤더 타입 파싱 개선 - null/undefined 안전 처리
+ let types: string[] = [];
+ if (!techVendorType) {
+ types = [];
+ } else if (techVendorType.startsWith('[') && techVendorType.endsWith(']')) {
+ // JSON 배열 형태
+ try {
+ const parsed = JSON.parse(techVendorType);
+ types = Array.isArray(parsed) ? parsed.filter(Boolean) : [techVendorType];
+ } catch {
+ types = [techVendorType];
+ }
+ } else if (techVendorType.includes(',')) {
+ // 콤마로 구분된 문자열
+ types = techVendorType.split(',').map(t => t.trim()).filter(Boolean);
+ } else {
+ // 단일 문자열
+ types = [techVendorType.trim()].filter(Boolean);
+ }
+
+ // 벤더 타입 정렬 - 조선 > 해양top > 해양hull 순
+ const typeOrder = ["조선", "해양top", "해양hull"];
+ types.sort((a, b) => {
+ const indexA = typeOrder.indexOf(a);
+ const indexB = typeOrder.indexOf(b);
+
+ // 정의된 순서에 있는 경우 우선순위 적용
+ if (indexA !== -1 && indexB !== -1) {
+ return indexA - indexB;
+ }
+ return a.localeCompare(b);
+ });
+
+ return (
+ <div className="flex flex-wrap gap-1">
+ {types.length > 0 ? types.map((type, index) => (
+ <Badge key={`${type}-${index}`} variant="secondary" className="text-xs">
+ {type}
+ </Badge>
+ )) : (
+ <span className="text-muted-foreground">-</span>
+ )}
+ </div>
+ );
+ }
+
+ // 날짜 컬럼 포맷팅
+ if (cfg.type === "date" && cell.getValue()) {
+ return formatDate(cell.getValue() as Date);
+ }
+
+ return cell.getValue();
+ },
+ };
+
+ groupMap[groupName].push(childCol);
+ });
+
+ // 3-2) groupMap -> columns (그룹별 -> 중첩 헤더 ColumnDef[] 배열 변환)
+ const columns: ColumnDef<TechVendor>[] = [
+ selectColumn, // 1) 체크박스
+ ];
+
+ // 3-3) 그룹이 있는 컬럼들은 중첩 헤더로, 없는 것들은 그냥 컬럼으로
+ Object.entries(groupMap).forEach(([groupName, childColumns]) => {
+ if (groupName === "_noGroup") {
+ // 그룹이 없는 컬럼들은 그냥 추가
+ columns.push(...childColumns);
+ } else {
+ // 그룹이 있는 컬럼들은 헤더 아래 자식으로 중첩
+ columns.push({
+ id: groupName,
+ header: groupName, // 그룹명을 헤더로
+ columns: childColumns, // 그룹에 속한 컬럼들을 자식으로
+ });
+ }
+ });
+
+ columns.push(actionsColumn); // 마지막에 액션 컬럼 추가
+
+ return columns;
}
\ No newline at end of file diff --git a/lib/tech-vendors/table/tech-vendors-table-floating-bar.tsx b/lib/tech-vendors/table/tech-vendors-table-floating-bar.tsx deleted file mode 100644 index 2cc83105..00000000 --- a/lib/tech-vendors/table/tech-vendors-table-floating-bar.tsx +++ /dev/null @@ -1,240 +0,0 @@ -"use client" - -import * as React from "react" -import { SelectTrigger } from "@radix-ui/react-select" -import { type Table } from "@tanstack/react-table" -import { - ArrowUp, - CheckCircle2, - Download, - Loader, - Trash2, - X, -} from "lucide-react" -import { toast } from "sonner" - -import { exportTableToExcel } from "@/lib/export" -import { Button } from "@/components/ui/button" -import { Portal } from "@/components/ui/portal" -import { - Select, - SelectContent, - SelectGroup, - SelectItem, -} from "@/components/ui/select" -import { Separator } from "@/components/ui/separator" -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip" -import { Kbd } from "@/components/kbd" - -import { ActionConfirmDialog } from "@/components/ui/action-dialog" -import { Vendor, vendors } from "@/db/schema/vendors" -import { modifyTechVendors } from "../service" -import { TechVendor } from "@/db/schema" - -interface VendorsTableFloatingBarProps { - table: Table<Vendor> -} - - -export function VendorsTableFloatingBar({ table }: VendorsTableFloatingBarProps) { - const rows = table.getFilteredSelectedRowModel().rows - - const [isPending, startTransition] = React.useTransition() - const [action, setAction] = React.useState< - "update-status" | "export" | "delete" - >() - // Clear selection on Escape key press - React.useEffect(() => { - function handleKeyDown(event: KeyboardEvent) { - if (event.key === "Escape") { - table.toggleAllRowsSelected(false) - } - } - - window.addEventListener("keydown", handleKeyDown) - return () => window.removeEventListener("keydown", handleKeyDown) - }, [table]) - - - - // 공용 confirm dialog state - const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false) - const [confirmProps, setConfirmProps] = React.useState<{ - title: string - description?: string - onConfirm: () => Promise<void> | void - }>({ - title: "", - description: "", - onConfirm: () => { }, - }) - - - // 2) - function handleSelectStatus(newStatus: Vendor["status"]) { - setAction("update-status") - - setConfirmProps({ - title: `Update ${rows.length} vendor${rows.length > 1 ? "s" : ""} with status: ${newStatus}?`, - description: "This action will override their current status.", - onConfirm: async () => { - startTransition(async () => { - const { error } = await modifyTechVendors({ - ids: rows.map((row) => String(row.original.id)), - status: newStatus as TechVendor["status"], - }) - if (error) { - toast.error(error) - return - } - toast.success("Vendors updated") - setConfirmDialogOpen(false) - }) - }, - }) - setConfirmDialogOpen(true) - } - - - return ( - <Portal > - <div className="fixed inset-x-0 bottom-10 z-50 mx-auto w-fit px-2.5" style={{ bottom: '1.5rem' }}> - <div className="w-full overflow-x-auto"> - <div className="mx-auto flex w-fit items-center gap-2 rounded-md border bg-background p-2 text-foreground shadow"> - <div className="flex h-7 items-center rounded-md border border-dashed pl-2.5 pr-1"> - <span className="whitespace-nowrap text-xs"> - {rows.length} selected - </span> - <Separator orientation="vertical" className="ml-2 mr-1" /> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="ghost" - size="icon" - className="size-5 hover:border" - onClick={() => table.toggleAllRowsSelected(false)} - > - <X className="size-3.5 shrink-0" aria-hidden="true" /> - </Button> - </TooltipTrigger> - <TooltipContent className="flex items-center border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900"> - <p className="mr-2">Clear selection</p> - <Kbd abbrTitle="Escape" variant="outline"> - Esc - </Kbd> - </TooltipContent> - </Tooltip> - </div> - <Separator orientation="vertical" className="hidden h-5 sm:block" /> - <div className="flex items-center gap-1.5"> - <Select - onValueChange={(value: Vendor["status"]) => { - handleSelectStatus(value) - }} - > - <Tooltip> - <SelectTrigger asChild> - <TooltipTrigger asChild> - <Button - variant="secondary" - size="icon" - className="size-7 border data-[state=open]:bg-accent data-[state=open]:text-accent-foreground" - disabled={isPending} - > - {isPending && action === "update-status" ? ( - <Loader - className="size-3.5 animate-spin" - aria-hidden="true" - /> - ) : ( - <CheckCircle2 - className="size-3.5" - aria-hidden="true" - /> - )} - </Button> - </TooltipTrigger> - </SelectTrigger> - <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> - <p>Update status</p> - </TooltipContent> - </Tooltip> - <SelectContent align="center"> - <SelectGroup> - {vendors.status.enumValues.map((status) => ( - <SelectItem - key={status} - value={status} - className="capitalize" - > - {status} - </SelectItem> - ))} - </SelectGroup> - </SelectContent> - </Select> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="secondary" - size="icon" - className="size-7 border" - onClick={() => { - setAction("export") - - startTransition(() => { - exportTableToExcel(table, { - excludeColumns: ["select", "actions"], - onlySelected: true, - }) - }) - }} - disabled={isPending} - > - {isPending && action === "export" ? ( - <Loader - className="size-3.5 animate-spin" - aria-hidden="true" - /> - ) : ( - <Download className="size-3.5" aria-hidden="true" /> - )} - </Button> - </TooltipTrigger> - <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> - <p>Export vendors</p> - </TooltipContent> - </Tooltip> - - </div> - </div> - </div> - </div> - - - {/* 공용 Confirm Dialog */} - <ActionConfirmDialog - open={confirmDialogOpen} - onOpenChange={setConfirmDialogOpen} - title={confirmProps.title} - description={confirmProps.description} - onConfirm={confirmProps.onConfirm} - isLoading={isPending && (action === "delete" || action === "update-status")} - confirmLabel={ - action === "delete" - ? "Delete" - : action === "update-status" - ? "Update" - : "Confirm" - } - confirmVariant={ - action === "delete" ? "destructive" : "default" - } - /> - </Portal> - ) -} 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 ac7ee184..c5380140 100644 --- a/lib/tech-vendors/table/tech-vendors-table-toolbar-actions.tsx +++ b/lib/tech-vendors/table/tech-vendors-table-toolbar-actions.tsx @@ -1,197 +1,201 @@ -"use client" - -import * as React from "react" -import { type Table } from "@tanstack/react-table" -import { Download, FileSpreadsheet, FileText } from "lucide-react" -import { toast } from "sonner" - -import { exportTableToExcel } from "@/lib/export" -import { Button } from "@/components/ui/button" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" - -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" -import { InviteTechVendorDialog } from "./invite-tech-vendor-dialog" - -interface TechVendorsTableToolbarActionsProps { - table: Table<TechVendor> - onRefresh?: () => void -} - -export function TechVendorsTableToolbarActions({ table, onRefresh }: TechVendorsTableToolbarActionsProps) { - const [isExporting, setIsExporting] = React.useState(false); - - // 선택된 모든 벤더 가져오기 - const selectedVendors = React.useMemo(() => { - return table - .getFilteredSelectedRowModel() - .rows - .map(row => row.original); - }, [table.getFilteredSelectedRowModel().rows]); - - // 초대 가능한 벤더들 (PENDING_INVITE 상태 + 이메일 있음) - const invitableVendors = React.useMemo(() => { - return selectedVendors.filter(vendor => - vendor.status === "PENDING_INVITE" && vendor.email - ); - }, [selectedVendors]); - - // 테이블의 모든 벤더 가져오기 (필터링된 결과) - const allFilteredVendors = React.useMemo(() => { - return table - .getFilteredRowModel() - .rows - .map(row => row.original); - }, [table.getFilteredRowModel().rows]); - - // 선택된 벤더 통합 내보내기 함수 실행 - const handleSelectedExport = async () => { - if (selectedVendors.length === 0) { - toast.warning("내보낼 협력업체를 선택해주세요."); - return; - } - - try { - setIsExporting(true); - toast.info(`선택된 ${selectedVendors.length}개 업체의 정보를 내보내는 중입니다...`); - await exportVendorsWithRelatedData(selectedVendors, "selected-vendors-detailed"); - toast.success(`${selectedVendors.length}개 업체 정보 내보내기가 완료되었습니다.`); - } catch (error) { - console.error("상세 정보 내보내기 오류:", error); - toast.error("상세 정보 내보내기 중 오류가 발생했습니다."); - } finally { - setIsExporting(false); - } - }; - - // 모든 벤더 통합 내보내기 함수 실행 - const handleAllFilteredExport = async () => { - if (allFilteredVendors.length === 0) { - toast.warning("내보낼 협력업체가 없습니다."); - return; - } - - try { - setIsExporting(true); - toast.info(`총 ${allFilteredVendors.length}개 업체의 정보를 내보내는 중입니다...`); - await exportVendorsWithRelatedData(allFilteredVendors, "all-vendors-detailed"); - toast.success(`${allFilteredVendors.length}개 업체 정보 내보내기가 완료되었습니다.`); - } catch (error) { - console.error("상세 정보 내보내기 오류:", error); - toast.error("상세 정보 내보내기 중 오류가 발생했습니다."); - } finally { - setIsExporting(false); - } - }; - - // 벤더 추가 성공 시 테이블 새로고침을 위한 핸들러 - const handleVendorAddSuccess = () => { - // 테이블 데이터 리프레시 - if (onRefresh) { - onRefresh(); - } else { - window.location.reload(); // 간단한 새로고침 방법 - } - }; - - return ( - <div className="flex items-center gap-2"> - {/* 초대 버튼 - 선택된 PENDING_REVIEW 벤더들이 있을 때만 표시 */} - {invitableVendors.length > 0 && ( - <InviteTechVendorDialog - vendors={invitableVendors} - onSuccess={handleVendorAddSuccess} - /> - )} - - {/* 벤더 추가 다이얼로그 추가 */} - <AddVendorDialog onSuccess={handleVendorAddSuccess} /> - - {/* Import 버튼 추가 */} - <ImportTechVendorButton - onSuccess={() => { - // 성공 시 테이블 새로고침 - toast.success("업체 정보 가져오기가 완료되었습니다."); - }} - /> - - {/* Export 드롭다운 메뉴로 변경 */} - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - variant="outline" - size="sm" - className="gap-2" - disabled={isExporting} - > - <Download className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline"> - {isExporting ? "내보내는 중..." : "Export"} - </span> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - {/* 템플릿 다운로드 추가 */} - <DropdownMenuItem - onClick={() => exportTechVendorTemplate()} - disabled={isExporting} - > - <FileText className="mr-2 size-4" /> - <span>Excel 템플릿 다운로드</span> - </DropdownMenuItem> - - <DropdownMenuSeparator /> - - {/* 기본 내보내기 - 현재 테이블에 보이는 데이터만 */} - <DropdownMenuItem - onClick={() => - exportTableToExcel(table, { - filename: "vendors", - excludeColumns: ["select", "actions"], - }) - } - disabled={isExporting} - > - <FileText className="mr-2 size-4" /> - <span>현재 테이블 데이터 내보내기</span> - </DropdownMenuItem> - - <DropdownMenuSeparator /> - - {/* 선택된 벤더만 상세 내보내기 */} - <DropdownMenuItem - onClick={handleSelectedExport} - disabled={selectedVendors.length === 0 || isExporting} - > - <FileSpreadsheet className="mr-2 size-4" /> - <span>선택한 업체 상세 정보 내보내기</span> - {selectedVendors.length > 0 && ( - <span className="ml-1 text-xs text-muted-foreground">({selectedVendors.length}개)</span> - )} - </DropdownMenuItem> - - {/* 모든 필터링된 벤더 상세 내보내기 */} - <DropdownMenuItem - onClick={handleAllFilteredExport} - disabled={allFilteredVendors.length === 0 || isExporting} - > - <Download className="mr-2 size-4" /> - <span>모든 업체 상세 정보 내보내기</span> - {allFilteredVendors.length > 0 && ( - <span className="ml-1 text-xs text-muted-foreground">({allFilteredVendors.length}개)</span> - )} - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - </div> - ) +"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, FileSpreadsheet, FileText } from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
+
+// 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"
+import { InviteTechVendorDialog } from "./invite-tech-vendor-dialog"
+
+interface TechVendorsTableToolbarActionsProps {
+ table: Table<TechVendor>
+ onRefresh?: () => void
+}
+
+export function TechVendorsTableToolbarActions({
+ table,
+ onRefresh
+}: TechVendorsTableToolbarActionsProps) {
+ const [isExporting, setIsExporting] = React.useState(false);
+
+ // 선택된 모든 벤더 가져오기
+ const selectedVendors = React.useMemo(() => {
+ return table
+ .getFilteredSelectedRowModel()
+ .rows
+ .map(row => row.original);
+ }, [table.getFilteredSelectedRowModel().rows]);
+
+ // 초대 가능한 벤더들 (PENDING_INVITE 상태 + 이메일 있음)
+ const invitableVendors = React.useMemo(() => {
+ return selectedVendors.filter(vendor =>
+ vendor.status === "PENDING_INVITE" && vendor.email
+ );
+ }, [selectedVendors]);
+
+ // // 테이블의 모든 벤더 가져오기 (필터링된 결과)
+ // const allFilteredVendors = React.useMemo(() => {
+ // return table
+ // .getFilteredRowModel()
+ // .rows
+ // .map(row => row.original);
+ // }, [table.getFilteredRowModel().rows]);
+
+ // // 선택된 벤더 통합 내보내기 함수 실행
+ // const handleSelectedExport = async () => {
+ // if (selectedVendors.length === 0) {
+ // toast.warning("내보낼 협력업체를 선택해주세요.");
+ // return;
+ // }
+
+ // try {
+ // setIsExporting(true);
+ // toast.info(`선택된 ${selectedVendors.length}개 업체의 정보를 내보내는 중입니다...`);
+ // await exportVendorsWithRelatedData(selectedVendors, "selected-vendors-detailed");
+ // toast.success(`${selectedVendors.length}개 업체 정보 내보내기가 완료되었습니다.`);
+ // } catch (error) {
+ // console.error("상세 정보 내보내기 오류:", error);
+ // toast.error("상세 정보 내보내기 중 오류가 발생했습니다.");
+ // } finally {
+ // setIsExporting(false);
+ // }
+ // };
+
+ // // 모든 벤더 통합 내보내기 함수 실행
+ // const handleAllFilteredExport = async () => {
+ // if (allFilteredVendors.length === 0) {
+ // toast.warning("내보낼 협력업체가 없습니다.");
+ // return;
+ // }
+
+ // try {
+ // setIsExporting(true);
+ // toast.info(`총 ${allFilteredVendors.length}개 업체의 정보를 내보내는 중입니다...`);
+ // await exportVendorsWithRelatedData(allFilteredVendors, "all-vendors-detailed");
+ // toast.success(`${allFilteredVendors.length}개 업체 정보 내보내기가 완료되었습니다.`);
+ // } catch (error) {
+ // console.error("상세 정보 내보내기 오류:", error);
+ // toast.error("상세 정보 내보내기 중 오류가 발생했습니다.");
+ // } finally {
+ // setIsExporting(false);
+ // }
+ // };
+
+ // 벤더 추가 성공 시 테이블 새로고침을 위한 핸들러
+ const handleVendorAddSuccess = () => {
+ // 테이블 데이터 리프레시
+ if (onRefresh) {
+ onRefresh();
+ } else {
+ window.location.reload(); // 간단한 새로고침 방법
+ }
+ };
+
+ return (
+ <div className="flex items-center gap-2">
+ {/* 초대 버튼 - 선택된 PENDING_REVIEW 벤더들이 있을 때만 표시 */}
+ {invitableVendors.length > 0 && (
+ <InviteTechVendorDialog
+ vendors={invitableVendors}
+ onSuccess={handleVendorAddSuccess}
+ />
+ )}
+
+ {/* 벤더 추가 다이얼로그 추가 */}
+ <AddVendorDialog onSuccess={handleVendorAddSuccess} />
+
+ {/* Import 버튼 추가 */}
+ <ImportTechVendorButton
+ onSuccess={() => {
+ // 성공 시 테이블 새로고침
+ toast.success("업체 정보 가져오기가 완료되었습니다.");
+ }}
+ />
+
+ {/* Export 드롭다운 메뉴로 변경 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ className="gap-2"
+ disabled={isExporting}
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">
+ {isExporting ? "내보내는 중..." : "Export"}
+ </span>
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ {/* 템플릿 다운로드 추가 */}
+ <DropdownMenuItem
+ onClick={() => exportTechVendorTemplate()}
+ disabled={isExporting}
+ >
+ <FileText className="mr-2 size-4" />
+ <span>Excel 템플릿 다운로드</span>
+ </DropdownMenuItem>
+
+ <DropdownMenuSeparator />
+
+ {/* 기본 내보내기 - 현재 테이블에 보이는 데이터만 */}
+ <DropdownMenuItem
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "vendors",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ disabled={isExporting}
+ >
+ <FileText className="mr-2 size-4" />
+ <span>현재 테이블 데이터 내보내기</span>
+ </DropdownMenuItem>
+
+ <DropdownMenuSeparator />
+
+ {/* 선택된 벤더만 상세 내보내기 */}
+ {/* <DropdownMenuItem
+ onClick={handleSelectedExport}
+ disabled={selectedVendors.length === 0 || isExporting}
+ >
+ <FileSpreadsheet className="mr-2 size-4" />
+ <span>선택한 업체 상세 정보 내보내기</span>
+ {selectedVendors.length > 0 && (
+ <span className="ml-1 text-xs text-muted-foreground">({selectedVendors.length}개)</span>
+ )}
+ </DropdownMenuItem> */}
+
+ {/* 모든 필터링된 벤더 상세 내보내기 */}
+ {/* <DropdownMenuItem
+ onClick={handleAllFilteredExport}
+ disabled={allFilteredVendors.length === 0 || isExporting}
+ >
+ <Download className="mr-2 size-4" />
+ <span>모든 업체 상세 정보 내보내기</span>
+ {allFilteredVendors.length > 0 && (
+ <span className="ml-1 text-xs text-muted-foreground">({allFilteredVendors.length}개)</span>
+ )}
+ </DropdownMenuItem> */}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ )
}
\ No newline at end of file diff --git a/lib/tech-vendors/table/tech-vendors-table.tsx b/lib/tech-vendors/table/tech-vendors-table.tsx index a8e18501..7f9625cf 100644 --- a/lib/tech-vendors/table/tech-vendors-table.tsx +++ b/lib/tech-vendors/table/tech-vendors-table.tsx @@ -1,195 +1,277 @@ -"use client" - -import * as React from "react" -import { useRouter } from "next/navigation" -import type { - DataTableAdvancedFilterField, - DataTableFilterField, - DataTableRowAction, -} from "@/types/table" - -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 "./tech-vendors-table-columns" -import { getTechVendors, getTechVendorStatusCounts } from "../service" -import { TechVendor, techVendors, TechVendorWithAttachments } from "@/db/schema/techVendors" -import { TechVendorsTableToolbarActions } from "./tech-vendors-table-toolbar-actions" -import { UpdateVendorSheet } from "./update-vendor-sheet" -import { getVendorStatusIcon } from "../utils" -import { TechVendorPossibleItemsViewDialog } from "./tech-vendor-possible-items-view-dialog" -// import { ViewTechVendorLogsDialog } from "./view-tech-vendors-logs-dialog" - -interface TechVendorsTableProps { - promises: Promise< - [ - Awaited<ReturnType<typeof getTechVendors>>, - Awaited<ReturnType<typeof getTechVendorStatusCounts>> - ] - > -} - -export function TechVendorsTable({ promises }: TechVendorsTableProps) { - // Suspense로 받아온 데이터 - const [{ data, pageCount }, statusCounts] = React.use(promises) - const [isCompact, setIsCompact] = React.useState<boolean>(false) - - const [rowAction, setRowAction] = React.useState<DataTableRowAction<TechVendor> | null>(null) - const [itemsDialogOpen, setItemsDialogOpen] = React.useState(false) - const [selectedVendorForItems, setSelectedVendorForItems] = React.useState<TechVendor | null>(null) - - // **router** 획득 - const router = useRouter() - - // openItemsDialog 함수 정의 - const openItemsDialog = React.useCallback((vendor: TechVendor) => { - setSelectedVendorForItems(vendor) - setItemsDialogOpen(true) - }, []) - - // getColumns() 호출 시, router와 openItemsDialog를 주입 - const columns = React.useMemo( - () => getColumns({ setRowAction, router, openItemsDialog }), - [setRowAction, router, openItemsDialog] - ) - - // 상태 한글 변환 유틸리티 함수 - const getStatusDisplay = (status: string): string => { - const statusMap: Record<string, string> = { - "ACTIVE": "활성 상태", - "INACTIVE": "비활성 상태", - "BLACKLISTED": "거래 금지", - "PENDING_INVITE": "초대 대기", - "INVITED": "초대 완료", - "QUOTE_COMPARISON": "견적 비교", - }; - - return statusMap[status] || status; - }; - - const filterFields: DataTableFilterField<TechVendorWithAttachments>[] = [ - { - id: "status", - label: "상태", - options: techVendors.status.enumValues.map((status) => ({ - label: getStatusDisplay(status), - value: status, - count: statusCounts[status], - })), - }, - - { id: "vendorCode", label: "업체 코드" }, - ] - - const advancedFilterFields: DataTableAdvancedFilterField<TechVendorWithAttachments>[] = [ - { id: "vendorName", label: "업체명", type: "text" }, - { id: "vendorCode", label: "업체코드", type: "text" }, - { id: "email", label: "이메일", type: "text" }, - { id: "country", label: "국가", type: "text" }, - { - id: "status", - label: "업체승인상태", - type: "multi-select", - options: techVendors.status.enumValues.map((status) => ({ - label: getStatusDisplay(status), - value: status, - count: statusCounts[status], - icon: getVendorStatusIcon(status), - })), - }, - { - id: "techVendorType", - label: "벤더 타입", - type: "multi-select", - options: [ - { label: "조선", value: "조선" }, - { label: "해양TOP", value: "해양TOP" }, - { label: "해양HULL", value: "해양HULL" }, - ], - }, - { - id: "workTypes", - label: "Work Type", - type: "multi-select", - options: [ - // 조선 workTypes - { label: "기장", value: "기장" }, - { label: "전장", value: "전장" }, - { label: "선실", value: "선실" }, - { label: "배관", value: "배관" }, - { label: "철의", value: "철의" }, - // 해양TOP workTypes - { label: "TM", value: "TM" }, - { label: "TS", value: "TS" }, - { label: "TE", value: "TE" }, - { label: "TP", value: "TP" }, - // 해양HULL workTypes - { label: "HA", value: "HA" }, - { label: "HE", value: "HE" }, - { label: "HH", value: "HH" }, - { label: "HM", value: "HM" }, - { label: "NC", value: "NC" }, - ], - }, - { id: "createdAt", label: "등록일", type: "date" }, - { id: "updatedAt", label: "수정일", type: "date" }, - ] - - const { table } = useDataTable({ - data, - columns, - pageCount, - filterFields, - enablePinning: true, - enableAdvancedFilter: true, - initialState: { - sorting: [{ id: "createdAt", desc: true }], - columnPinning: { right: ["actions", "possibleItems"] }, - }, - getRowId: (originalRow) => String(originalRow.id), - shallow: false, - clearOnDefault: true, - }) - - const handleCompactChange = React.useCallback((compact: boolean) => { - setIsCompact(compact) - }, []) - - // 테이블 새로고침 핸들러 - const handleRefresh = React.useCallback(() => { - router.refresh() - }, [router]) - - - return ( - <> - <DataTable - table={table} - compact={isCompact} - // floatingBar={<TechVendorsTableFloatingBar table={table} />} - > - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - enableCompactToggle={true} - compactStorageKey="techVendorsTableCompact" - onCompactChange={handleCompactChange} - > - <TechVendorsTableToolbarActions table={table} onRefresh={handleRefresh} /> - </DataTableAdvancedToolbar> - </DataTable> - <UpdateVendorSheet - open={rowAction?.type === "update"} - onOpenChange={() => setRowAction(null)} - vendor={rowAction?.row.original ?? null} - /> - <TechVendorPossibleItemsViewDialog - open={itemsDialogOpen} - onOpenChange={setItemsDialogOpen} - vendor={selectedVendorForItems} - /> - - </> - ) +"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+import { cn } from "@/lib/utils"
+import { PanelLeftClose, PanelLeftOpen } from "lucide-react"
+
+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 { Button } from "@/components/ui/button"
+import { getColumns } from "./tech-vendors-table-columns"
+import { getTechVendors, getTechVendorStatusCounts } from "../service"
+import { TechVendor, techVendors, TechVendorWithAttachments } from "@/db/schema/techVendors"
+import { TechVendorsTableToolbarActions } from "./tech-vendors-table-toolbar-actions"
+import { UpdateVendorSheet } from "./update-vendor-sheet"
+import { getVendorStatusIcon } from "../utils"
+import { TechVendorsFilterSheet } from "./tech-vendors-filter-sheet"
+// import { ViewTechVendorLogsDialog } from "./view-tech-vendors-logs-dialog"
+
+// 필터 패널 관련 상수
+const FILTER_PANEL_WIDTH = 400;
+const LAYOUT_HEADER_HEIGHT = 60;
+const LOCAL_HEADER_HEIGHT = 60;
+const FIXED_FILTER_HEIGHT = "calc(100vh - 120px)";
+
+interface TechVendorsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getTechVendors>>,
+ Awaited<ReturnType<typeof getTechVendorStatusCounts>>
+ ]
+ >
+ className?: string;
+ calculatedHeight?: string;
+}
+
+export function TechVendorsTable({
+ promises,
+ className,
+ calculatedHeight
+}: TechVendorsTableProps) {
+ // Suspense로 받아온 데이터
+ const [{ data, pageCount }, statusCounts] = React.use(promises)
+ const [isCompact, setIsCompact] = React.useState<boolean>(false)
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<TechVendor> | null>(null)
+
+ // 필터 패널 상태
+ const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false)
+
+ // **router** 획득
+ const router = useRouter()
+
+ // getColumns() 호출 시, router를 주입
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction, router }),
+ [setRowAction, router]
+ )
+
+ // 상태 한글 변환 유틸리티 함수
+ const getStatusDisplay = (status: string): string => {
+ const statusMap: Record<string, string> = {
+ "ACTIVE": "활성 상태",
+ "INACTIVE": "비활성 상태",
+ "BLACKLISTED": "거래 금지",
+ "PENDING_INVITE": "초대 대기",
+ "INVITED": "초대 완료",
+ "QUOTE_COMPARISON": "견적 비교",
+ };
+
+ return statusMap[status] || status;
+ };
+
+ const filterFields: DataTableFilterField<TechVendorWithAttachments>[] = [
+ {
+ id: "status",
+ label: "상태",
+ options: techVendors.status.enumValues.map((status) => ({
+ label: getStatusDisplay(status),
+ value: status,
+ count: statusCounts[status],
+ })),
+ },
+
+ { id: "vendorCode", label: "업체 코드" },
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField<TechVendorWithAttachments>[] = [
+ { id: "vendorName", label: "업체명", type: "text" },
+ { id: "vendorCode", label: "업체코드", type: "text" },
+ { id: "email", label: "이메일", type: "text" },
+ { id: "country", label: "국가", type: "text" },
+ {
+ id: "status",
+ label: "업체승인상태",
+ type: "multi-select",
+ options: techVendors.status.enumValues.map((status) => ({
+ label: getStatusDisplay(status),
+ value: status,
+ count: statusCounts[status],
+ icon: getVendorStatusIcon(status),
+ })),
+ },
+ {
+ id: "techVendorType",
+ label: "벤더 타입",
+ type: "multi-select",
+ options: [
+ { label: "조선", value: "조선" },
+ { label: "해양TOP", value: "해양TOP" },
+ { label: "해양HULL", value: "해양HULL" },
+ ],
+ },
+ {
+ id: "workTypes",
+ label: "Work Type",
+ type: "multi-select",
+ options: [
+ // 조선 workTypes
+ { label: "기장", value: "기장" },
+ { label: "전장", value: "전장" },
+ { label: "선실", value: "선실" },
+ { label: "배관", value: "배관" },
+ { label: "철의", value: "철의" },
+ { label: "선체", value: "선체" },
+ // 해양TOP workTypes
+ { label: "TM", value: "TM" },
+ { label: "TS", value: "TS" },
+ { label: "TE", value: "TE" },
+ { label: "TP", value: "TP" },
+ // 해양HULL workTypes
+ { label: "HA", value: "HA" },
+ { label: "HE", value: "HE" },
+ { label: "HH", value: "HH" },
+ { label: "HM", value: "HM" },
+ { label: "NC", value: "NC" },
+ { label: "HP", value: "HP" },
+ { label: "HO", value: "HO" },
+ ],
+ },
+ { id: "createdAt", label: "등록일", type: "date" },
+ { id: "updatedAt", label: "수정일", type: "date" },
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions", "possibleItems"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ const handleCompactChange = React.useCallback((compact: boolean) => {
+ setIsCompact(compact)
+ }, [])
+
+ // 테이블 새로고침 핸들러
+ const handleRefresh = React.useCallback(() => {
+ router.refresh()
+ }, [router])
+
+ // 필터 패널 검색 핸들러
+ const handleSearch = React.useCallback(() => {
+ router.refresh()
+ }, [router])
+
+ return (
+ <div
+ className={cn("flex flex-col relative", className)}
+ style={{ height: calculatedHeight }}
+ >
+ {/* Filter Panel */}
+ <div
+ className={cn(
+ "fixed left-0 bg-background border-r z-30 flex flex-col transition-all duration-300 ease-in-out overflow-hidden",
+ isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0"
+ )}
+ style={{
+ width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px',
+ top: `${LAYOUT_HEADER_HEIGHT*2}px`,
+ height: FIXED_FILTER_HEIGHT
+ }}
+ >
+ {/* Filter Content */}
+ <div className="h-full">
+ <TechVendorsFilterSheet
+ isOpen={isFilterPanelOpen}
+ onClose={() => setIsFilterPanelOpen(false)}
+ onSearch={handleSearch}
+ isLoading={false}
+ />
+ </div>
+ </div>
+
+ {/* Main Content */}
+ <div
+ className="flex flex-col transition-all duration-300 ease-in-out"
+ style={{
+ width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : '100%',
+ marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px',
+ height: '100%'
+ }}
+ >
+ {/* Header Bar - 고정 높이 */}
+ <div
+ className="flex items-center justify-between p-4 bg-background border-b"
+ style={{
+ height: `${LOCAL_HEADER_HEIGHT}px`,
+ flexShrink: 0
+ }}
+ >
+ <div className="flex items-center gap-3">
+ <Button
+ variant="outline"
+ size="sm"
+ type='button'
+ onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)}
+ className="flex items-center shadow-sm"
+ >
+ {isFilterPanelOpen ? <PanelLeftClose className="size-4"/> : <PanelLeftOpen className="size-4"/>}
+ </Button>
+ </div>
+
+ {/* Right side info
+ <div className="text-sm text-muted-foreground">
+ {data && (
+ <span>총 {data.length || 0}건</span>
+ )}
+ </div> */}
+ </div>
+
+ {/* DataTable */}
+ <div className="flex-1 overflow-hidden">
+ <DataTable
+ table={table}
+ compact={isCompact}
+ // floatingBar={<TechVendorsTableFloatingBar table={table} />}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ enableCompactToggle={true}
+ compactStorageKey="techVendorsTableCompact"
+ onCompactChange={handleCompactChange}
+ >
+ <TechVendorsTableToolbarActions
+ table={table}
+ onRefresh={handleRefresh}
+ />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </div>
+ </div>
+
+ <UpdateVendorSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ vendor={rowAction?.row.original ?? null}
+ />
+ </div>
+ )
}
\ 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 1d05b0c4..8498df51 100644 --- a/lib/tech-vendors/table/update-vendor-sheet.tsx +++ b/lib/tech-vendors/table/update-vendor-sheet.tsx @@ -1,413 +1,624 @@ -"use client" - -import * as React from "react" -import { zodResolver } from "@hookform/resolvers/zod" -import { useForm } from "react-hook-form" -import { - Loader, - Activity, - AlertCircle, - AlertTriangle, - Circle as CircleIcon, - Building, -} from "lucide-react" -import { toast } from "sonner" - -import { Button } from "@/components/ui/button" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, - FormDescription -} from "@/components/ui/form" -import { Input } from "@/components/ui/input" -import { - Sheet, - SheetClose, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet" -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { useSession } from "next-auth/react" // Import useSession - -import { TechVendor, techVendors } from "@/db/schema/techVendors" -import { updateTechVendorSchema, type UpdateTechVendorSchema } from "../validations" -import { modifyTechVendor } from "../service" - -interface UpdateVendorSheetProps - extends React.ComponentPropsWithRef<typeof Sheet> { - vendor: TechVendor | null -} -type StatusType = (typeof techVendors.status.enumValues)[number]; - -type StatusConfig = { - Icon: React.ElementType; - className: string; - label: string; -}; - -// 상태 표시 유틸리티 함수 -const getStatusConfig = (status: StatusType): StatusConfig => { - switch(status) { - case "ACTIVE": - return { - Icon: Activity, - className: "text-emerald-600", - label: "활성 상태" - }; - case "INACTIVE": - return { - Icon: AlertCircle, - className: "text-gray-600", - label: "비활성 상태" - }; - case "BLACKLISTED": - return { - Icon: AlertTriangle, - className: "text-slate-800", - label: "거래 금지" - }; - case "PENDING_REVIEW": - return { - Icon: AlertTriangle, - className: "text-slate-800", - label: "비교 견적" - }; - default: - return { - Icon: CircleIcon, - className: "text-gray-600", - label: status - }; - } -}; - - -// 폼 컴포넌트 -export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps) { - const [isPending, startTransition] = React.useTransition() - const { data: session } = useSession() - // 폼 정의 - UpdateVendorSchema 타입을 직접 사용 - const form = useForm<UpdateTechVendorSchema>({ - resolver: zodResolver(updateTechVendorSchema), - defaultValues: { - // 업체 기본 정보 - vendorName: vendor?.vendorName ?? "", - vendorCode: vendor?.vendorCode ?? "", - address: vendor?.address ?? "", - country: vendor?.country ?? "", - phone: vendor?.phone ?? "", - email: vendor?.email ?? "", - website: vendor?.website ?? "", - techVendorType: vendor?.techVendorType ? vendor.techVendorType.split(',').map(s => s.trim()).filter(Boolean) as ("조선" | "해양TOP" | "해양HULL")[] : [], - status: vendor?.status ?? "ACTIVE", - }, - }) - - React.useEffect(() => { - if (vendor) { - form.reset({ - vendorName: vendor?.vendorName ?? "", - vendorCode: vendor?.vendorCode ?? "", - address: vendor?.address ?? "", - country: vendor?.country ?? "", - phone: vendor?.phone ?? "", - email: vendor?.email ?? "", - website: vendor?.website ?? "", - techVendorType: vendor?.techVendorType ? vendor.techVendorType.split(',').map(s => s.trim()).filter(Boolean) as ("조선" | "해양TOP" | "해양HULL")[] : [], - status: vendor?.status ?? "ACTIVE", - - }); - } - }, [vendor, form]); - - - // 제출 핸들러 - async function onSubmit(data: UpdateTechVendorSchema) { - if (!vendor) return - - if (!session?.user?.id) { - toast.error("사용자 인증 정보를 찾을 수 없습니다.") - return - } - startTransition(async () => { - try { - // Add status change comment if status has changed - const oldStatus = vendor.status ?? "ACTIVE" // Default to ACTIVE if undefined - const newStatus = data.status ?? "ACTIVE" // Default to ACTIVE if undefined - - const statusComment = - oldStatus !== newStatus - ? `상태 변경: ${getStatusConfig(oldStatus).label} → ${getStatusConfig(newStatus).label}` - : "" // Empty string instead of undefined - - // 업체 정보 업데이트 - userId와 상태 변경 코멘트 추가 - const { error } = await modifyTechVendor({ - id: String(vendor.id), - userId: Number(session.user.id), // Add user ID from session - comment: statusComment, // Add comment for status changes - ...data, // 모든 데이터 전달 - 서비스 함수에서 필요한 필드만 처리 - techVendorType: Array.isArray(data.techVendorType) ? data.techVendorType.join(',') : undefined, - }) - - if (error) throw new Error(error) - - toast.success("업체 정보가 업데이트되었습니다!") - form.reset() - props.onOpenChange?.(false) - } catch (err: unknown) { - toast.error(String(err)) - } - }) -} - - return ( - <Sheet {...props}> - <SheetContent className="flex flex-col gap-6 sm:max-w-lg overflow-y-auto"> - <SheetHeader className="text-left"> - <SheetTitle>업체 정보 수정</SheetTitle> - <SheetDescription> - 업체 세부 정보를 수정하고 변경 사항을 저장하세요 - </SheetDescription> - </SheetHeader> - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6"> - {/* 업체 기본 정보 섹션 */} - <div className="space-y-4"> - <div className="flex items-center"> - <Building className="mr-2 h-5 w-5 text-muted-foreground" /> - <h3 className="text-sm font-medium">업체 기본 정보</h3> - </div> - <FormDescription> - 업체가 제공한 기본 정보입니다. 필요시 수정하세요. - </FormDescription> - <div className="grid grid-cols-1 gap-4 md:grid-cols-2"> - {/* vendorName */} - <FormField - control={form.control} - name="vendorName" - render={({ field }) => ( - <FormItem> - <FormLabel>업체명</FormLabel> - <FormControl> - <Input placeholder="업체명 입력" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* vendorCode */} - <FormField - control={form.control} - name="vendorCode" - render={({ field }) => ( - <FormItem> - <FormLabel>업체 코드</FormLabel> - <FormControl> - <Input placeholder="예: ABC123" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* address */} - <FormField - control={form.control} - name="address" - render={({ field }) => ( - <FormItem className="md:col-span-2"> - <FormLabel>주소</FormLabel> - <FormControl> - <Input placeholder="주소 입력" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* country */} - <FormField - control={form.control} - name="country" - render={({ field }) => ( - <FormItem> - <FormLabel>국가</FormLabel> - <FormControl> - <Input placeholder="예: 대한민국" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* phone */} - <FormField - control={form.control} - name="phone" - render={({ field }) => ( - <FormItem> - <FormLabel>전화번호</FormLabel> - <FormControl> - <Input placeholder="예: 010-1234-5678" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* email */} - <FormField - control={form.control} - name="email" - render={({ field }) => ( - <FormItem> - <FormLabel>이메일</FormLabel> - <FormControl> - <Input placeholder="예: info@company.com" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* website */} - <FormField - control={form.control} - name="website" - render={({ field }) => ( - <FormItem> - <FormLabel>웹사이트</FormLabel> - <FormControl> - <Input placeholder="예: https://www.company.com" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* techVendorType */} - <FormField - control={form.control} - name="techVendorType" - render={({ field }) => ( - <FormItem className="md:col-span-2"> - <FormLabel>벤더 타입 *</FormLabel> - <div className="space-y-2"> - {["조선", "해양TOP", "해양HULL"].map((type) => ( - <div key={type} className="flex items-center space-x-2"> - <input - type="checkbox" - id={`update-${type}`} - checked={field.value?.includes(type as "조선" | "해양TOP" | "해양HULL")} - onChange={(e) => { - const currentValue = Array.isArray(field.value) ? field.value : []; - if (e.target.checked) { - field.onChange([...currentValue, type]); - } else { - field.onChange(currentValue.filter((v: string) => v !== type)); - } - }} - className="w-4 h-4" - /> - <label htmlFor={`update-${type}`} className="text-sm font-medium cursor-pointer"> - {type} - </label> - </div> - ))} - </div> - <FormMessage /> - </FormItem> - )} - /> - - {/* status with icons */} - <FormField - control={form.control} - name="status" - render={({ field }) => { - // 현재 선택된 상태의 구성 정보 가져오기 - const selectedConfig = getStatusConfig(field.value ?? "ACTIVE"); - const SelectedIcon = selectedConfig?.Icon || CircleIcon; - - return ( - <FormItem> - <FormLabel>업체승인상태</FormLabel> - <FormControl> - <Select - value={field.value || ""} - onValueChange={field.onChange} - > - <SelectTrigger className="w-full"> - <SelectValue> - {field.value && ( - <div className="flex items-center"> - <SelectedIcon className={`mr-2 h-4 w-4 ${selectedConfig.className}`} /> - <span>{selectedConfig.label}</span> - </div> - )} - </SelectValue> - </SelectTrigger> - <SelectContent> - <SelectGroup> - {techVendors.status.enumValues.map((status) => { - const config = getStatusConfig(status); - const StatusIcon = config.Icon; - return ( - <SelectItem key={status} value={status}> - <div className="flex items-center"> - <StatusIcon className={`mr-2 h-4 w-4 ${config.className}`} /> - <span>{config.label}</span> - </div> - </SelectItem> - ); - })} - </SelectGroup> - </SelectContent> - </Select> - </FormControl> - <FormMessage /> - </FormItem> - ); - }} - /> - - - - - </div> - </div> - - <SheetFooter className="gap-2 pt-2 sm:space-x-0"> - <SheetClose asChild> - <Button type="button" variant="outline"> - 취소 - </Button> - </SheetClose> - <Button disabled={isPending}> - {isPending && ( - <Loader className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" /> - )} - 저장 - </Button> - </SheetFooter> - </form> - </Form> - </SheetContent> - </Sheet> - ) +"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import {
+ Loader,
+ Activity,
+ AlertCircle,
+ AlertTriangle,
+ Circle as CircleIcon
+} from "lucide-react"
+import { toast } from "sonner"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { useSession } from "next-auth/react"
+
+import { TechVendor, techVendors } from "@/db/schema/techVendors"
+import { updateTechVendorSchema, type UpdateTechVendorSchema } from "../validations"
+import { modifyTechVendor } from "../service"
+
+interface UpdateVendorSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ vendor: TechVendor | null
+}
+type StatusType = (typeof techVendors.status.enumValues)[number];
+
+type StatusConfig = {
+ Icon: React.ElementType;
+ className: string;
+ label: string;
+};
+
+// 상태 표시 유틸리티 함수
+const getStatusConfig = (status: StatusType): StatusConfig => {
+ switch(status) {
+ case "ACTIVE":
+ return {
+ Icon: Activity,
+ className: "text-emerald-600",
+ label: "활성 상태"
+ };
+ case "INACTIVE":
+ return {
+ Icon: AlertCircle,
+ className: "text-gray-600",
+ label: "비활성 상태"
+ };
+ case "BLACKLISTED":
+ return {
+ Icon: AlertTriangle,
+ className: "text-slate-800",
+ label: "거래 금지"
+ };
+ case "QUOTE_COMPARISON":
+ return {
+ Icon: AlertTriangle,
+ className: "text-slate-800",
+ label: "비교 견적"
+ };
+ case "PENDING_INVITE":
+ return {
+ Icon: AlertTriangle,
+ className: "text-slate-800",
+ label: "초대 대기"
+ };
+ case "INVITED":
+ return {
+ Icon: AlertTriangle,
+ className: "text-slate-800",
+ label: "초대 완료"
+ };
+ default:
+ return {
+ Icon: CircleIcon,
+ className: "text-gray-600",
+ label: status
+ };
+ }
+};
+
+// 폼 컴포넌트
+export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps) {
+ const [isPending, startTransition] = React.useTransition()
+ const { data: session } = useSession()
+
+ // 폼 정의 - UpdateVendorSchema 타입을 직접 사용
+ const form = useForm<UpdateTechVendorSchema>({
+ resolver: zodResolver(updateTechVendorSchema),
+ defaultValues: {
+ // 업체 기본 정보
+ vendorName: vendor?.vendorName ?? "",
+ vendorCode: vendor?.vendorCode ?? "",
+ address: vendor?.address ?? "",
+ country: vendor?.country ?? "",
+ countryEng: vendor?.countryEng ?? "",
+ countryFab: vendor?.countryFab ?? "",
+ phone: vendor?.phone ?? "",
+ email: vendor?.email ?? "",
+ website: vendor?.website ?? "",
+ techVendorType: vendor?.techVendorType ? vendor.techVendorType.split(',').map(s => s.trim()).filter(Boolean) as ("조선" | "해양TOP" | "해양HULL")[] : [],
+ status: vendor?.status ?? "ACTIVE",
+ // 에이전트 정보
+ agentName: vendor?.agentName ?? "",
+ agentEmail: vendor?.agentEmail ?? "",
+ agentPhone: vendor?.agentPhone ?? "",
+ // 대표자 정보
+ representativeName: vendor?.representativeName ?? "",
+ representativeEmail: vendor?.representativeEmail ?? "",
+ representativePhone: vendor?.representativePhone ?? "",
+ representativeBirth: vendor?.representativeBirth ?? "",
+ },
+ })
+
+ React.useEffect(() => {
+ if (vendor) {
+ form.reset({
+ vendorName: vendor?.vendorName ?? "",
+ vendorCode: vendor?.vendorCode ?? "",
+ address: vendor?.address ?? "",
+ country: vendor?.country ?? "",
+ countryEng: vendor?.countryEng ?? "",
+ countryFab: vendor?.countryFab ?? "",
+ phone: vendor?.phone ?? "",
+ email: vendor?.email ?? "",
+ website: vendor?.website ?? "",
+ techVendorType: vendor?.techVendorType ? vendor.techVendorType.split(',').map(s => s.trim()).filter(Boolean) as ("조선" | "해양TOP" | "해양HULL")[] : [],
+ status: vendor?.status ?? "ACTIVE",
+ // 에이전트 정보
+ agentName: vendor?.agentName ?? "",
+ agentEmail: vendor?.agentEmail ?? "",
+ agentPhone: vendor?.agentPhone ?? "",
+ // 대표자 정보
+ representativeName: vendor?.representativeName ?? "",
+ representativeEmail: vendor?.representativeEmail ?? "",
+ representativePhone: vendor?.representativePhone ?? "",
+ representativeBirth: vendor?.representativeBirth ?? "",
+ });
+ }
+ }, [vendor, form]);
+
+ // 제출 핸들러
+ async function onSubmit(data: UpdateTechVendorSchema) {
+ if (!vendor) return
+
+ if (!session?.user?.id) {
+ toast.error("사용자 인증 정보를 찾을 수 없습니다.")
+ return
+ }
+
+ startTransition(async () => {
+ try {
+ // Add status change comment if status has changed
+ const oldStatus = vendor.status ?? "ACTIVE" // Default to ACTIVE if undefined
+ const newStatus = data.status ?? "ACTIVE" // Default to ACTIVE if undefined
+
+ const statusComment =
+ oldStatus !== newStatus
+ ? `상태 변경: ${getStatusConfig(oldStatus).label} → ${getStatusConfig(newStatus).label}`
+ : "" // Empty string instead of undefined
+
+ // 업체 정보 업데이트 - userId와 상태 변경 코멘트 추가
+ const { error } = await modifyTechVendor({
+ id: String(vendor.id),
+ userId: Number(session.user.id), // Add user ID from session
+ comment: statusComment, // Add comment for status changes
+ ...data, // 모든 데이터 전달 - 서비스 함수에서 필요한 필드만 처리
+ techVendorType: Array.isArray(data.techVendorType) ? data.techVendorType.join(',') : undefined,
+ })
+
+ if (error) throw new Error(error)
+
+ toast.success("업체 정보가 업데이트되었습니다!")
+ form.reset()
+ props.onOpenChange?.(false)
+ } catch (err: unknown) {
+ toast.error(String(err))
+ }
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-xl overflow-y-auto">
+ <SheetHeader className="text-left">
+ <SheetTitle>업체 정보 수정</SheetTitle>
+ <SheetDescription>
+ 업체 세부 정보를 수정하고 변경 사항을 저장하세요
+ </SheetDescription>
+ </SheetHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
+
+ {/* 업체 기본 정보 섹션 */}
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-base">
+ 업체 기본 정보
+ </CardTitle>
+ <CardDescription>
+ 업체의 기본 정보를 관리합니다
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md: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="예: ABC123" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 이메일 */}
+ <FormField
+ control={form.control}
+ name="email"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>이메일</FormLabel>
+ <FormControl>
+ <Input placeholder="예: info@company.com" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 전화번호 */}
+ <FormField
+ control={form.control}
+ name="phone"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>전화번호</FormLabel>
+ <FormControl>
+ <Input placeholder="예: 010-1234-5678" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 웹사이트 */}
+ <FormField
+ control={form.control}
+ name="website"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>웹사이트</FormLabel>
+ <FormControl>
+ <Input placeholder="예: https://www.company.com" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 주소 */}
+ <FormField
+ control={form.control}
+ name="address"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>주소</FormLabel>
+ <FormControl>
+ <Input placeholder="주소 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <div className="grid grid-cols-1 md: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="예: South Korea" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 제조국가 */}
+ <FormField
+ control={form.control}
+ name="countryFab"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>제조국가</FormLabel>
+ <FormControl>
+ <Input placeholder="제조국가 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 벤더 타입 */}
+ <FormField
+ control={form.control}
+ name="techVendorType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>벤더 타입 *</FormLabel>
+ <div className="flex gap-6">
+ {["조선", "해양TOP", "해양HULL"].map((type) => (
+ <div key={type} className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id={`update-${type}`}
+ checked={field.value?.includes(type as "조선" | "해양TOP" | "해양HULL")}
+ onChange={(e) => {
+ const currentValue = Array.isArray(field.value) ? field.value : [];
+ if (e.target.checked) {
+ field.onChange([...currentValue, type]);
+ } else {
+ field.onChange(currentValue.filter((v: string) => v !== type));
+ }
+ }}
+ className="w-4 h-4"
+ />
+ <label htmlFor={`update-${type}`} className="text-sm font-medium cursor-pointer">
+ {type}
+ </label>
+ </div>
+ ))}
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </CardContent>
+ </Card>
+
+ {/* 승인 상태 섹션 */}
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-base">
+ 승인 상태
+ </CardTitle>
+ <CardDescription>
+ 업체의 승인 상태를 관리합니다
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <FormField
+ control={form.control}
+ name="status"
+ render={({ field }) => {
+ const selectedConfig = getStatusConfig(field.value ?? "ACTIVE");
+ const SelectedIcon = selectedConfig?.Icon || CircleIcon;
+
+ return (
+ <FormItem>
+ <FormLabel>업체 승인 상태</FormLabel>
+ <FormControl>
+ <Select
+ value={field.value || ""}
+ onValueChange={field.onChange}
+ >
+ <SelectTrigger className="w-full">
+ <SelectValue>
+ {field.value && (
+ <div className="flex items-center">
+ <SelectedIcon className={`mr-2 h-4 w-4 ${selectedConfig.className}`} />
+ <span>{selectedConfig.label}</span>
+ </div>
+ )}
+ </SelectValue>
+ </SelectTrigger>
+ <SelectContent>
+ <SelectGroup>
+ {techVendors.status.enumValues.map((status) => {
+ const config = getStatusConfig(status);
+ const StatusIcon = config.Icon;
+ return (
+ <SelectItem key={status} value={status}>
+ <div className="flex items-center">
+ <StatusIcon className={`mr-2 h-4 w-4 ${config.className}`} />
+ <span>{config.label}</span>
+ </div>
+ </SelectItem>
+ );
+ })}
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ );
+ }}
+ />
+ </CardContent>
+ </Card>
+
+ {/* 에이전트 정보 섹션 */}
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-base">
+ 에이전트 정보
+ </CardTitle>
+ <CardDescription>
+ 해당 업체의 에이전트 정보를 관리합니다
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-1 md: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>
+ )}
+ />
+
+ {/* 에이전트 이메일 */}
+ <FormField
+ control={form.control}
+ name="agentEmail"
+ render={({ field }) => (
+ <FormItem className="md:col-span-2">
+ <FormLabel>에이전트 이메일</FormLabel>
+ <FormControl>
+ <Input type="email" placeholder="에이전트 이메일 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 대표자 정보 섹션 */}
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-base">
+ 대표자 정보
+ </CardTitle>
+ <CardDescription>
+ 업체 대표자의 정보를 관리합니다
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-1 md: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="representativeBirth"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>대표자 생년월일</FormLabel>
+ <FormControl>
+ <Input placeholder="YYYY-MM-DD" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 대표자 전화번호 */}
+ <FormField
+ control={form.control}
+ name="representativePhone"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>대표자 전화번호</FormLabel>
+ <FormControl>
+ <Input placeholder="대표자 전화번호 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 대표자 이메일 */}
+ <FormField
+ control={form.control}
+ name="representativeEmail"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>대표자 이메일</FormLabel>
+ <FormControl>
+ <Input type="email" placeholder="대표자 이메일 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </CardContent>
+ </Card>
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ 취소
+ </Button>
+ </SheetClose>
+ <Button disabled={isPending}>
+ {isPending && (
+ <Loader className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
+ )}
+ 저장
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
}
\ No newline at end of file diff --git a/lib/tech-vendors/table/vendor-all-export.ts b/lib/tech-vendors/table/vendor-all-export.ts index f2650102..f1492324 100644 --- a/lib/tech-vendors/table/vendor-all-export.ts +++ b/lib/tech-vendors/table/vendor-all-export.ts @@ -1,257 +1,257 @@ -// /lib/vendor-export.ts -import ExcelJS from "exceljs" -import { TechVendor, TechVendorContact, TechVendorItem } from "@/db/schema/techVendors" -import { exportTechVendorDetails } from "../service"; - -/** - * 선택된 벤더의 모든 관련 정보를 통합 시트 형식으로 엑셀로 내보내는 함수 - * - 기본정보 시트 - * - 연락처 시트 - * - 아이템 시트 - * 각 시트에는 식별을 위한 벤더 코드, 벤더명, 세금ID가 포함됨 - */ -export async function exportVendorsWithRelatedData( - vendors: TechVendor[], - filename = "tech-vendors-detailed" -): Promise<void> { - if (!vendors.length) return; - - // 선택된 벤더 ID 목록 - const vendorIds = vendors.map(vendor => vendor.id); - - try { - // 서버로부터 모든 관련 데이터 가져오기 - const vendorsWithDetails = await exportTechVendorDetails(vendorIds); - - if (!vendorsWithDetails.length) { - throw new Error("내보내기 데이터를 가져오는 중 오류가 발생했습니다."); - } - - // 워크북 생성 - const workbook = new ExcelJS.Workbook(); - - // 데이터 타입 확인 (서비스에서 반환하는 실제 데이터 형태) - const vendorData = vendorsWithDetails as unknown as any[]; - - // ===== 1. 기본 정보 시트 ===== - createBasicInfoSheet(workbook, vendorData); - - // ===== 2. 연락처 시트 ===== - createContactsSheet(workbook, vendorData); - - // ===== 3. 아이템 시트 ===== - createItemsSheet(workbook, vendorData); - - - // 파일 다운로드 - const buffer = await workbook.xlsx.writeBuffer(); - const blob = new Blob([buffer], { - type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = `${filename}-${new Date().toISOString().split("T")[0]}.xlsx`; - link.click(); - URL.revokeObjectURL(url); - - return; - } catch (error) { - console.error("Export error:", error); - throw error; - } -} - -// 기본 정보 시트 생성 함수 -function createBasicInfoSheet( - workbook: ExcelJS.Workbook, - vendors: TechVendor[] -): void { - const basicInfoSheet = workbook.addWorksheet("기본정보"); - - // 기본 정보 시트 헤더 설정 - basicInfoSheet.columns = [ - { header: "업체코드", key: "vendorCode", width: 15 }, - { header: "업체명", key: "vendorName", width: 20 }, - { header: "세금ID", key: "taxId", width: 15 }, - { header: "국가", key: "country", width: 10 }, - { header: "상태", key: "status", width: 15 }, - { header: "이메일", key: "email", width: 20 }, - { header: "전화번호", key: "phone", width: 15 }, - { header: "웹사이트", key: "website", width: 20 }, - { 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 }, - ]; - - // 헤더 스타일 설정 - applyHeaderStyle(basicInfoSheet); - - // 벤더 데이터 추가 - vendors.forEach((vendor: TechVendor) => { - basicInfoSheet.addRow({ - vendorCode: vendor.vendorCode || "", - vendorName: vendor.vendorName, - taxId: vendor.taxId, - country: vendor.country, - status: getStatusText(vendor.status), // 상태 코드를 읽기 쉬운 텍스트로 변환 - email: vendor.email, - phone: vendor.phone, - website: vendor.website, - address: vendor.address, - representativeName: vendor.representativeName, - createdAt: vendor.createdAt ? formatDate(vendor.createdAt) : "", - techVendorType: vendor.techVendorType?.split(',').join(', ') || vendor.techVendorType, - }); - }); -} - -// 연락처 시트 생성 함수 -function createContactsSheet( - workbook: ExcelJS.Workbook, - vendors: TechVendor[] -): void { - const contactsSheet = workbook.addWorksheet("연락처"); - - contactsSheet.columns = [ - // 벤더 식별 정보 - { header: "업체코드", key: "vendorCode", width: 15 }, - { header: "업체명", key: "vendorName", width: 20 }, - { header: "세금ID", key: "taxId", width: 15 }, - // 연락처 정보 - { header: "이름", key: "contactName", width: 15 }, - { header: "직책", key: "contactPosition", width: 15 }, - { header: "이메일", key: "contactEmail", width: 25 }, - { header: "전화번호", key: "contactPhone", width: 15 }, - { header: "주요 연락처", key: "isPrimary", width: 10 }, - ]; - - // 헤더 스타일 설정 - applyHeaderStyle(contactsSheet); - - // 벤더별 연락처 데이터 추가 - vendors.forEach((vendor: TechVendor) => { - if (vendor.contacts && vendor.contacts.length > 0) { - vendor.contacts.forEach((contact: TechVendorContact) => { - contactsSheet.addRow({ - // 벤더 식별 정보 - vendorCode: vendor.vendorCode || "", - vendorName: vendor.vendorName, - taxId: vendor.taxId, - // 연락처 정보 - contactName: contact.contactName, - contactPosition: contact.contactPosition || "", - contactEmail: contact.contactEmail, - contactPhone: contact.contactPhone || "", - isPrimary: contact.isPrimary ? "예" : "아니오", - }); - }); - } else { - // 연락처가 없는 경우에도 벤더 정보만 추가 - contactsSheet.addRow({ - vendorCode: vendor.vendorCode || "", - vendorName: vendor.vendorName, - taxId: vendor.taxId, - contactName: "", - contactPosition: "", - contactEmail: "", - contactPhone: "", - isPrimary: "", - }); - } - }); -} - -// 아이템 시트 생성 함수 -function createItemsSheet( - workbook: ExcelJS.Workbook, - vendors: TechVendor[] -): void { - const itemsSheet = workbook.addWorksheet("아이템"); - - itemsSheet.columns = [ - // 벤더 식별 정보 - { header: "업체코드", key: "vendorCode", width: 15 }, - { header: "업체명", key: "vendorName", width: 20 }, - { header: "세금ID", key: "taxId", width: 15 }, - // 아이템 정보 - { header: "아이템 코드", key: "itemCode", width: 15 }, - { header: "아이템명", key: "itemName", width: 25 }, - { header: "설명", key: "description", width: 30 }, - { header: "등록일", key: "createdAt", width: 15 }, - ]; - - // 헤더 스타일 설정 - applyHeaderStyle(itemsSheet); - - // 벤더별 아이템 데이터 추가 - vendors.forEach((vendor: TechVendor) => { - if (vendor.items && vendor.items.length > 0) { - vendor.items.forEach((item: TechVendorItem) => { - itemsSheet.addRow({ - // 벤더 식별 정보 - vendorCode: vendor.vendorCode || "", - vendorName: vendor.vendorName, - taxId: vendor.taxId, - // 아이템 정보 - itemCode: item.itemCode, - itemName: item.itemName, - createdAt: item.createdAt ? formatDate(item.createdAt) : "", - }); - }); - } else { - // 아이템이 없는 경우에도 벤더 정보만 추가 - itemsSheet.addRow({ - vendorCode: vendor.vendorCode || "", - vendorName: vendor.vendorName, - taxId: vendor.taxId, - itemCode: "", - itemName: "", - createdAt: "", - }); - } - }); -} - - -// 헤더 스타일 적용 함수 -function applyHeaderStyle(sheet: ExcelJS.Worksheet): void { - const headerRow = sheet.getRow(1); - headerRow.font = { bold: true }; - headerRow.alignment = { horizontal: "center" }; - headerRow.eachCell((cell: ExcelJS.Cell) => { - cell.fill = { - type: "pattern", - pattern: "solid", - fgColor: { argb: "FFCCCCCC" }, - }; - }); -} - -// 날짜 포맷 함수 -function formatDate(date: Date | string): string { - if (!date) return ""; - if (typeof date === 'string') { - date = new Date(date); - } - return date.toISOString().split('T')[0]; -} - - -// 상태 코드를 읽기 쉬운 텍스트로 변환하는 함수 -function getStatusText(status: string): string { - const statusMap: Record<string, string> = { - "ACTIVE": "활성", - "INACTIVE": "비활성", - "BLACKLISTED": "거래 금지" - }; - - return statusMap[status] || status; +// /lib/vendor-export.ts
+import ExcelJS from "exceljs"
+import { TechVendor, TechVendorContact, TechVendorItem } from "@/db/schema/techVendors"
+import { exportTechVendorDetails } from "../service";
+
+/**
+ * 선택된 벤더의 모든 관련 정보를 통합 시트 형식으로 엑셀로 내보내는 함수
+ * - 기본정보 시트
+ * - 연락처 시트
+ * - 아이템 시트
+ * 각 시트에는 식별을 위한 벤더 코드, 벤더명, 세금ID가 포함됨
+ */
+export async function exportVendorsWithRelatedData(
+ vendors: TechVendor[],
+ filename = "tech-vendors-detailed"
+): Promise<void> {
+ if (!vendors.length) return;
+
+ // 선택된 벤더 ID 목록
+ const vendorIds = vendors.map(vendor => vendor.id);
+
+ try {
+ // 서버로부터 모든 관련 데이터 가져오기
+ const vendorsWithDetails = await exportTechVendorDetails(vendorIds);
+
+ if (!vendorsWithDetails.length) {
+ throw new Error("내보내기 데이터를 가져오는 중 오류가 발생했습니다.");
+ }
+
+ // 워크북 생성
+ const workbook = new ExcelJS.Workbook();
+
+ // 데이터 타입 확인 (서비스에서 반환하는 실제 데이터 형태)
+ const vendorData = vendorsWithDetails as unknown as any[];
+
+ // ===== 1. 기본 정보 시트 =====
+ createBasicInfoSheet(workbook, vendorData);
+
+ // ===== 2. 연락처 시트 =====
+ createContactsSheet(workbook, vendorData);
+
+ // ===== 3. 아이템 시트 =====
+ createItemsSheet(workbook, vendorData);
+
+
+ // 파일 다운로드
+ const buffer = await workbook.xlsx.writeBuffer();
+ const blob = new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = `${filename}-${new Date().toISOString().split("T")[0]}.xlsx`;
+ link.click();
+ URL.revokeObjectURL(url);
+
+ return;
+ } catch (error) {
+ console.error("Export error:", error);
+ throw error;
+ }
+}
+
+// 기본 정보 시트 생성 함수
+function createBasicInfoSheet(
+ workbook: ExcelJS.Workbook,
+ vendors: TechVendor[]
+): void {
+ const basicInfoSheet = workbook.addWorksheet("기본정보");
+
+ // 기본 정보 시트 헤더 설정
+ basicInfoSheet.columns = [
+ { header: "업체코드", key: "vendorCode", width: 15 },
+ { header: "업체명", key: "vendorName", width: 20 },
+ { header: "세금ID", key: "taxId", width: 15 },
+ { header: "국가", key: "country", width: 10 },
+ { header: "상태", key: "status", width: 15 },
+ { header: "이메일", key: "email", width: 20 },
+ { header: "전화번호", key: "phone", width: 15 },
+ { header: "웹사이트", key: "website", width: 20 },
+ { 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 },
+ ];
+
+ // 헤더 스타일 설정
+ applyHeaderStyle(basicInfoSheet);
+
+ // 벤더 데이터 추가
+ vendors.forEach((vendor: TechVendor) => {
+ basicInfoSheet.addRow({
+ vendorCode: vendor.vendorCode || "",
+ vendorName: vendor.vendorName,
+ taxId: vendor.taxId,
+ country: vendor.country,
+ status: getStatusText(vendor.status), // 상태 코드를 읽기 쉬운 텍스트로 변환
+ email: vendor.email,
+ phone: vendor.phone,
+ website: vendor.website,
+ address: vendor.address,
+ representativeName: vendor.representativeName,
+ createdAt: vendor.createdAt ? formatDate(vendor.createdAt) : "",
+ techVendorType: vendor.techVendorType?.split(',').join(', ') || vendor.techVendorType,
+ });
+ });
+}
+
+// 연락처 시트 생성 함수
+function createContactsSheet(
+ workbook: ExcelJS.Workbook,
+ vendors: TechVendor[]
+): void {
+ const contactsSheet = workbook.addWorksheet("연락처");
+
+ contactsSheet.columns = [
+ // 벤더 식별 정보
+ { header: "업체코드", key: "vendorCode", width: 15 },
+ { header: "업체명", key: "vendorName", width: 20 },
+ { header: "세금ID", key: "taxId", width: 15 },
+ // 연락처 정보
+ { header: "이름", key: "contactName", width: 15 },
+ { header: "직책", key: "contactPosition", width: 15 },
+ { header: "이메일", key: "contactEmail", width: 25 },
+ { header: "전화번호", key: "contactPhone", width: 15 },
+ { header: "주요 연락처", key: "isPrimary", width: 10 },
+ ];
+
+ // 헤더 스타일 설정
+ applyHeaderStyle(contactsSheet);
+
+ // 벤더별 연락처 데이터 추가
+ vendors.forEach((vendor: TechVendor) => {
+ if (vendor.contacts && vendor.contacts.length > 0) {
+ vendor.contacts.forEach((contact: TechVendorContact) => {
+ contactsSheet.addRow({
+ // 벤더 식별 정보
+ vendorCode: vendor.vendorCode || "",
+ vendorName: vendor.vendorName,
+ taxId: vendor.taxId,
+ // 연락처 정보
+ contactName: contact.contactName,
+ contactPosition: contact.contactPosition || "",
+ contactEmail: contact.contactEmail,
+ contactPhone: contact.contactPhone || "",
+ isPrimary: contact.isPrimary ? "예" : "아니오",
+ });
+ });
+ } else {
+ // 연락처가 없는 경우에도 벤더 정보만 추가
+ contactsSheet.addRow({
+ vendorCode: vendor.vendorCode || "",
+ vendorName: vendor.vendorName,
+ taxId: vendor.taxId,
+ contactName: "",
+ contactPosition: "",
+ contactEmail: "",
+ contactPhone: "",
+ isPrimary: "",
+ });
+ }
+ });
+}
+
+// 아이템 시트 생성 함수
+function createItemsSheet(
+ workbook: ExcelJS.Workbook,
+ vendors: TechVendor[]
+): void {
+ const itemsSheet = workbook.addWorksheet("아이템");
+
+ itemsSheet.columns = [
+ // 벤더 식별 정보
+ { header: "업체코드", key: "vendorCode", width: 15 },
+ { header: "업체명", key: "vendorName", width: 20 },
+ { header: "세금ID", key: "taxId", width: 15 },
+ // 아이템 정보
+ { header: "아이템 코드", key: "itemCode", width: 15 },
+ { header: "아이템명", key: "itemName", width: 25 },
+ { header: "설명", key: "description", width: 30 },
+ { header: "등록일", key: "createdAt", width: 15 },
+ ];
+
+ // 헤더 스타일 설정
+ applyHeaderStyle(itemsSheet);
+
+ // 벤더별 아이템 데이터 추가
+ vendors.forEach((vendor: TechVendor) => {
+ if (vendor.items && vendor.items.length > 0) {
+ vendor.items.forEach((item: TechVendorItem) => {
+ itemsSheet.addRow({
+ // 벤더 식별 정보
+ vendorCode: vendor.vendorCode || "",
+ vendorName: vendor.vendorName,
+ taxId: vendor.taxId,
+ // 아이템 정보
+ itemCode: item.itemCode,
+ itemName: item.itemName,
+ createdAt: item.createdAt ? formatDate(item.createdAt) : "",
+ });
+ });
+ } else {
+ // 아이템이 없는 경우에도 벤더 정보만 추가
+ itemsSheet.addRow({
+ vendorCode: vendor.vendorCode || "",
+ vendorName: vendor.vendorName,
+ taxId: vendor.taxId,
+ itemCode: "",
+ itemName: "",
+ createdAt: "",
+ });
+ }
+ });
+}
+
+
+// 헤더 스타일 적용 함수
+function applyHeaderStyle(sheet: ExcelJS.Worksheet): void {
+ const headerRow = sheet.getRow(1);
+ headerRow.font = { bold: true };
+ headerRow.alignment = { horizontal: "center" };
+ headerRow.eachCell((cell: ExcelJS.Cell) => {
+ cell.fill = {
+ type: "pattern",
+ pattern: "solid",
+ fgColor: { argb: "FFCCCCCC" },
+ };
+ });
+}
+
+// 날짜 포맷 함수
+function formatDate(date: Date | string): string {
+ if (!date) return "";
+ if (typeof date === 'string') {
+ date = new Date(date);
+ }
+ return date.toISOString().split('T')[0];
+}
+
+
+// 상태 코드를 읽기 쉬운 텍스트로 변환하는 함수
+function getStatusText(status: string): string {
+ const statusMap: Record<string, string> = {
+ "ACTIVE": "활성",
+ "INACTIVE": "비활성",
+ "BLACKLISTED": "거래 금지"
+ };
+
+ return statusMap[status] || status;
}
\ No newline at end of file diff --git a/lib/tech-vendors/utils.ts b/lib/tech-vendors/utils.ts index e409975a..ac91cd8d 100644 --- a/lib/tech-vendors/utils.ts +++ b/lib/tech-vendors/utils.ts @@ -1,28 +1,28 @@ -import { LucideIcon, CheckCircle2, CircleAlert, Clock, ShieldAlert, Mail, BarChart2 } from "lucide-react"; -import type { TechVendor } from "@/db/schema/techVendors"; - -type StatusType = TechVendor["status"]; - -/** - * 기술벤더 상태에 대한 아이콘을 반환합니다. - */ -export function getVendorStatusIcon(status: StatusType): LucideIcon { - switch (status) { - case "PENDING_INVITE": - return Clock; - case "INVITED": - return Mail; - case "QUOTE_COMPARISON": - return BarChart2; - case "ACTIVE": - return CheckCircle2; - case "INACTIVE": - return CircleAlert; - case "BLACKLISTED": - return ShieldAlert; - default: - return CircleAlert; - } -} - - +import { LucideIcon, CheckCircle2, CircleAlert, Clock, ShieldAlert, Mail, BarChart2 } from "lucide-react";
+import type { TechVendor } from "@/db/schema/techVendors";
+
+type StatusType = TechVendor["status"];
+
+/**
+ * 기술벤더 상태에 대한 아이콘을 반환합니다.
+ */
+export function getVendorStatusIcon(status: StatusType): LucideIcon {
+ switch (status) {
+ case "PENDING_INVITE":
+ return Clock;
+ case "INVITED":
+ return Mail;
+ case "QUOTE_COMPARISON":
+ return BarChart2;
+ case "ACTIVE":
+ return CheckCircle2;
+ case "INACTIVE":
+ return CircleAlert;
+ case "BLACKLISTED":
+ return ShieldAlert;
+ default:
+ return CircleAlert;
+ }
+}
+
+
diff --git a/lib/tech-vendors/validations.ts b/lib/tech-vendors/validations.ts index 0c850c1f..618ad22e 100644 --- a/lib/tech-vendors/validations.ts +++ b/lib/tech-vendors/validations.ts @@ -1,321 +1,398 @@ -import { - createSearchParamsCache, - parseAsArrayOf, - parseAsInteger, - parseAsString, - parseAsStringEnum, -} from "nuqs/server" -import * as z from "zod" - -import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" -import { techVendors, TechVendor, TechVendorContact, TechVendorItemsView, VENDOR_TYPES } from "@/db/schema/techVendors"; - -export const searchParamsCache = createSearchParamsCache({ - // 공통 플래그 - flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( - [] - ), - - // 페이징 - page: parseAsInteger.withDefault(1), - perPage: parseAsInteger.withDefault(10), - - // 정렬 (techVendors 테이블에 맞춰 TechVendor 타입 지정) - sort: getSortingStateParser<TechVendor>().withDefault([ - { id: "createdAt", desc: true }, // createdAt 기준 내림차순 - ]), - - // 고급 필터 - filters: getFiltersStateParser().withDefault([]), - joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - - // 검색 키워드 - search: parseAsString.withDefault(""), - - // ----------------------------------------------------------------- - // 기술영업 협력업체에 특화된 검색 필드 - // ----------------------------------------------------------------- - // 상태 (ACTIVE, INACTIVE, BLACKLISTED 등) 중에서 선택 - status: parseAsStringEnum(["ACTIVE", "INACTIVE", "BLACKLISTED", "PENDING_REVIEW"]), - - // 협력업체명 검색 - vendorName: parseAsString.withDefault(""), - - // 국가 검색 - country: parseAsString.withDefault(""), - - // 예) 코드 검색 - vendorCode: parseAsString.withDefault(""), - - // 벤더 타입 필터링 (다중 선택 가능) - vendorType: parseAsStringEnum(["ship", "top", "hull"]), - - // workTypes 필터링 (다중 선택 가능) - workTypes: parseAsArrayOf(parseAsStringEnum([ - // 조선 workTypes - "기장", "전장", "선실", "배관", "철의", - // 해양TOP workTypes - "TM", "TS", "TE", "TP", - // 해양HULL workTypes - "HA", "HE", "HH", "HM", "NC" - ])).withDefault([]), - - // 필요하다면 이메일 검색 / 웹사이트 검색 등 추가 가능 - email: parseAsString.withDefault(""), - website: parseAsString.withDefault(""), -}); - -export const searchParamsContactCache = createSearchParamsCache({ - // 공통 플래그 - flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( - [] - ), - - // 페이징 - page: parseAsInteger.withDefault(1), - perPage: parseAsInteger.withDefault(10), - - // 정렬 - sort: getSortingStateParser<TechVendorContact>().withDefault([ - { id: "createdAt", desc: true }, // createdAt 기준 내림차순 - ]), - - // 고급 필터 - filters: getFiltersStateParser().withDefault([]), - joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - - // 검색 키워드 - search: parseAsString.withDefault(""), - - // 특정 필드 검색 - contactName: parseAsString.withDefault(""), - contactPosition: parseAsString.withDefault(""), - contactEmail: parseAsString.withDefault(""), - contactPhone: parseAsString.withDefault(""), -}); - -export const searchParamsItemCache = createSearchParamsCache({ - // 공통 플래그 - flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( - [] - ), - - // 페이징 - page: parseAsInteger.withDefault(1), - perPage: parseAsInteger.withDefault(10), - - // 정렬 - sort: getSortingStateParser<TechVendorItemsView>().withDefault([ - { id: "createdAt", desc: true }, // createdAt 기준 내림차순 - ]), - - // 고급 필터 - filters: getFiltersStateParser().withDefault([]), - joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - - // 검색 키워드 - search: parseAsString.withDefault(""), - - // 특정 필드 검색 - itemName: parseAsString.withDefault(""), - itemCode: parseAsString.withDefault(""), -}); - -// 기술영업 벤더 기본 정보 업데이트 스키마 -export const updateTechVendorSchema = z.object({ - vendorName: z.string().min(1, "업체명은 필수 입력사항입니다"), - vendorCode: z.string().optional(), - address: z.string().optional(), - country: z.string().optional(), - phone: z.string().optional(), - email: z.string().email("유효한 이메일 주소를 입력해주세요").optional(), - website: z.string().url("유효한 URL을 입력해주세요").optional(), - techVendorType: z.union([ - z.array(z.enum(VENDOR_TYPES)).min(1, "최소 하나의 벤더 타입을 선택해주세요"), - z.string().min(1, "벤더 타입을 선택해주세요") - ]).optional(), - status: z.enum(techVendors.status.enumValues).optional(), - userId: z.number().optional(), - comment: z.string().optional(), -}); - -// 연락처 스키마 -const contactSchema = z.object({ - id: z.number().optional(), - contactName: z - .string() - .min(1, "Contact name is required") - .max(255, "Max length 255"), - contactPosition: z.string().max(100).optional(), - contactEmail: z.string().email("Invalid email").max(255), - contactPhone: z.string().max(50).optional(), - isPrimary: z.boolean().default(false).optional() -}); - -// 기술영업 벤더 생성 스키마 -export const createTechVendorSchema = z - .object({ - vendorName: z - .string() - .min(1, "Vendor name is required") - .max(255, "Max length 255"), - - email: z.string().email("Invalid email").max(255), - // 나머지 optional - vendorCode: z.string().max(100, "Max length 100").optional(), - address: z.string().optional(), - country: z.string() - .min(1, "국가 선택은 필수입니다.") - .max(100, "Max length 100"), - phone: z.string().max(50, "Max length 50").optional(), - website: z.string().max(255).optional(), - - files: z.any().optional(), - status: z.enum(techVendors.status.enumValues).default("ACTIVE"), - techVendorType: z.union([ - z.array(z.enum(VENDOR_TYPES)).min(1, "최소 하나의 벤더 타입을 선택해주세요"), - z.string().min(1, "벤더 타입을 선택해주세요") - ]).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(), - taxId: z.string().min(1, { message: "사업자등록번호를 입력해주세요" }), - - items: z.string().min(1, { message: "공급품목을 입력해주세요" }), - - contacts: z - .array(contactSchema) - .nonempty("At least one contact is required.") - }) - .superRefine((data, ctx) => { - if (data.country === "KR") { - // 1) 대표자 정보가 누락되면 각각 에러 발생 - if (!data.representativeName) { - ctx.addIssue({ - code: "custom", - path: ["representativeName"], - message: "대표자 이름은 한국(KR) 업체일 경우 필수입니다.", - }) - } - if (!data.representativeBirth) { - ctx.addIssue({ - code: "custom", - path: ["representativeBirth"], - message: "대표자 생년월일은 한국(KR) 업체일 경우 필수입니다.", - }) - } - if (!data.representativeEmail) { - ctx.addIssue({ - code: "custom", - path: ["representativeEmail"], - message: "대표자 이메일은 한국(KR) 업체일 경우 필수입니다.", - }) - } - if (!data.representativePhone) { - ctx.addIssue({ - code: "custom", - path: ["representativePhone"], - message: "대표자 전화번호는 한국(KR) 업체일 경우 필수입니다.", - }) - } - - } - }); - -// 연락처 생성 스키마 -export const createTechVendorContactSchema = z.object({ - vendorId: z.number(), - contactName: z.string() - .min(1, "Contact name is required") - .max(255, "Max length 255"), - contactPosition: z.string().max(100, "Max length 100"), - contactEmail: z.string().email(), - contactPhone: z.string().max(50, "Max length 50").optional(), - country: z.string().max(100, "Max length 100").optional(), - isPrimary: z.boolean(), -}); - -// 연락처 업데이트 스키마 -export const updateTechVendorContactSchema = z.object({ - contactName: z.string() - .min(1, "Contact name is required") - .max(255, "Max length 255"), - contactPosition: z.string().max(100, "Max length 100").optional(), - contactEmail: z.string().email().optional(), - contactPhone: z.string().max(50, "Max length 50").optional(), - country: z.string().max(100, "Max length 100").optional(), - isPrimary: z.boolean().optional(), -}); - -// 아이템 생성 스키마 -export const createTechVendorItemSchema = z.object({ - vendorId: z.number(), - itemCode: z.string().max(100, "Max length 100"), - itemList: z.string().min(1, "Item list is required").max(255, "Max length 255"), -}); - -// 아이템 업데이트 스키마 -export const updateTechVendorItemSchema = z.object({ - itemList: z.string().optional(), - itemCode: z.string().max(100, "Max length 100"), -}); - -export const searchParamsRfqHistoryCache = createSearchParamsCache({ - // 공통 플래그 - flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( - [] - ), - - // 페이징 - page: parseAsInteger.withDefault(1), - perPage: parseAsInteger.withDefault(10), - - // 정렬 (RFQ 히스토리에 맞춰) - sort: getSortingStateParser<{ - id: number; - rfqCode: string | null; - description: string | null; - projectCode: string | null; - projectName: string | null; - projectType: string | null; // 프로젝트 타입 추가 - status: string; - totalAmount: string | null; - currency: string | null; - dueDate: Date | null; - createdAt: Date; - quotationCode: string | null; - submittedAt: Date | null; - }>().withDefault([ - { id: "createdAt", desc: true }, - ]), - - // 고급 필터 - filters: getFiltersStateParser().withDefault([]), - joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - - // 검색 키워드 - search: parseAsString.withDefault(""), - - // RFQ 히스토리 특화 필드 - rfqCode: parseAsString.withDefault(""), - description: parseAsString.withDefault(""), - projectCode: parseAsString.withDefault(""), - projectName: parseAsString.withDefault(""), - projectType: parseAsStringEnum(["SHIP", "TOP", "HULL"]), // 프로젝트 타입 필터 추가 - status: parseAsStringEnum(["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"]), -}); - -// 타입 내보내기 -export type GetTechVendorsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>> -export type GetTechVendorContactsSchema = Awaited<ReturnType<typeof searchParamsContactCache.parse>> -export type GetTechVendorItemsSchema = Awaited<ReturnType<typeof searchParamsItemCache.parse>> -export type GetTechVendorRfqHistorySchema = Awaited<ReturnType<typeof searchParamsRfqHistoryCache.parse>> - -export type UpdateTechVendorSchema = z.infer<typeof updateTechVendorSchema> -export type CreateTechVendorSchema = z.infer<typeof createTechVendorSchema> -export type CreateTechVendorContactSchema = z.infer<typeof createTechVendorContactSchema> -export type UpdateTechVendorContactSchema = z.infer<typeof updateTechVendorContactSchema> -export type CreateTechVendorItemSchema = z.infer<typeof createTechVendorItemSchema> -export type UpdateTechVendorItemSchema = z.infer<typeof updateTechVendorItemSchema>
\ No newline at end of file +import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { techVendors, TechVendor, TechVendorContact, TechVendorItemsView, VENDOR_TYPES } from "@/db/schema/techVendors";
+
+// TechVendorPossibleItem 타입 정의
+export interface TechVendorPossibleItem {
+ id: number;
+ vendorId: number;
+ vendorCode: string | null;
+ vendorEmail: string | null;
+ itemCode: string;
+ workType: string | null;
+ shipTypes: string | null;
+ itemList: string | null;
+ subItemList: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+ // 조인된 정보
+ techVendorType?: "조선" | "해양TOP" | "해양HULL";
+}
+
+export const searchParamsCache = createSearchParamsCache({
+ // 공통 플래그
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+
+ // 페이징
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 정렬 (techVendors 테이블에 맞춰 TechVendor 타입 지정)
+ sort: getSortingStateParser<TechVendor>().withDefault([
+ { id: "createdAt", desc: true }, // createdAt 기준 내림차순
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 검색 키워드
+ search: parseAsString.withDefault(""),
+
+ // -----------------------------------------------------------------
+ // 기술영업 협력업체에 특화된 검색 필드
+ // -----------------------------------------------------------------
+ // 상태 (ACTIVE, INACTIVE, BLACKLISTED 등) 중에서 선택
+ status: parseAsStringEnum(["ACTIVE", "INACTIVE", "BLACKLISTED", "PENDING_REVIEW"]),
+
+ // 협력업체명 검색
+ vendorName: parseAsString.withDefault(""),
+
+ // 국가 검색
+ country: parseAsString.withDefault(""),
+
+ // 예) 코드 검색
+ vendorCode: parseAsString.withDefault(""),
+
+ // 벤더 타입 필터링 (다중 선택 가능)
+ vendorType: parseAsStringEnum(["ship", "top", "hull"]),
+
+ // workTypes 필터링 (다중 선택 가능)
+ workTypes: parseAsArrayOf(parseAsStringEnum([
+ // 조선 workTypes
+ "기장", "전장", "선실", "배관", "철의", "선체",
+ // 해양TOP workTypes
+ "TM", "TS", "TE", "TP",
+ // 해양HULL workTypes
+ "HA", "HE", "HH", "HM", "NC", "HO", "HP"
+ ])).withDefault([]),
+
+ // 필요하다면 이메일 검색 / 웹사이트 검색 등 추가 가능
+ email: parseAsString.withDefault(""),
+ website: parseAsString.withDefault(""),
+});
+
+export const searchParamsContactCache = createSearchParamsCache({
+ // 공통 플래그
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+
+ // 페이징
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 정렬
+ sort: getSortingStateParser<TechVendorContact>().withDefault([
+ { id: "createdAt", desc: true }, // createdAt 기준 내림차순
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 검색 키워드
+ search: parseAsString.withDefault(""),
+
+ // 특정 필드 검색
+ contactName: parseAsString.withDefault(""),
+ contactPosition: parseAsString.withDefault(""),
+ contactEmail: parseAsString.withDefault(""),
+ contactPhone: parseAsString.withDefault(""),
+});
+
+export const searchParamsItemCache = createSearchParamsCache({
+ // 공통 플래그
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+
+ // 페이징
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 정렬
+ sort: getSortingStateParser<TechVendorItemsView>().withDefault([
+ { id: "createdAt", desc: true }, // createdAt 기준 내림차순
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 검색 키워드
+ search: parseAsString.withDefault(""),
+
+ // 특정 필드 검색
+ itemName: parseAsString.withDefault(""),
+ itemCode: parseAsString.withDefault(""),
+});
+
+export const searchParamsPossibleItemsCache = createSearchParamsCache({
+ // 공통 플래그
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+
+ // 페이징
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 정렬
+ sort: getSortingStateParser<TechVendorPossibleItem>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 검색 키워드
+ search: parseAsString.withDefault(""),
+
+ // 개별 필터 필드들
+ itemCode: parseAsString.withDefault(""),
+ workType: parseAsString.withDefault(""),
+ itemList: parseAsString.withDefault(""),
+ shipTypes: parseAsString.withDefault(""),
+ subItemList: parseAsString.withDefault(""),
+});
+
+// 기술영업 벤더 기본 정보 업데이트 스키마
+export const updateTechVendorSchema = z.object({
+ vendorName: z.string().min(1, "업체명은 필수 입력사항입니다"),
+ vendorCode: z.string().optional(),
+ address: z.string().optional(),
+ country: z.string().optional(),
+ countryEng: z.string().optional(),
+ countryFab: z.string().optional(),
+ phone: z.string().optional(),
+ email: z.string().email("유효한 이메일 주소를 입력해주세요").optional(),
+ website: z.string().optional(),
+ techVendorType: z.union([
+ z.array(z.enum(VENDOR_TYPES)).min(1, "최소 하나의 벤더 타입을 선택해주세요"),
+ z.string().min(1, "벤더 타입을 선택해주세요")
+ ]).optional(),
+ status: z.enum(techVendors.status.enumValues).optional(),
+ // 에이전트 정보
+ agentName: z.string().optional(),
+ agentEmail: z.string().email("유효한 이메일 주소를 입력해주세요").optional().or(z.literal("")),
+ agentPhone: z.string().optional(),
+ // 대표자 정보
+ representativeName: z.string().optional(),
+ representativeEmail: z.string().email("유효한 이메일 주소를 입력해주세요").optional().or(z.literal("")),
+ representativePhone: z.string().optional(),
+ representativeBirth: z.string().optional(),
+ userId: z.number().optional(),
+ comment: z.string().optional(),
+});
+
+// 연락처 스키마
+const contactSchema = z.object({
+ id: z.number().optional(),
+ contactName: z
+ .string()
+ .min(1, "Contact name is required")
+ .max(255, "Max length 255"),
+ contactPosition: z.string().max(100).optional(),
+ contactEmail: z.string().email("Invalid email").max(255),
+ contactCountry: z.string().max(100).optional(),
+ contactPhone: z.string().max(50).optional(),
+ isPrimary: z.boolean().default(false).optional()
+});
+
+// 기술영업 벤더 생성 스키마
+export const createTechVendorSchema = z
+ .object({
+ vendorName: z
+ .string()
+ .min(1, "Vendor name is required")
+ .max(255, "Max length 255"),
+
+ email: z.string().email("Invalid email").max(255),
+ // 나머지 optional
+ vendorCode: z.string().max(100, "Max length 100").optional(),
+ address: z.string().optional(),
+ country: z.string()
+ .min(1, "국가 선택은 필수입니다.")
+ .max(100, "Max length 100"),
+ phone: z.string().max(50, "Max length 50").optional(),
+ website: z.string().max(255).optional(),
+
+ files: z.any().optional(),
+ status: z.enum(techVendors.status.enumValues).default("ACTIVE"),
+ techVendorType: z.union([
+ z.array(z.enum(VENDOR_TYPES)).min(1, "최소 하나의 벤더 타입을 선택해주세요"),
+ z.string().min(1, "벤더 타입을 선택해주세요")
+ ]).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(),
+ taxId: z.string().min(1, { message: "사업자등록번호를 입력해주세요" }),
+
+ items: z.string().min(1, { message: "공급품목을 입력해주세요" }),
+
+ contacts: z
+ .array(contactSchema)
+ .nonempty("At least one contact is required.")
+ })
+ .superRefine((data, ctx) => {
+ if (data.country === "KR") {
+ // 1) 대표자 정보가 누락되면 각각 에러 발생
+ if (!data.representativeName) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["representativeName"],
+ message: "대표자 이름은 한국(KR) 업체일 경우 필수입니다.",
+ })
+ }
+ if (!data.representativeBirth) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["representativeBirth"],
+ message: "대표자 생년월일은 한국(KR) 업체일 경우 필수입니다.",
+ })
+ }
+ if (!data.representativeEmail) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["representativeEmail"],
+ message: "대표자 이메일은 한국(KR) 업체일 경우 필수입니다.",
+ })
+ }
+ if (!data.representativePhone) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["representativePhone"],
+ message: "대표자 전화번호는 한국(KR) 업체일 경우 필수입니다.",
+ })
+ }
+
+ }
+ });
+
+// 연락처 생성 스키마
+export const createTechVendorContactSchema = z.object({
+ vendorId: z.number(),
+ contactName: z.string()
+ .min(1, "Contact name is required")
+ .max(255, "Max length 255"),
+ contactPosition: z.string().max(100, "Max length 100"),
+ contactEmail: z.string().email(),
+ contactPhone: z.string().max(50, "Max length 50").optional(),
+ contactCountry: z.string().max(100, "Max length 100").optional(),
+ isPrimary: z.boolean(),
+});
+
+// 연락처 업데이트 스키마
+export const updateTechVendorContactSchema = z.object({
+ contactName: z.string()
+ .min(1, "Contact name is required")
+ .max(255, "Max length 255"),
+ contactPosition: z.string().max(100, "Max length 100").optional(),
+ contactEmail: z.string().email().optional(),
+ contactPhone: z.string().max(50, "Max length 50").optional(),
+ contactCountry: z.string().max(100, "Max length 100").optional(),
+ isPrimary: z.boolean().optional(),
+});
+
+// 아이템 생성 스키마
+export const createTechVendorItemSchema = z.object({
+ vendorId: z.number(),
+ itemCode: z.string().max(100, "Max length 100"),
+ itemList: z.string().min(1, "Item list is required").max(255, "Max length 255"),
+});
+
+// 아이템 업데이트 스키마
+export const updateTechVendorItemSchema = z.object({
+ itemList: z.string().optional(),
+ itemCode: z.string().max(100, "Max length 100"),
+});
+
+// Possible Items 생성 스키마
+export const createTechVendorPossibleItemSchema = z.object({
+ vendorId: z.number(),
+ itemCode: z.string().min(1, "아이템 코드는 필수입니다"),
+ workType: z.string().optional(),
+ shipTypes: z.string().optional(),
+ itemList: z.string().optional(),
+ subItemList: z.string().optional(),
+});
+
+// Possible Items 업데이트 스키마
+export const updateTechVendorPossibleItemSchema = createTechVendorPossibleItemSchema.extend({
+ id: z.number(),
+});
+
+export const searchParamsRfqHistoryCache = createSearchParamsCache({
+ // 공통 플래그
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+
+ // 페이징
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 정렬 (RFQ 히스토리에 맞춰)
+ sort: getSortingStateParser<{
+ id: number;
+ rfqCode: string | null;
+ description: string | null;
+ projectCode: string | null;
+ projectName: string | null;
+ projectType: string | null; // 프로젝트 타입 추가
+ status: string;
+ totalAmount: string | null;
+ currency: string | null;
+ dueDate: Date | null;
+ createdAt: Date;
+ quotationCode: string | null;
+ submittedAt: Date | null;
+ }>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 검색 키워드
+ search: parseAsString.withDefault(""),
+
+ // RFQ 히스토리 특화 필드
+ rfqCode: parseAsString.withDefault(""),
+ description: parseAsString.withDefault(""),
+ projectCode: parseAsString.withDefault(""),
+ projectName: parseAsString.withDefault(""),
+ projectType: parseAsStringEnum(["SHIP", "TOP", "HULL"]), // 프로젝트 타입 필터 추가
+ status: parseAsStringEnum(["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"]),
+});
+
+// 타입 내보내기
+export type GetTechVendorsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
+export type GetTechVendorContactsSchema = Awaited<ReturnType<typeof searchParamsContactCache.parse>>
+export type GetTechVendorItemsSchema = Awaited<ReturnType<typeof searchParamsItemCache.parse>>
+export type GetTechVendorPossibleItemsSchema = Awaited<ReturnType<typeof searchParamsPossibleItemsCache.parse>>
+export type GetTechVendorRfqHistorySchema = Awaited<ReturnType<typeof searchParamsRfqHistoryCache.parse>>
+
+export type UpdateTechVendorSchema = z.infer<typeof updateTechVendorSchema>
+export type CreateTechVendorSchema = z.infer<typeof createTechVendorSchema>
+export type CreateTechVendorContactSchema = z.infer<typeof createTechVendorContactSchema>
+export type UpdateTechVendorContactSchema = z.infer<typeof updateTechVendorContactSchema>
+export type CreateTechVendorItemSchema = z.infer<typeof createTechVendorItemSchema>
+export type UpdateTechVendorItemSchema = z.infer<typeof updateTechVendorItemSchema>
+export type CreateTechVendorPossibleItemSchema = z.infer<typeof createTechVendorPossibleItemSchema>
+export type UpdateTechVendorPossibleItemSchema = z.infer<typeof updateTechVendorPossibleItemSchema>
\ No newline at end of file |
