summaryrefslogtreecommitdiff
path: root/lib/vendors
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-03-26 00:37:41 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-03-26 00:37:41 +0000
commite0dfb55c5457aec489fc084c4567e791b4c65eb1 (patch)
tree68543a65d88f5afb3a0202925804103daa91bc6f /lib/vendors
3/25 까지의 대표님 작업사항
Diffstat (limited to 'lib/vendors')
-rw-r--r--lib/vendors/contacts-table/add-contact-dialog.tsx175
-rw-r--r--lib/vendors/contacts-table/contact-table-columns.tsx195
-rw-r--r--lib/vendors/contacts-table/contact-table-toolbar-actions.tsx106
-rw-r--r--lib/vendors/contacts-table/contact-table.tsx87
-rw-r--r--lib/vendors/contacts-table/feature-flags-provider.tsx108
-rw-r--r--lib/vendors/items-table/add-item-dialog.tsx289
-rw-r--r--lib/vendors/items-table/feature-flags-provider.tsx108
-rw-r--r--lib/vendors/items-table/item-table-columns.tsx197
-rw-r--r--lib/vendors/items-table/item-table-toolbar-actions.tsx106
-rw-r--r--lib/vendors/items-table/item-table.tsx85
-rw-r--r--lib/vendors/repository.ts282
-rw-r--r--lib/vendors/rfq-history-table/feature-flags-provider.tsx108
-rw-r--r--lib/vendors/rfq-history-table/rfq-history-table-columns.tsx223
-rw-r--r--lib/vendors/rfq-history-table/rfq-history-table-toolbar-actions.tsx136
-rw-r--r--lib/vendors/rfq-history-table/rfq-history-table.tsx156
-rw-r--r--lib/vendors/rfq-history-table/rfq-items-table-dialog.tsx98
-rw-r--r--lib/vendors/service.ts1345
-rw-r--r--lib/vendors/table/approve-vendor-dialog.tsx150
-rw-r--r--lib/vendors/table/attachmentButton.tsx69
-rw-r--r--lib/vendors/table/feature-flags-provider.tsx108
-rw-r--r--lib/vendors/table/request-vendor-pg-dialog.tsx150
-rw-r--r--lib/vendors/table/send-vendor-dialog.tsx150
-rw-r--r--lib/vendors/table/update-vendor-sheet.tsx270
-rw-r--r--lib/vendors/table/vendors-table-columns.tsx279
-rw-r--r--lib/vendors/table/vendors-table-floating-bar.tsx241
-rw-r--r--lib/vendors/table/vendors-table-toolbar-actions.tsx97
-rw-r--r--lib/vendors/table/vendors-table.tsx121
-rw-r--r--lib/vendors/validations.ts341
28 files changed, 5780 insertions, 0 deletions
diff --git a/lib/vendors/contacts-table/add-contact-dialog.tsx b/lib/vendors/contacts-table/add-contact-dialog.tsx
new file mode 100644
index 00000000..5376583a
--- /dev/null
+++ b/lib/vendors/contacts-table/add-contact-dialog.tsx
@@ -0,0 +1,175 @@
+"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 {
+ createVendorContactSchema,
+ type CreateVendorContactSchema,
+} from "../validations"
+import { createVendorContact } from "../service"
+
+interface AddContactDialogProps {
+ vendorId: number
+}
+
+export function AddContactDialog({ vendorId }: AddContactDialogProps) {
+ const [open, setOpen] = React.useState(false)
+
+ // react-hook-form 세팅
+ const form = useForm<CreateVendorContactSchema>({
+ resolver: zodResolver(createVendorContactSchema),
+ defaultValues: {
+ // vendorId는 form에 표시할 필요가 없다면 hidden으로 관리하거나, submit 시 추가
+ vendorId,
+ contactName: "",
+ contactPosition: "",
+ contactEmail: "",
+ contactPhone: "",
+ isPrimary: false,
+ },
+ })
+
+ async function onSubmit(data: CreateVendorContactSchema) {
+ // 혹은 여기서 data.vendorId = vendorId; 해줘도 됨
+ const result = await createVendorContact(data)
+ if (result.error) {
+ alert(`에러: ${result.error}`)
+ return
+ }
+ // 성공 시 모달 닫고 폼 리셋
+ 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>
+ )}
+ />
+
+ {/* 단순 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/vendors/contacts-table/contact-table-columns.tsx b/lib/vendors/contacts-table/contact-table-columns.tsx
new file mode 100644
index 00000000..f80fae33
--- /dev/null
+++ b/lib/vendors/contacts-table/contact-table-columns.tsx
@@ -0,0 +1,195 @@
+"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 { 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,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
+
+import { VendorContact, vendors } from "@/db/schema/vendors"
+import { modifyVendor } from "../service"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { vendorContactsColumnsConfig } from "@/config/vendorContactsColumnsConfig"
+
+
+
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorContact> | null>>;
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorContact>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<VendorContact> = {
+ 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<VendorContact> = {
+ 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-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<VendorContact>[] }
+ const groupMap: Record<string, ColumnDef<VendorContact>[]> = {}
+
+ vendorContactsColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<VendorContact> = {
+ 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<VendorContact>[] = []
+
+ // 순서를 고정하고 싶다면 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/vendors/contacts-table/contact-table-toolbar-actions.tsx b/lib/vendors/contacts-table/contact-table-toolbar-actions.tsx
new file mode 100644
index 00000000..8aef6953
--- /dev/null
+++ b/lib/vendors/contacts-table/contact-table-toolbar-actions.tsx
@@ -0,0 +1,106 @@
+"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"
+
+
+// 만약 서버 액션이나 API 라우트를 이용해 업로드 처리한다면 import
+import { importTasksExcel } from "@/lib/tasks/service" // 예시
+import { VendorContact } from "@/db/schema/vendors"
+import { AddContactDialog } from "./add-contact-dialog"
+
+interface VendorsTableToolbarActionsProps {
+ table: Table<VendorContact>
+ vendorId: number
+}
+
+export function VendorsTableToolbarActions({ table,vendorId }: VendorsTableToolbarActionsProps) {
+ // 파일 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 (err) {
+ 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: "tasks",
+ 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/vendors/contacts-table/contact-table.tsx b/lib/vendors/contacts-table/contact-table.tsx
new file mode 100644
index 00000000..2991187e
--- /dev/null
+++ b/lib/vendors/contacts-table/contact-table.tsx
@@ -0,0 +1,87 @@
+"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 { getVendorContacts, } from "../service"
+import { VendorContact, vendors } from "@/db/schema/vendors"
+import { VendorsTableToolbarActions } from "./contact-table-toolbar-actions"
+
+interface VendorsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getVendorContacts>>,
+ ]
+ >,
+ vendorId:number
+}
+
+export function VendorContactsTable({ promises , vendorId}: VendorsTableProps) {
+ const { featureFlags } = useFeatureFlags()
+
+ // Suspense로 받아온 데이터
+ const [{ data, pageCount }] = React.use(promises)
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorContact> | null>(null)
+
+ // getColumns() 호출 시, router를 주입
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ const filterFields: DataTableFilterField<VendorContact>[] = [
+
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField<VendorContact>[] = [
+ { 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}
+ >
+ <VendorsTableToolbarActions table={table} vendorId={vendorId} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/vendors/contacts-table/feature-flags-provider.tsx b/lib/vendors/contacts-table/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/vendors/contacts-table/feature-flags-provider.tsx
@@ -0,0 +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>
+ )
+}
diff --git a/lib/vendors/items-table/add-item-dialog.tsx b/lib/vendors/items-table/add-item-dialog.tsx
new file mode 100644
index 00000000..6bbcc436
--- /dev/null
+++ b/lib/vendors/items-table/add-item-dialog.tsx
@@ -0,0 +1,289 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Check, ChevronsUpDown } from "lucide-react"
+
+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 {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command"
+import { cn } from "@/lib/utils"
+
+import {
+ createVendorItemSchema,
+ type CreateVendorItemSchema,
+} from "../validations"
+
+import { createVendorItem, getItemsForVendor, ItemDropdownOption } from "../service"
+
+interface AddItemDialogProps {
+ vendorId: number
+}
+
+export function AddItemDialog({ vendorId }: AddItemDialogProps) {
+ const [open, setOpen] = React.useState(false)
+ const [commandOpen, setCommandOpen] = React.useState(false)
+ const [items, setItems] = React.useState<ItemDropdownOption[]>([])
+ const [filteredItems, setFilteredItems] = React.useState<ItemDropdownOption[]>([])
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [searchTerm, setSearchTerm] = React.useState("")
+
+ // 선택된 아이템의 정보를 보여주기 위한 상태
+ const [selectedItem, setSelectedItem] = React.useState<{
+ itemName: string;
+ description: string;
+ } | null>(null)
+
+ // react-hook-form 세팅 - 서버로 보낼 값은 vendorId와 itemCode만
+ const form = useForm<CreateVendorItemSchema>({
+ resolver: zodResolver(createVendorItemSchema),
+ defaultValues: {
+ vendorId,
+ itemCode: "",
+ },
+ })
+
+ console.log(vendorId)
+
+ // 아이템 목록 가져오기 (한 번만 호출)
+ const fetchItems = React.useCallback(async () => {
+ if (items.length > 0) return // 이미 로드된 경우 스킵
+
+ setIsLoading(true)
+ try {
+ const result = await getItemsForVendor(vendorId)
+ if (result.data) {
+ setItems(result.data)
+ setFilteredItems(result.data)
+ }
+ } catch (error) {
+ console.error("Failed to fetch items:", error)
+ } finally {
+ setIsLoading(false)
+ }
+ }, [items.length])
+
+ // 팝오버 열릴 때 아이템 목록 로드
+ React.useEffect(() => {
+ if (commandOpen) {
+ fetchItems()
+ }
+ }, [commandOpen, fetchItems])
+
+ // 클라이언트 사이드 필터링
+ React.useEffect(() => {
+ if (!items.length) return
+
+ if (!searchTerm.trim()) {
+ setFilteredItems(items)
+ return
+ }
+
+ const lowerSearch = searchTerm.toLowerCase()
+ const filtered = items.filter(item =>
+ item.itemCode.toLowerCase().includes(lowerSearch) ||
+ item.itemName.toLowerCase().includes(lowerSearch) ||
+ (item.description && item.description.toLowerCase().includes(lowerSearch))
+ )
+
+ setFilteredItems(filtered)
+ }, [searchTerm, items])
+
+ // 선택된 아이템 데이터로 폼 업데이트
+ const handleSelectItem = (item: ItemDropdownOption) => {
+ // 폼에는 itemCode만 설정
+ form.setValue("itemCode", item.itemCode)
+
+ // 나머지 정보는 표시용 상태에 저장
+ setSelectedItem({
+ itemName: item.itemName,
+ description: item.description || "",
+ })
+
+ setCommandOpen(false)
+ }
+
+ // 폼 제출 - itemCode만 서버로 전송
+ async function onSubmit(data: CreateVendorItemSchema) {
+ // 서버에는 vendorId와 itemCode만 전송됨
+ const result = await createVendorItem(data)
+ console.log(result)
+ if (result.error) {
+ alert(`에러: ${result.error}`)
+ return
+ }
+ // 성공 시 모달 닫고 폼 리셋
+ form.reset()
+ setSelectedItem(null)
+ setOpen(false)
+ }
+
+ // 모달 열림/닫힘 핸들
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ // 닫힐 때 폼 리셋
+ form.reset()
+ setSelectedItem(null)
+ }
+ setOpen(nextOpen)
+ }
+
+ // 현재 선택된 아이템 코드
+ const selectedItemCode = form.watch("itemCode")
+
+ // 선택된 아이템 코드가 있으면 상세 정보 표시를 위한 아이템 찾기
+ const displayItemCode = selectedItemCode || "아이템 선택..."
+ const displayItemName = selectedItem?.itemName || ""
+
+ return (
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ {/* 모달 열기 버튼 */}
+ <DialogTrigger asChild>
+ <Button variant="default" size="sm">
+ Add Item
+ </Button>
+ </DialogTrigger>
+
+ <DialogContent className="max-h-[90vh] overflow-hidden flex flex-col">
+ <DialogHeader>
+ <DialogTitle>Create New Item</DialogTitle>
+ <DialogDescription>
+ 아이템을 선택한 후 <b>Create</b> 버튼을 누르세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* shadcn/ui Form + react-hook-form */}
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 overflow-hidden">
+ <div className="space-y-4 py-4 flex-1 overflow-y-auto">
+
+ {/* 아이템 선택 */}
+ <div>
+ <FormLabel className="text-sm font-medium">아이템 선택</FormLabel>
+ <Popover open={commandOpen} onOpenChange={setCommandOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={commandOpen}
+ className="w-full justify-between mt-1"
+ >
+ {selectedItemCode
+ ? `${selectedItemCode} - ${displayItemName}`
+ : "아이템 선택..."}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput
+ placeholder="아이템 코드/이름 검색..."
+ onValueChange={setSearchTerm}
+ />
+ <CommandList className="max-h-[200px]">
+ <CommandEmpty>검색 결과가 없습니다</CommandEmpty>
+ {isLoading ? (
+ <div className="py-6 text-center text-sm">로딩 중...</div>
+ ) : (
+ <CommandGroup>
+ {filteredItems.map((item) => (
+ <CommandItem
+ key={item.itemCode}
+ value={`${item.itemCode} ${item.itemName}`}
+ onSelect={() => handleSelectItem(item)}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ selectedItemCode === item.itemCode
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ <span className="font-medium">{item.itemCode}</span>
+ <span className="ml-2 text-gray-500 truncate">- {item.itemName}</span>
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ )}
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ </div>
+
+ {/* 아이템 정보 영역 - 선택된 경우에만 표시 */}
+ {selectedItem && (
+ <div className="rounded-md border p-3 mt-4 overflow-hidden">
+ <h3 className="font-medium text-sm mb-2">선택된 아이템 정보</h3>
+
+ {/* Item Code - readonly (hidden field) */}
+ <FormField
+ control={form.control}
+ name="itemCode"
+ render={({ field }) => (
+ <FormItem className="hidden">
+ <FormControl>
+ <Input {...field} />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+
+ {/* Item Name (표시용) */}
+ <div className="mb-2">
+ <p className="text-xs font-medium text-gray-500">Item Name</p>
+ <p className="text-sm mt-0.5 break-words">{selectedItem.itemName}</p>
+ </div>
+
+ {/* Description (표시용) */}
+ {selectedItem.description && (
+ <div>
+ <p className="text-xs font-medium text-gray-500">Description</p>
+ <p className="text-sm mt-0.5 break-words max-h-20 overflow-y-auto">{selectedItem.description}</p>
+ </div>
+ )}
+ </div>
+ )}
+
+ </div>
+
+ <DialogFooter className="flex-shrink-0 pt-2">
+ <Button type="button" variant="outline" onClick={() => setOpen(false)}>
+ Cancel
+ </Button>
+ <Button
+ type="submit"
+ disabled={form.formState.isSubmitting || !selectedItemCode}
+ >
+ Create
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/vendors/items-table/feature-flags-provider.tsx b/lib/vendors/items-table/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/vendors/items-table/feature-flags-provider.tsx
@@ -0,0 +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>
+ )
+}
diff --git a/lib/vendors/items-table/item-table-columns.tsx b/lib/vendors/items-table/item-table-columns.tsx
new file mode 100644
index 00000000..b5d26434
--- /dev/null
+++ b/lib/vendors/items-table/item-table-columns.tsx
@@ -0,0 +1,197 @@
+"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 { 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,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
+
+import { VendorItemsView, vendors } from "@/db/schema/vendors"
+import { modifyVendor } from "../service"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { vendorItemsColumnsConfig } from "@/config/vendorItemsColumnsConfig"
+
+
+
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorItemsView> | null>>;
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorItemsView>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<VendorItemsView> = {
+ 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<VendorItemsView> = {
+ 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-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<VendorItemsView>[] }
+ const groupMap: Record<string, ColumnDef<VendorItemsView>[]> = {}
+
+ vendorItemsColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<VendorItemsView> = {
+ accessorKey: cfg.id,
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
+ ),
+ minSize: cfg.minWidth,
+ size: cfg.defaultWidth,
+ 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<VendorItemsView>[] = []
+
+ // 순서를 고정하고 싶다면 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/vendors/items-table/item-table-toolbar-actions.tsx b/lib/vendors/items-table/item-table-toolbar-actions.tsx
new file mode 100644
index 00000000..f7bd2bf6
--- /dev/null
+++ b/lib/vendors/items-table/item-table-toolbar-actions.tsx
@@ -0,0 +1,106 @@
+"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"
+
+
+// 만약 서버 액션이나 API 라우트를 이용해 업로드 처리한다면 import
+import { importTasksExcel } from "@/lib/tasks/service" // 예시
+import { VendorItemsView } from "@/db/schema/vendors"
+import { AddItemDialog } from "./add-item-dialog"
+
+interface VendorsTableToolbarActionsProps {
+ table: Table<VendorItemsView>
+ vendorId: number
+}
+
+export function VendorsTableToolbarActions({ table,vendorId }: VendorsTableToolbarActionsProps) {
+ // 파일 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 (err) {
+ toast.error("파일 업로드 중 오류가 발생했습니다.")
+
+ }
+ }
+
+ function handleImportClick() {
+ // 숨겨진 <input type="file" /> 요소를 클릭
+ fileInputRef.current?.click()
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+
+ <AddItemDialog 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: "tasks",
+ 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/vendors/items-table/item-table.tsx b/lib/vendors/items-table/item-table.tsx
new file mode 100644
index 00000000..d8cd0ea2
--- /dev/null
+++ b/lib/vendors/items-table/item-table.tsx
@@ -0,0 +1,85 @@
+"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 "./item-table-columns"
+import { getVendorItems, } from "../service"
+import { VendorItemsView, vendors } from "@/db/schema/vendors"
+import { VendorsTableToolbarActions } from "./item-table-toolbar-actions"
+
+interface VendorsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getVendorItems>>,
+ ]
+ >,
+ vendorId:number
+}
+
+export function VendorItemsTable({ promises , vendorId}: VendorsTableProps) {
+ const { featureFlags } = useFeatureFlags()
+
+ // Suspense로 받아온 데이터
+ const [{ data, pageCount }] = React.use(promises)
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorItemsView> | null>(null)
+
+ // getColumns() 호출 시, router를 주입
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ const filterFields: DataTableFilterField<VendorItemsView>[] = [
+
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField<VendorItemsView>[] = [
+ { id: "itemName", label: "Item Name", type: "text" },
+ { id: "itemCode", label: "Item Code", type: "text" },
+ { id: "description", label: "Description", 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.itemCode),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable
+ table={table}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <VendorsTableToolbarActions table={table} vendorId={vendorId} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/vendors/repository.ts b/lib/vendors/repository.ts
new file mode 100644
index 00000000..ff195932
--- /dev/null
+++ b/lib/vendors/repository.ts
@@ -0,0 +1,282 @@
+// src/lib/vendors/repository.ts
+
+import { and, eq, inArray, count, gt, AnyColumn, SQLWrapper, SQL} from "drizzle-orm";
+import { PgTransaction } from "drizzle-orm/pg-core";
+import { VendorContact, vendorContacts, vendorItemsView, vendorPossibleItems, vendors, type Vendor } from "@/db/schema/vendors";
+import db from '@/db/db';
+import { items } from "@/db/schema/items";
+import { rfqs,rfqItems, rfqEvaluations, vendorResponses } from "@/db/schema/rfq";
+import { sql } from "drizzle-orm";
+
+interface SelectVendorsOptions {
+ where?: any;
+ orderBy?: any[];
+ offset?: number;
+ limit?: number;
+}
+export declare function asc(column: AnyColumn | SQLWrapper): SQL;
+export declare function desc(column: AnyColumn | SQLWrapper): SQL;
+export type NewVendorContact = typeof vendorContacts.$inferInsert
+export type NewVendorItem = typeof vendorPossibleItems.$inferInsert
+
+/**
+ * 1) SELECT (목록 조회)
+ */
+export async function selectVendors(
+ tx: PgTransaction<any, any, any>,
+ { where, orderBy, offset, limit }: SelectVendorsOptions
+) {
+ return tx
+ .select()
+ .from(vendors)
+ .where(where ?? undefined)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset ?? 0)
+ .limit(limit ?? 20);
+}
+
+/**
+ * 2) COUNT
+ */
+export async function countVendors(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+ ) {
+ const res = await tx.select({ count: count() }).from(vendors).where(where);
+ return res[0]?.count ?? 0;
+ }
+
+
+/**
+ * 3) INSERT (단일 벤더 생성)
+ * - id/createdAt/updatedAt은 DB default 사용
+ * - 반환값은 "생성된 레코드" 배열 ([newVendor])
+ */
+export async function insertVendor(
+ tx: PgTransaction<any, any, any>,
+ data: Omit<Vendor, "id" | "createdAt" | "updatedAt">
+) {
+ return tx.insert(vendors).values(data).returning();
+}
+
+/**
+ * 4) UPDATE (단일 벤더)
+ */
+export async function updateVendor(
+ tx: PgTransaction<any, any, any>,
+ id: string,
+ data: Partial<Vendor>
+) {
+ return tx
+ .update(vendors)
+ .set(data)
+ .where(eq(vendors.id, Number(id)))
+ .returning();
+}
+
+/**
+ * 5) UPDATE (복수 벤더)
+ * - 여러 개의 id를 받아 일괄 업데이트
+ */
+export async function updateVendors(
+ tx: PgTransaction<any, any, any>,
+ ids: string[],
+ data: Partial<Vendor>
+) {
+ const numericIds = ids.map((i) => Number(i));
+ return tx
+ .update(vendors)
+ .set(data)
+ .where(inArray(vendors.id, numericIds))
+ .returning();
+}
+
+/** status 기준 groupBy */
+export async function groupByStatus(
+ tx: PgTransaction<any, any, any>,
+) {
+ return tx
+ .select({
+ status: vendors.status,
+ count: count(),
+ })
+ .from(vendors)
+ .groupBy(vendors.status)
+ .having(gt(count(), 0));
+}
+
+
+// ID로 사용자 조회
+export const getVendorById = async (id: number): Promise<Vendor | null> => {
+ const vendorsRes = await db.select().from(vendors).where(eq(vendors.id, id)).execute();
+ if (vendorsRes.length === 0) return null;
+
+ const vendor = vendorsRes[0];
+ return vendor
+};
+
+export const getVendorContactsById = async (id: number): Promise<VendorContact | null> => {
+ const contactsRes = await db.select().from(vendorContacts).where(eq(vendorContacts.vendorId, id)).execute();
+ if (contactsRes.length === 0) return null;
+
+ const contact = contactsRes[0];
+ return contact
+};
+
+export async function selectVendorContacts(
+ tx: PgTransaction<any, any, any>,
+ params: {
+ where?: any; // drizzle-orm의 조건식 (and, eq...) 등
+ orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[];
+ offset?: number;
+ limit?: number;
+ }
+) {
+ const { where, orderBy, offset = 0, limit = 10 } = params;
+
+ return tx
+ .select()
+ .from(vendorContacts)
+ .where(where)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit);
+}
+
+export async function countVendorContacts(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const res = await tx.select({ count: count() }).from(vendorContacts).where(where);
+ return res[0]?.count ?? 0;
+}
+
+export async function insertVendorContact(
+ tx: PgTransaction<any, any, any>,
+ data: NewVendorContact // DB와 동일한 insert 가능한 타입
+) {
+ // returning() 사용 시 배열로 돌아오므로 [0]만 리턴
+ return tx
+ .insert(vendorContacts)
+ .values(data)
+ .returning({ id: vendorContacts.id, createdAt: vendorContacts.createdAt });
+}
+
+
+export async function selectVendorItems(
+ tx: PgTransaction<any, any, any>,
+ params: {
+ where?: any; // drizzle-orm의 조건식 (and, eq...) 등
+ orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[];
+ offset?: number;
+ limit?: number;
+ }
+) {
+ const { where, orderBy, offset = 0, limit = 10 } = params;
+
+ return tx
+ .select({
+ // vendor_possible_items cols
+ vendorItemId: vendorItemsView.vendorItemId,
+ vendorId: vendorItemsView.vendorId,
+ itemCode: vendorItemsView.itemCode,
+ createdAt: vendorItemsView.createdAt,
+ updatedAt: vendorItemsView.updatedAt,
+ itemName: vendorItemsView.itemName,
+ description: vendorItemsView.description,
+ })
+ .from(vendorItemsView)
+ .where(where ?? undefined)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit);
+}
+
+export async function countVendorItems(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const res = await tx.select({ count: count() }).from(vendorItemsView).where(where);
+ return res[0]?.count ?? 0;
+}
+
+export async function insertVendorItem(
+ tx: PgTransaction<any, any, any>,
+ data: NewVendorItem // DB와 동일한 insert 가능한 타입
+) {
+ // returning() 사용 시 배열로 돌아오므로 [0]만 리턴
+ return tx
+ .insert(vendorPossibleItems)
+ .values(data)
+ .returning({ id: vendorPossibleItems.id, createdAt: vendorPossibleItems.createdAt });
+}
+
+export async function selectRfqHistory(
+ tx: PgTransaction<any, any, any>,
+ { where, orderBy, offset, limit }: SelectVendorsOptions
+) {
+ return tx
+ .select({
+ // RFQ 기본 정보
+ id: rfqs.id,
+ rfqCode: rfqs.rfqCode,
+
+ description: rfqs.description,
+ dueDate: rfqs.dueDate,
+ status: rfqs.status,
+ createdAt: rfqs.createdAt,
+
+
+ // Item 정보 (집계)
+ itemCount: sql<number>`count(distinct ${rfqItems.id})::integer`,
+
+ // 평가 정보
+ tbeResult: sql<string>`
+ (select result from ${rfqEvaluations}
+ where rfq_id = ${rfqs.id}
+ and vendor_id = ${vendorResponses.vendorId}
+ and eval_type = 'TBE'
+ limit 1)`,
+ cbeResult: sql<string>`
+ (select result from ${rfqEvaluations}
+ where rfq_id = ${rfqs.id}
+ and vendor_id = ${vendorResponses.vendorId}
+ and eval_type = 'CBE'
+ limit 1)`
+ })
+ .from(rfqs)
+ .innerJoin(vendorResponses, eq(rfqs.id, vendorResponses.rfqId))
+
+ .leftJoin(rfqItems, eq(rfqs.id, rfqItems.rfqId))
+ .where(where ?? undefined)
+ .groupBy(
+ rfqs.id,
+ rfqs.rfqCode,
+
+ rfqs.description,
+ rfqs.dueDate,
+ rfqs.status,
+ rfqs.createdAt,
+
+ vendorResponses.vendorId,
+
+ )
+ .orderBy(...(orderBy ?? []))
+ .offset(offset ?? 0)
+ .limit(limit ?? 20);
+}
+
+export async function countRfqHistory(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const [{ count }] = await tx
+ .select({
+ count: sql<number>`count(distinct ${rfqs.id})::integer`,
+ })
+ .from(rfqs)
+ .innerJoin(vendorResponses, eq(rfqs.id, vendorResponses.rfqId))
+ .where(where ?? undefined);
+
+ return count;
+}
diff --git a/lib/vendors/rfq-history-table/feature-flags-provider.tsx b/lib/vendors/rfq-history-table/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/vendors/rfq-history-table/feature-flags-provider.tsx
@@ -0,0 +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>
+ )
+}
diff --git a/lib/vendors/rfq-history-table/rfq-history-table-columns.tsx b/lib/vendors/rfq-history-table/rfq-history-table-columns.tsx
new file mode 100644
index 00000000..7e22e96a
--- /dev/null
+++ b/lib/vendors/rfq-history-table/rfq-history-table-columns.tsx
@@ -0,0 +1,223 @@
+"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 { 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,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
+
+import { VendorItem, vendors } from "@/db/schema/vendors"
+import { modifyVendor } from "../service"
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { getRFQStatusIcon } from "@/lib/tasks/utils"
+import { rfqHistoryColumnsConfig } from "@/config/rfqHistoryColumnsConfig"
+
+export interface RfqHistoryRow {
+ id: number;
+ rfqCode: string | null;
+ projectCode: string | null;
+ projectName: string | null;
+ description: string | null;
+ dueDate: Date;
+ status: "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED";
+ vendorStatus: string;
+ totalAmount: number | null;
+ currency: string | null;
+ leadTime: string | null;
+ itemCount: number;
+ tbeResult: string | null;
+ cbeResult: string | null;
+ createdAt: Date;
+ items: {
+ rfqId: number;
+ id: number;
+ itemCode: string;
+ description: string | null;
+ quantity: number | null;
+ uom: string | null;
+ }[];
+}
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<RfqHistoryRow> | null>>;
+ openItemsModal: (rfqId: number) => void;
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction, openItemsModal }: GetColumnsProps): ColumnDef<RfqHistoryRow>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<RfqHistoryRow> = {
+ 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<RfqHistoryRow> = {
+ 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) 일반 컬럼들
+ // ----------------------------------------------------------------
+ const basicColumns: ColumnDef<RfqHistoryRow>[] = rfqHistoryColumnsConfig.map((cfg) => {
+ const column: ColumnDef<RfqHistoryRow> = {
+ accessorKey: cfg.id,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
+ ),
+ size: cfg.size,
+ }
+
+ if (cfg.id === "description") {
+ column.cell = ({ row }) => {
+ const description = row.original.description
+ if (!description) return null
+ return (
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <div className="break-words whitespace-normal line-clamp-2">
+ {description}
+ </div>
+ </TooltipTrigger>
+ <TooltipContent side="bottom" className="max-w-[400px] whitespace-pre-wrap break-words">
+ {description}
+ </TooltipContent>
+ </Tooltip>
+ )
+ }
+ }
+
+ if (cfg.id === "status") {
+ column.cell = ({ row }) => {
+ const statusVal = row.original.status
+ if (!statusVal) return null
+ const Icon = getRFQStatusIcon(statusVal)
+ return (
+ <div className="flex items-center">
+ <Icon className="mr-2 size-4 text-muted-foreground" aria-hidden="true" />
+ <span className="capitalize">{statusVal}</span>
+ </div>
+ )
+ }
+ }
+
+ if (cfg.id === "totalAmount") {
+ column.cell = ({ row }) => {
+ const amount = row.original.totalAmount
+ const currency = row.original.currency
+ if (!amount || !currency) return null
+ return (
+ <div className="whitespace-nowrap">
+ {`${currency} ${amount.toLocaleString()}`}
+ </div>
+ )
+ }
+ }
+
+ if (cfg.id === "dueDate" || cfg.id === "createdAt") {
+ column.cell = ({ row }) => (
+ <div className="whitespace-nowrap">
+ {formatDate(row.getValue(cfg.id))}
+ </div>
+ )
+ }
+
+ return column
+ })
+
+ const itemsColumn: ColumnDef<RfqHistoryRow> = {
+ id: "items",
+ header: "Items",
+ cell: ({ row }) => {
+ const rfq = row.original;
+ const count = rfq.itemCount || 0;
+ return (
+ <Button variant="ghost" onClick={() => openItemsModal(rfq.id)}>
+ {count === 0 ? "No Items" : `${count} Items`}
+ </Button>
+ )
+ },
+ }
+
+ // ----------------------------------------------------------------
+ // 4) 최종 컬럼 배열
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ ...basicColumns,
+ itemsColumn,
+ actionsColumn,
+ ]
+} \ No newline at end of file
diff --git a/lib/vendors/rfq-history-table/rfq-history-table-toolbar-actions.tsx b/lib/vendors/rfq-history-table/rfq-history-table-toolbar-actions.tsx
new file mode 100644
index 00000000..46eaa6a6
--- /dev/null
+++ b/lib/vendors/rfq-history-table/rfq-history-table-toolbar-actions.tsx
@@ -0,0 +1,136 @@
+"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 { DataTableViewOptions } from "@/components/data-table/data-table-view-options"
+
+
+// 만약 서버 액션이나 API 라우트를 이용해 업로드 처리한다면 import
+import { importTasksExcel } from "@/lib/tasks/service" // 예시
+import { VendorItem } from "@/db/schema/vendors"
+// import { AddItemDialog } from "./add-item-dialog"
+
+interface RfqHistoryRow {
+ id: number;
+ rfqCode: string | null;
+ projectCode: string | null;
+ projectName: string | null;
+ description: string | null;
+ dueDate: Date;
+ status: "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED";
+ vendorStatus: string;
+ totalAmount: number | null;
+ currency: string | null;
+ leadTime: string | null;
+ itemCount: number;
+ tbeResult: string | null;
+ cbeResult: string | null;
+ createdAt: Date;
+ items: {
+ rfqId: number;
+ id: number;
+ itemCode: string;
+ description: string | null;
+ quantity: number | null;
+ uom: string | null;
+ }[];
+}
+
+interface RfqHistoryTableToolbarActionsProps {
+ table: Table<RfqHistoryRow>
+}
+
+export function RfqHistoryTableToolbarActions({
+ table,
+}: RfqHistoryTableToolbarActionsProps) {
+ // 파일 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 (err) {
+ toast.error("파일 업로드 중 오류가 발생했습니다.")
+
+ }
+ }
+
+ // function handleImportClick() {
+ // // 숨겨진 <input type="file" /> 요소를 클릭
+ // fileInputRef.current?.click()
+ // }
+
+ return (
+ <div className="flex items-center gap-2">
+ <DataTableViewOptions table={table} />
+
+ {/* 조회만 하는 모듈 */}
+ {/* <AddItemDialog 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: "rfq-history",
+ 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/vendors/rfq-history-table/rfq-history-table.tsx b/lib/vendors/rfq-history-table/rfq-history-table.tsx
new file mode 100644
index 00000000..71830303
--- /dev/null
+++ b/lib/vendors/rfq-history-table/rfq-history-table.tsx
@@ -0,0 +1,156 @@
+"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 { getColumns } from "./rfq-history-table-columns"
+import { getRfqHistory } from "../service"
+import { RfqHistoryTableToolbarActions } from "./rfq-history-table-toolbar-actions"
+import { RfqItemsTableDialog } from "./rfq-items-table-dialog"
+import { getRFQStatusIcon } from "@/lib/tasks/utils"
+import { TooltipProvider } from "@/components/ui/tooltip"
+
+export interface RfqHistoryRow {
+ id: number;
+ rfqCode: string | null;
+ projectCode: string | null;
+ projectName: string | null;
+ description: string | null;
+ dueDate: Date;
+ status: "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED";
+ vendorStatus: string;
+ totalAmount: number | null;
+ currency: string | null;
+ leadTime: string | null;
+ itemCount: number;
+ tbeResult: string | null;
+ cbeResult: string | null;
+ createdAt: Date;
+ items: {
+ rfqId: number;
+ id: number;
+ itemCode: string;
+ description: string | null;
+ quantity: number | null;
+ uom: string | null;
+ }[];
+}
+
+interface RfqHistoryTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getRfqHistory>>,
+ ]
+ >
+}
+
+export function VendorRfqHistoryTable({ promises }: RfqHistoryTableProps) {
+ const [{ data, pageCount }] = React.use(promises)
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqHistoryRow> | null>(null)
+
+ const [itemsModalOpen, setItemsModalOpen] = React.useState(false);
+ const [selectedRfq, setSelectedRfq] = React.useState<RfqHistoryRow | null>(null);
+
+ const openItemsModal = React.useCallback((rfqId: number) => {
+ const rfq = data.find(r => r.id === rfqId);
+ if (rfq) {
+ setSelectedRfq(rfq);
+ setItemsModalOpen(true);
+ }
+ }, [data]);
+
+ const columns = React.useMemo(() => getColumns({
+ setRowAction,
+ openItemsModal,
+ }), [setRowAction, openItemsModal]);
+
+ const filterFields: DataTableFilterField<RfqHistoryRow>[] = [
+ {
+ id: "rfqCode",
+ label: "RFQ Code",
+ placeholder: "Filter RFQ Code...",
+ },
+ {
+ id: "status",
+ label: "Status",
+ options: ["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"].map((status) => ({
+ label: toSentenceCase(status),
+ value: status,
+ icon: getRFQStatusIcon(status),
+ })),
+ },
+ {
+ id: "vendorStatus",
+ label: "Vendor Status",
+ placeholder: "Filter Vendor Status...",
+ }
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField<RfqHistoryRow>[] = [
+ { id: "rfqCode", label: "RFQ Code", type: "text" },
+ { id: "projectCode", label: "Project Code", type: "text" },
+ { id: "projectName", label: "Project Name", type: "text" },
+ {
+ id: "status",
+ label: "RFQ Status",
+ type: "multi-select",
+ options: ["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"].map((status) => ({
+ label: toSentenceCase(status),
+ value: status,
+ icon: getRFQStatusIcon(status),
+ })),
+ },
+ { id: "vendorStatus", label: "Vendor Status", type: "text" },
+ { id: "dueDate", label: "Due Date", type: "date" },
+ { id: "createdAt", label: "Created 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: true,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <TooltipProvider>
+ <DataTable
+ table={table}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <RfqHistoryTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ <RfqItemsTableDialog
+ open={itemsModalOpen}
+ onOpenChange={setItemsModalOpen}
+ items={selectedRfq?.items ?? []}
+ />
+ </TooltipProvider>
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/vendors/rfq-history-table/rfq-items-table-dialog.tsx b/lib/vendors/rfq-history-table/rfq-items-table-dialog.tsx
new file mode 100644
index 00000000..49a5d890
--- /dev/null
+++ b/lib/vendors/rfq-history-table/rfq-items-table-dialog.tsx
@@ -0,0 +1,98 @@
+"use client"
+
+import * as React from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { DataTable } from "@/components/data-table/data-table"
+import { useDataTable } from "@/hooks/use-data-table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
+
+interface RfqItem {
+ id: number
+ itemCode: string
+ description: string | null
+ quantity: number | null
+ uom: string | null
+}
+
+interface RfqItemsTableDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ items: RfqItem[]
+}
+
+export function RfqItemsTableDialog({
+ open,
+ onOpenChange,
+ items,
+}: RfqItemsTableDialogProps) {
+ const columns = React.useMemo<ColumnDef<RfqItem>[]>(
+ () => [
+ {
+ accessorKey: "itemCode",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="Item Code" />
+ ),
+ },
+ {
+ accessorKey: "description",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="Description" />
+ ),
+ cell: ({ row }) => row.getValue("description") || "-",
+ },
+ {
+ accessorKey: "quantity",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="Quantity" />
+ ),
+ cell: ({ row }) => {
+ const quantity = row.getValue("quantity") as number | null;
+ return (
+ <div className="text-center">
+ {quantity !== null ? quantity.toLocaleString() : "-"}
+ </div>
+ );
+ },
+ },
+ {
+ accessorKey: "uom",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="UoM" />
+ ),
+ cell: ({ row }) => row.getValue("uom") || "-",
+ },
+ ],
+ []
+ )
+
+ const { table } = useDataTable({
+ data: items,
+ columns,
+ pageCount: 1,
+ enablePinning: false,
+ enableAdvancedFilter: false,
+ })
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-3xl">
+ <DialogHeader>
+ <DialogTitle>RFQ Items</DialogTitle>
+ <DialogDescription>
+ Items included in this RFQ
+ </DialogDescription>
+ </DialogHeader>
+ <div className="mt-4">
+ <DataTable table={table} />
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts
new file mode 100644
index 00000000..2da16888
--- /dev/null
+++ b/lib/vendors/service.ts
@@ -0,0 +1,1345 @@
+"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택)
+
+import { revalidateTag, unstable_noStore } from "next/cache";
+import db from "@/db/db";
+import { vendorAttachments, VendorContact, vendorContacts, vendorItemsView, vendorPossibleItems, vendors, type Vendor } from "@/db/schema/vendors";
+import logger from '@/lib/logger';
+
+import { filterColumns } from "@/lib/filter-columns";
+import { unstable_cache } from "@/lib/unstable-cache";
+import { getErrorMessage } from "@/lib/handle-error";
+
+import {
+ selectVendors,
+ countVendors,
+ insertVendor,
+ updateVendor,
+ updateVendors, groupByStatus,
+ getVendorById,
+ getVendorContactsById,
+ selectVendorContacts,
+ countVendorContacts,
+ insertVendorContact,
+ selectVendorItems,
+ countVendorItems,
+ insertVendorItem,
+ countRfqHistory,
+ selectRfqHistory
+} from "./repository";
+
+import type {
+ CreateVendorSchema,
+ UpdateVendorSchema,
+ GetVendorsSchema,
+ GetVendorContactsSchema,
+ CreateVendorContactSchema,
+ GetVendorItemsSchema,
+ CreateVendorItemSchema,
+ GetRfqHistorySchema,
+} from "./validations";
+
+import { asc, desc, ilike, inArray, and, or, gte, lte, eq, isNull } from "drizzle-orm";
+import { rfqItems, rfqs, vendorRfqView } from "@/db/schema/rfq";
+import path from "path";
+import fs from "fs/promises";
+import { randomUUID } from "crypto";
+import JSZip from 'jszip';
+import { promises as fsPromises } from 'fs';
+import { sendEmail } from "../mail/sendEmail";
+import { PgTransaction } from "drizzle-orm/pg-core";
+import { items } from "@/db/schema/items";
+import { id_ID } from "@faker-js/faker";
+import { users } from "@/db/schema/users";
+
+
+/* -----------------------------------------------------
+ 1) 조회 관련
+----------------------------------------------------- */
+
+/**
+ * 복잡한 조건으로 Vendor 목록을 조회 (+ pagination) 하고,
+ * 총 개수에 따라 pageCount를 계산해서 리턴.
+ * Next.js의 unstable_cache를 사용해 일정 시간 캐시.
+ */
+export async function getVendors(input: GetVendorsSchema) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // 1) 고급 필터
+ const advancedWhere = filterColumns({
+ table: vendors,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ // 2) 글로벌 검색
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(vendors.vendorName, s),
+ ilike(vendors.vendorCode, s),
+ ilike(vendors.email, s),
+ ilike(vendors.status, s)
+ );
+ }
+
+ // 최종 where 결합
+ const finalWhere = and(advancedWhere, globalWhere);
+
+ // 간단 검색 (advancedTable=false) 시 예시
+ const simpleWhere = and(
+ input.vendorName
+ ? ilike(vendors.vendorName, `%${input.vendorName}%`)
+ : undefined,
+ input.status ? ilike(vendors.status, input.status) : undefined,
+ input.country
+ ? ilike(vendors.country, `%${input.country}%`)
+ : undefined
+ );
+
+ // 실제 사용될 where
+ const where = finalWhere;
+
+ // 정렬
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(vendors[item.id]) : asc(vendors[item.id])
+ )
+ : [asc(vendors.createdAt)];
+
+ // 트랜잭션 내에서 데이터 조회
+ const { data, total } = await db.transaction(async (tx) => {
+ // 1) vendor 목록 조회
+ const vendorsData = await selectVendors(tx, {
+ where,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ // 2) 각 vendor의 attachments 조회
+ const vendorsWithAttachments = await Promise.all(
+ vendorsData.map(async (vendor) => {
+ const attachments = await tx
+ .select({
+ id: vendorAttachments.id,
+ fileName: vendorAttachments.fileName,
+ filePath: vendorAttachments.filePath,
+ })
+ .from(vendorAttachments)
+ .where(eq(vendorAttachments.vendorId, vendor.id));
+
+ return {
+ ...vendor,
+ hasAttachments: attachments.length > 0,
+ attachmentsList: attachments,
+ };
+ })
+ );
+
+ // 3) 전체 개수
+ const total = await countVendors(tx, where);
+ return { data: vendorsWithAttachments, total };
+ });
+
+ // 페이지 수
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount };
+ } catch (err) {
+ // 에러 발생 시
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input)], // 캐싱 키
+ {
+ revalidate: 3600,
+ tags: ["vendors"], // revalidateTag("vendors") 호출 시 무효화
+ }
+ )();
+}
+
+
+export async function getVendorStatusCounts() {
+ return unstable_cache(
+ async () => {
+ try {
+
+ const initial: Record<Vendor["status"], number> = {
+ ACTIVE: 0,
+ INACTIVE: 0,
+ BLACKLISTED: 0,
+ "PENDING_REVIEW": 0,
+ "IN_REVIEW": 0,
+ "REJECTED": 0,
+ "IN_PQ": 0,
+ "PQ_FAILED": 0,
+ "APPROVED": 0,
+ "PQ_SUBMITTED": 0
+ };
+
+
+ const result = await db.transaction(async (tx) => {
+ const rows = await groupByStatus(tx);
+ return rows.reduce<Record<Vendor["status"], number>>((acc, { status, count }) => {
+ acc[status] = count;
+ return acc;
+ }, initial);
+ });
+
+ return result;
+ } catch (err) {
+ return {} as Record<Vendor["status"], number>;
+ }
+ },
+ ["task-status-counts"], // 캐싱 키
+ {
+ revalidate: 3600,
+ }
+ )();
+}
+
+/* -----------------------------------------------------
+ 2) 생성(Create)
+----------------------------------------------------- */
+
+/**
+ * 신규 Vendor 생성
+ */
+
+async function storeVendorFiles(
+ tx: PgTransaction<any, any, any>,
+ vendorId: number,
+ files: File[],
+ attachmentType: string
+) {
+ const vendorDir = path.join(
+ process.cwd(),
+ "public",
+ "vendors",
+ String(vendorId)
+ )
+ await fs.mkdir(vendorDir, { recursive: true })
+
+ for (const file of files) {
+ // Convert file to buffer
+ const ab = await file.arrayBuffer()
+ const buffer = Buffer.from(ab)
+
+ // Generate a unique filename
+ const uniqueName = `${randomUUID()}-${file.name}`
+ const relativePath = path.join("vendors", String(vendorId), uniqueName)
+ const absolutePath = path.join(process.cwd(), "public", relativePath)
+
+ // Write to disk
+ await fs.writeFile(absolutePath, buffer)
+
+ // Insert attachment record
+ await tx.insert(vendorAttachments).values({
+ vendorId,
+ fileName: file.name,
+ filePath: "/" + relativePath.replace(/\\/g, "/"),
+ attachmentType, // "GENERAL", "CREDIT_RATING", "CASH_FLOW_RATING", ...
+ })
+ }
+}
+
+export type CreateVendorData = {
+ vendorName: string
+ vendorCode?: string
+ website?: string
+ taxId: string
+ address?: string
+ email: string
+ phone?: string
+
+ representativeName?: string
+ representativeBirth?: string
+ representativeEmail?: string
+ representativePhone?: string
+
+ creditAgency?: string
+ creditRating?: string
+ cashFlowRating?: string
+ corporateRegistrationNumber?: string
+
+ country?: string
+ status?: "PENDING_REVIEW" | "IN_REVIEW" | "IN_PQ" | "PQ_FAILED" | "APPROVED" | "ACTIVE" | "INACTIVE" | "BLACKLISTED" | "PQ_SUBMITTED"
+}
+
+export async function createVendor(params: {
+ vendorData: CreateVendorData
+ // 기존의 일반 첨부파일
+ files?: File[]
+
+ // 신용평가 / 현금흐름 등급 첨부
+ creditRatingFiles?: File[]
+ cashFlowRatingFiles?: File[]
+ contacts: {
+ contactName: string
+ contactPosition?: string
+ contactEmail: string
+ contactPhone?: string
+ isPrimary?: boolean
+ }[]
+}) {
+ unstable_noStore() // Next.js 서버 액션 캐싱 방지
+
+ try {
+ const { vendorData, files = [], creditRatingFiles = [], cashFlowRatingFiles = [], contacts } = params
+
+ // 이메일 중복 검사 - 이미 users 테이블에 존재하는지 확인
+ const existingUser = await db
+ .select({ id: users.id })
+ .from(users)
+ .where(eq(users.email, vendorData.email))
+ .limit(1);
+
+ // 이미 사용자가 존재하면 에러 반환
+ if (existingUser.length > 0) {
+ return {
+ data: null,
+ error: `이미 등록된 이메일입니다. 다른 이메일을 사용해주세요. (Email ${vendorData.email} already exists in the system)`
+ };
+ }
+
+ await db.transaction(async (tx) => {
+ // 1) Insert the vendor (확장 필드도 함께)
+ const [newVendor] = await insertVendor(tx, {
+ vendorName: vendorData.vendorName,
+ vendorCode: vendorData.vendorCode || null,
+ address: vendorData.address || null,
+ country: vendorData.country || null,
+ phone: vendorData.phone || null,
+ email: vendorData.email,
+ website: vendorData.website || null,
+ status: vendorData.status ?? "PENDING_REVIEW",
+ taxId: vendorData.taxId,
+
+ // 대표자 정보
+ representativeName: vendorData.representativeName || null,
+ representativeBirth: vendorData.representativeBirth || null,
+ representativeEmail: vendorData.representativeEmail || null,
+ representativePhone: vendorData.representativePhone || null,
+ corporateRegistrationNumber: vendorData.corporateRegistrationNumber || null,
+
+ // 신용/현금흐름
+ creditAgency: vendorData.creditAgency || null,
+ creditRating: vendorData.creditRating || null,
+ cashFlowRating: vendorData.cashFlowRating || null,
+ })
+
+ // 2) If there are attached files, store them
+ // (2-1) 일반 첨부
+ if (files.length > 0) {
+ await storeVendorFiles(tx, newVendor.id, files, "GENERAL")
+ }
+
+ // (2-2) 신용평가 파일
+ if (creditRatingFiles.length > 0) {
+ await storeVendorFiles(tx, newVendor.id, creditRatingFiles, "CREDIT_RATING")
+ }
+
+ // (2-3) 현금흐름 파일
+ if (cashFlowRatingFiles.length > 0) {
+ await storeVendorFiles(tx, newVendor.id, cashFlowRatingFiles, "CASH_FLOW_RATING")
+ }
+
+ for (const contact of contacts) {
+ await tx.insert(vendorContacts).values({
+ vendorId: newVendor.id,
+ contactName: contact.contactName,
+ contactPosition: contact.contactPosition || null,
+ contactEmail: contact.contactEmail,
+ contactPhone: contact.contactPhone || null,
+ isPrimary: contact.isPrimary ?? false,
+ })
+ }
+ })
+
+ revalidateTag("vendors")
+ return { data: null, error: null }
+ } catch (error) {
+ return { data: null, error: getErrorMessage(error) }
+ }
+}
+/* -----------------------------------------------------
+ 3) 업데이트 (단건/복수)
+----------------------------------------------------- */
+
+/** 단건 업데이트 */
+export async function modifyVendor(
+ input: UpdateVendorSchema & { id: string }
+) {
+ unstable_noStore();
+ try {
+ const updated = await db.transaction(async (tx) => {
+ // 특정 ID 벤더를 업데이트
+ const [res] = await updateVendor(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;
+ });
+
+ // 필요 시, status 변경 등에 따른 다른 캐시도 무효화
+ revalidateTag("vendors");
+ revalidateTag("rfq-vendors");
+
+ return { data: updated, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/** 복수 업데이트 */
+export async function modifyVendors(input: {
+ ids: string[];
+ status?: Vendor["status"];
+}) {
+ unstable_noStore();
+ try {
+ const data = await db.transaction(async (tx) => {
+ // 여러 벤더 일괄 업데이트
+ const [updated] = await updateVendors(tx, input.ids, {
+ // 예: 상태만 일괄 변경
+ status: input.status,
+ });
+ return updated;
+ });
+
+ revalidateTag("vendors");
+ if (data.status === input.status) {
+ revalidateTag("vendor-status-counts");
+ }
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+export const findVendorById = async (id: number): Promise<Vendor | null> => {
+ try {
+ logger.info({ id }, 'Fetching user by ID');
+ const vendor = await getVendorById(id);
+ if (!vendor) {
+ logger.warn({ id }, 'User not found');
+ } else {
+ logger.debug({ vendor }, 'User fetched successfully');
+ }
+ return vendor;
+ } catch (error) {
+ logger.error({ error }, 'Error fetching user by ID');
+ throw new Error('Failed to fetch user');
+ }
+};
+
+
+export const findVendorContactsById = async (id: number): Promise<VendorContact | null> => {
+ try {
+ logger.info({ id }, 'Fetching user by ID');
+ const vendor = await getVendorContactsById(id);
+ if (!vendor) {
+ logger.warn({ id }, 'User not found');
+ } else {
+ logger.debug({ vendor }, 'User fetched successfully');
+ }
+ return vendor;
+ } catch (error) {
+ logger.error({ error }, 'Error fetching user by ID');
+ throw new Error('Failed to fetch user');
+ }
+};
+
+
+export async function getVendorContacts(input: GetVendorContactsSchema, id: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // const advancedTable = input.flags.includes("advancedTable");
+ const advancedTable = true;
+
+
+ // advancedTable 모드면 filterColumns()로 where 절 구성
+ const advancedWhere = filterColumns({
+ table: vendorContacts,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(ilike(vendorContacts.contactName, s), ilike(vendorContacts.contactPosition, s)
+ , ilike(vendorContacts.contactEmail, s), ilike(vendorContacts.contactPhone, s)
+ )
+ // 필요시 여러 칼럼 OR조건 (status, priority, etc)
+ }
+
+ const vendorWhere = eq(vendorContacts.vendorId, id)
+
+ const finalWhere = and(
+ // advancedWhere or your existing conditions
+ advancedWhere,
+ globalWhere,
+ vendorWhere
+ )
+
+
+ // 아니면 ilike, inArray, gte 등으로 where 절 구성
+ const where = finalWhere
+
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(vendorContacts[item.id]) : asc(vendorContacts[item.id])
+ )
+ : [asc(vendorContacts.createdAt)];
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectVendorContacts(tx, {
+ where,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+ const total = await countVendorContacts(tx, where);
+ 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: [`vendor-contacts-${id}`], // revalidateTag("tasks") 호출 시 무효화
+ }
+ )();
+}
+
+export async function createVendorContact(input: CreateVendorContactSchema) {
+ unstable_noStore(); // Next.js 서버 액션 캐싱 방지
+ try {
+ await db.transaction(async (tx) => {
+ // DB Insert
+ const [newContact] = await insertVendorContact(tx, {
+ vendorId: input.vendorId,
+ contactName: input.contactName,
+ contactPosition: input.contactPosition || "",
+ contactEmail: input.contactEmail,
+ contactPhone: input.contactPhone || "",
+ isPrimary: input.isPrimary || false,
+ });
+ return newContact;
+ });
+
+ // 캐시 무효화 (벤더 연락처 목록 등)
+ revalidateTag(`vendor-contacts-${input.vendorId}`);
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+
+///item
+
+export async function getVendorItems(input: GetVendorItemsSchema, id: number) {
+ const cachedFunction = unstable_cache(
+
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // const advancedTable = input.flags.includes("advancedTable");
+ const advancedTable = true;
+
+ // advancedTable 모드면 filterColumns()로 where 절 구성
+ const advancedWhere = filterColumns({
+ table: vendorItemsView,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(ilike(vendorItemsView.itemCode, s)
+ , ilike(vendorItemsView.description, s)
+ )
+ // 필요시 여러 칼럼 OR조건 (status, priority, etc)
+ }
+
+ const vendorWhere = eq(vendorItemsView.vendorId, id)
+
+ const finalWhere = and(
+ // advancedWhere or your existing conditions
+ advancedWhere,
+ globalWhere,
+ vendorWhere
+ )
+
+
+ // 아니면 ilike, inArray, gte 등으로 where 절 구성
+ const where = finalWhere
+
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(vendorItemsView[item.id]) : asc(vendorItemsView[item.id])
+ )
+ : [asc(vendorItemsView.createdAt)];
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectVendorItems(tx, {
+ where,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+ const total = await countVendorItems(tx, where);
+ return { data, total };
+ });
+
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+
+ console.log(data)
+
+ return { data, pageCount };
+ } catch (err) {
+ // 에러 발생 시 디폴트
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input), String(id)], // 캐싱 키
+ {
+ revalidate: 3600,
+ tags: [`vendor-items-${id}`], // revalidateTag("tasks") 호출 시 무효화
+ }
+ );
+ return cachedFunction();
+}
+
+export interface ItemDropdownOption {
+ itemCode: string;
+ itemName: string;
+ description: string | null;
+}
+
+/**
+ * Vendor Item 추가 시 사용할 아이템 목록 조회 (전체 목록 반환)
+ * 아이템 코드, 이름, 설명만 간소화해서 반환
+ */
+export async function getItemsForVendor(vendorId: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ // 해당 vendorId가 이미 가지고 있는 itemCode 목록을 서브쿼리로 구함
+ // 그 아이템코드를 제외(notIn)하여 모든 items 테이블에서 조회
+ const itemsData = await db
+ .select({
+ itemCode: items.itemCode,
+ itemName: items.itemName,
+ description: items.description,
+ })
+ .from(items)
+ .leftJoin(
+ vendorPossibleItems,
+ eq(items.itemCode, vendorPossibleItems.itemCode)
+ )
+ // vendorPossibleItems.vendorId가 이 vendorId인 행이 없는(즉 아직 등록되지 않은) 아이템만
+ .where(
+ isNull(vendorPossibleItems.id) // 또는 isNull(vendorPossibleItems.itemCode)
+ )
+ .orderBy(asc(items.itemName))
+
+ return {
+ data: itemsData.map((item) => ({
+ itemCode: item.itemCode ?? "", // null이라면 ""로 치환
+ itemName: item.itemName,
+ description: item.description ?? "" // null이라면 ""로 치환
+ })),
+ error: null
+ }
+ } catch (err) {
+ console.error("Failed to fetch items for vendor dropdown:", err)
+ return {
+ data: [],
+ error: "아이템 목록을 불러오는데 실패했습니다.",
+ }
+ }
+ },
+ // 캐시 키를 vendorId 별로 달리 해야 한다.
+ ["items-for-vendor", String(vendorId)],
+ {
+ revalidate: 3600, // 1시간 캐싱
+ tags: ["items"], // revalidateTag("items") 호출 시 무효화
+ }
+ )()
+}
+
+export async function createVendorItem(input: CreateVendorItemSchema) {
+ unstable_noStore(); // Next.js 서버 액션 캐싱 방지
+ try {
+ await db.transaction(async (tx) => {
+ // DB Insert
+ const [newContact] = await insertVendorItem(tx, {
+ vendorId: input.vendorId,
+ itemCode: input.itemCode,
+
+ });
+ return newContact;
+ });
+
+ // 캐시 무효화 (벤더 연락처 목록 등)
+ revalidateTag(`vendor-items-${input.vendorId}`);
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+export async function getRfqHistory(input: GetRfqHistorySchema, vendorId: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ logger.info({ vendorId, input }, "Starting getRfqHistory");
+
+ const offset = (input.page - 1) * input.perPage;
+
+ // 기본 where 조건 (vendorId)
+ const vendorWhere = eq(vendorRfqView.vendorId, vendorId);
+ logger.debug({ vendorWhere }, "Vendor where condition");
+
+ // 고급 필터링
+ const advancedWhere = filterColumns({
+ table: vendorRfqView,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+ logger.debug({ advancedWhere }, "Advanced where condition");
+
+ // 글로벌 검색
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(vendorRfqView.rfqCode, s),
+ ilike(vendorRfqView.projectCode, s),
+ ilike(vendorRfqView.projectName, s)
+ );
+ logger.debug({ globalWhere, search: input.search }, "Global search condition");
+ }
+
+ const finalWhere = and(
+ advancedWhere,
+ globalWhere,
+ vendorWhere
+ );
+ logger.debug({ finalWhere }, "Final where condition");
+
+ // 정렬 조건
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(rfqs[item.id]) : asc(rfqs[item.id])
+ )
+ : [desc(rfqs.createdAt)];
+ logger.debug({ orderBy }, "Order by condition");
+
+ // 트랜잭션으로 데이터 조회
+ const { data, total } = await db.transaction(async (tx) => {
+ logger.debug("Starting transaction for RFQ history query");
+
+ const data = await selectRfqHistory(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+ logger.debug({ dataLength: data.length }, "RFQ history data fetched");
+
+ // RFQ 아이템 정보 조회
+ const rfqIds = data.map(rfq => rfq.id);
+ const items = await tx
+ .select({
+ rfqId: rfqItems.rfqId,
+ id: rfqItems.id,
+ itemCode: rfqItems.itemCode,
+ description: rfqItems.description,
+ quantity: rfqItems.quantity,
+ uom: rfqItems.uom,
+ })
+ .from(rfqItems)
+ .where(inArray(rfqItems.rfqId, rfqIds));
+
+ // RFQ 데이터에 아이템 정보 추가
+ const dataWithItems = data.map(rfq => ({
+ ...rfq,
+ items: items.filter(item => item.rfqId === rfq.id),
+ }));
+
+ const total = await countRfqHistory(tx, finalWhere);
+ logger.debug({ total }, "RFQ history total count");
+
+ return { data: dataWithItems, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+ logger.info({
+ vendorId,
+ dataLength: data.length,
+ total,
+ pageCount
+ }, "RFQ history query completed");
+
+ return { data, pageCount };
+ } catch (err) {
+ logger.error({
+ err,
+ vendorId,
+ stack: err instanceof Error ? err.stack : undefined
+ }, 'Error fetching RFQ history');
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify({ input, vendorId })],
+ {
+ revalidate: 3600,
+ tags: ["rfq-history"],
+ }
+ )();
+}
+
+export async function checkJoinPortal(taxID: string) {
+ try {
+ // 이미 등록된 회사가 있는지 검색
+ const result = await db.select().from(vendors).where(eq(vendors.taxId, taxID)).limit(1)
+
+ if (result.length > 0) {
+ // 이미 가입되어 있음
+ // data에 예시로 vendorName이나 다른 정보를 담아 반환
+ return {
+ success: false,
+ data: result[0].vendorName ?? "Already joined",
+ }
+ }
+
+ // 미가입 → 가입 가능
+ return {
+ success: true,
+ }
+ } catch (err) {
+ console.error("checkJoinPortal error:", err)
+ // 서버 에러 시
+ return {
+ success: false,
+ data: "서버 에러가 발생했습니다.",
+ }
+ }
+}
+
+interface CreateCompanyInput {
+ vendorName: string
+ taxId: string
+ email: string
+ address: string
+ phone?: string
+ country?: string
+ // 필요한 필드 추가 가능 (vendorCode, website 등)
+}
+
+
+/**
+ * 벤더 첨부파일 다운로드를 위한 서버 액션
+ * @param vendorId 벤더 ID
+ * @param fileId 특정 파일 ID (단일 파일 다운로드시)
+ * @returns 다운로드할 수 있는 임시 URL
+ */
+export async function downloadVendorAttachments(vendorId: number, fileId?: number) {
+ try {
+ // 벤더 정보 조회
+ const vendor = await db.select()
+ .from(vendors)
+ .where(eq(vendors.id, vendorId))
+ .limit(1)
+ .then(rows => rows[0]);
+
+ if (!vendor) {
+ throw new Error(`벤더 정보를 찾을 수 없습니다. (ID: ${vendorId})`);
+ }
+
+ // 첨부파일 조회 (특정 파일 또는 모든 파일)
+ const attachments = fileId
+ ? await db.select()
+ .from(vendorAttachments)
+ .where(eq(vendorAttachments.id, fileId))
+ : await db.select()
+ .from(vendorAttachments)
+ .where(eq(vendorAttachments.vendorId, vendorId));
+
+ if (!attachments.length) {
+ throw new Error('다운로드할 첨부파일이 없습니다.');
+ }
+
+ // 업로드 기본 경로
+ const basePath = process.env.UPLOAD_DIR || path.join(process.cwd(), 'uploads');
+
+ // 단일 파일인 경우 직접 URL 반환
+ if (attachments.length === 1) {
+ const attachment = attachments[0];
+ const filePath = `/api/vendors/attachments/download?id=${attachment.id}`;
+ return { url: filePath, fileName: attachment.fileName };
+ }
+
+ // 다중 파일: 임시 ZIP 생성 후 URL 반환
+ // 임시 디렉토리 생성
+ const tempDir = path.join(process.cwd(), 'tmp');
+ await fsPromises.mkdir(tempDir, { recursive: true });
+
+ // 고유 ID로 임시 ZIP 파일명 생성
+ const tempId = randomUUID();
+ const zipFileName = `${vendor.vendorName || `vendor-${vendorId}`}-attachments-${tempId}.zip`;
+ const zipFilePath = path.join(tempDir, zipFileName);
+
+ // JSZip을 사용하여 ZIP 파일 생성
+ const zip = new JSZip();
+
+ // 파일 읽기 및 추가 작업을 병렬로 처리
+ await Promise.all(
+ attachments.map(async (attachment) => {
+ const filePath = path.join(basePath, attachment.filePath);
+
+ try {
+ // 파일 존재 확인 (fsPromises.access 사용)
+ try {
+ await fsPromises.access(filePath, fs.constants.F_OK);
+ } catch (e) {
+ console.warn(`파일이 존재하지 않습니다: ${filePath}`);
+ return; // 파일이 없으면 건너뜀
+ }
+
+ // 파일 읽기 (fsPromises.readFile 사용)
+ const fileData = await fsPromises.readFile(filePath);
+
+ // ZIP에 파일 추가
+ zip.file(attachment.fileName, fileData);
+ } catch (error) {
+ console.warn(`파일을 처리할 수 없습니다: ${filePath}`, error);
+ // 오류가 있더라도 계속 진행
+ }
+ })
+ );
+
+ // ZIP 생성 및 저장
+ const zipContent = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE', compressionOptions: { level: 9 } });
+ await fsPromises.writeFile(zipFilePath, zipContent);
+
+ // 임시 ZIP 파일에 접근할 수 있는 URL 생성
+ const downloadUrl = `/api/vendors/attachments/download-temp?file=${encodeURIComponent(zipFileName)}`;
+
+ return {
+ url: downloadUrl,
+ fileName: `${vendor.vendorName || `vendor-${vendorId}`}-attachments.zip`
+ };
+ } catch (error) {
+ console.error('첨부파일 다운로드 서버 액션 오류:', error);
+ throw new Error('첨부파일 다운로드 준비 중 오류가 발생했습니다.');
+ }
+}
+
+/**
+ * 임시 ZIP 파일 정리를 위한 서버 액션
+ * @param fileName 정리할 파일명
+ */
+export async function cleanupTempFiles(fileName: string) {
+ 'use server';
+
+ try {
+ const tempDir = path.join(process.cwd(), 'tmp');
+ const filePath = path.join(tempDir, fileName);
+
+ try {
+ // 파일 존재 확인
+ await fsPromises.access(filePath, fs.constants.F_OK);
+ // 파일 삭제
+ await fsPromises.unlink(filePath);
+ } catch {
+ // 파일이 없으면 무시
+ }
+
+ return { success: true };
+ } catch (error) {
+ console.error('임시 파일 정리 오류:', error);
+ return { success: false, error: '임시 파일 정리 중 오류가 발생했습니다.' };
+ }
+}
+
+
+interface ApproveVendorsInput {
+ ids: number[];
+}
+
+/**
+ * 선택된 벤더의 상태를 IN_REVIEW로 변경하고 이메일 알림을 발송하는 서버 액션
+ */
+export async function approveVendors(input: ApproveVendorsInput) {
+ unstable_noStore();
+
+ try {
+ // 트랜잭션 내에서 벤더 상태 업데이트, 유저 생성 및 이메일 발송
+ const result = await db.transaction(async (tx) => {
+ // 1. 벤더 상태 업데이트
+ const [updated] = await tx
+ .update(vendors)
+ .set({
+ status: "IN_REVIEW",
+ updatedAt: new Date()
+ })
+ .where(inArray(vendors.id, input.ids))
+ .returning();
+
+ // 2. 업데이트된 벤더 정보 조회
+ const updatedVendors = await tx
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ email: vendors.email,
+ })
+ .from(vendors)
+ .where(inArray(vendors.id, input.ids));
+
+ // 3. 각 벤더에 대한 유저 계정 생성
+ await Promise.all(
+ updatedVendors.map(async (vendor) => {
+ if (!vendor.email) return; // 이메일이 없으면 스킵
+
+ // 이미 존재하는 유저인지 확인
+ const existingUser = await tx
+ .select({ id: users.id })
+ .from(users)
+ .where(eq(users.email, vendor.email))
+ .limit(1);
+
+ // 유저가 존재하지 않는 경우에만 생성
+ if (existingUser.length === 0) {
+ await tx.insert(users).values({
+ name: vendor.vendorName,
+ email: vendor.email,
+ companyId: vendor.id,
+ domain: "partners", // 기본값으로 이미 설정되어 있지만 명시적으로 지정
+ });
+ }
+ })
+ );
+
+ // 4. 각 벤더에게 이메일 발송
+ await Promise.all(
+ updatedVendors.map(async (vendor) => {
+ if (!vendor.email) return; // 이메일이 없으면 스킵
+
+ try {
+ const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기
+
+ const subject =
+ "[eVCP] Admin Account Created";
+
+ const loginUrl = "http://3.36.56.124:3000/en/login";
+
+ await sendEmail({
+ to: vendor.email,
+ subject,
+ template: "admin-created", // 이메일 템플릿 이름
+ context: {
+ vendorName: vendor.vendorName,
+ loginUrl,
+ language: userLang,
+ },
+ });
+ } catch (emailError) {
+ console.error(`Failed to send email to vendor ${vendor.id}:`, emailError);
+ // 이메일 전송 실패는 전체 트랜잭션을 실패시키지 않음
+ }
+ })
+ );
+
+ return updated;
+ });
+
+ // 캐시 무효화
+ revalidateTag("vendors");
+ revalidateTag("vendor-status-counts");
+ revalidateTag("users"); // 유저 캐시도 무효화
+
+ return { data: result, error: null };
+ } catch (err) {
+ console.error("Error approving vendors:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+export async function requestPQVendors(input: ApproveVendorsInput) {
+ unstable_noStore();
+
+ try {
+ // 트랜잭션 내에서 벤더 상태 업데이트 및 이메일 발송
+ const result = await db.transaction(async (tx) => {
+ // 1. 벤더 상태 업데이트
+ const [updated] = await tx
+ .update(vendors)
+ .set({
+ status: "IN_PQ",
+ updatedAt: new Date()
+ })
+ .where(inArray(vendors.id, input.ids))
+ .returning();
+
+ // 2. 업데이트된 벤더 정보 조회
+ const updatedVendors = await tx
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ email: vendors.email,
+ })
+ .from(vendors)
+ .where(inArray(vendors.id, input.ids));
+
+ // 3. 각 벤더에게 이메일 발송
+ await Promise.all(
+ updatedVendors.map(async (vendor) => {
+ if (!vendor.email) return; // 이메일이 없으면 스킵
+
+ try {
+ const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기
+
+ const subject =
+ "[eVCP] You are invited to submit PQ";
+
+ const loginUrl = "http://3.36.56.124:3000/en/login";
+
+ await sendEmail({
+ to: vendor.email,
+ subject,
+ template: "pq", // 이메일 템플릿 이름
+ context: {
+ vendorName: vendor.vendorName,
+ loginUrl,
+ language: userLang,
+ },
+ });
+ } catch (emailError) {
+ console.error(`Failed to send email to vendor ${vendor.id}:`, emailError);
+ // 이메일 전송 실패는 전체 트랜잭션을 실패시키지 않음
+ }
+ })
+ );
+
+ return updated;
+ });
+
+ // 캐시 무효화
+ revalidateTag("vendors");
+ revalidateTag("vendor-status-counts");
+
+ return { data: result, error: null };
+ } catch (err) {
+ console.error("Error approving vendors:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+interface SendVendorsInput {
+ ids: number[];
+}
+
+/**
+ * APPROVED 상태인 벤더 정보를 기간계 시스템에 전송하고 벤더 코드를 업데이트하는 서버 액션
+ */
+export async function sendVendors(input: SendVendorsInput) {
+ unstable_noStore();
+
+ try {
+ // 트랜잭션 내에서 진행
+ const result = await db.transaction(async (tx) => {
+ // 1. 선택된 벤더 중 APPROVED 상태인 벤더만 필터링
+ const approvedVendors = await tx
+ .select()
+ .from(vendors)
+ .where(
+ and(
+ inArray(vendors.id, input.ids),
+ eq(vendors.status, "APPROVED")
+ )
+ );
+
+ if (!approvedVendors.length) {
+ throw new Error("No approved vendors found in the selection");
+ }
+
+ // 벤더별 처리 결과를 저장할 배열
+ const results = [];
+
+ // 2. 각 벤더에 대해 처리
+ for (const vendor of approvedVendors) {
+ // 2-1. 벤더 연락처 정보 조회
+ const contacts = await tx
+ .select()
+ .from(vendorContacts)
+ .where(eq(vendorContacts.vendorId, vendor.id));
+
+ // 2-2. 벤더 가능 아이템 조회
+ const possibleItems = await tx
+ .select()
+ .from(vendorPossibleItems)
+ .where(eq(vendorPossibleItems.vendorId, vendor.id));
+
+ // 2-3. 벤더 첨부파일 조회
+ const attachments = await tx
+ .select({
+ id: vendorAttachments.id,
+ fileName: vendorAttachments.fileName,
+ filePath: vendorAttachments.filePath,
+ })
+ .from(vendorAttachments)
+ .where(eq(vendorAttachments.vendorId, vendor.id));
+
+ // 2-4. 벤더 정보를 기간계 시스템에 전송 (NextJS API 라우트 사용)
+ const vendorData = {
+ id: vendor.id,
+ vendorName: vendor.vendorName,
+ taxId: vendor.taxId,
+ address: vendor.address || "",
+ country: vendor.country || "",
+ phone: vendor.phone || "",
+ email: vendor.email || "",
+ website: vendor.website || "",
+ contacts,
+ possibleItems,
+ attachments,
+ };
+
+ try {
+ // 내부 API 호출 (기간계 시스템 연동 API)
+ const erpResponse = await fetch(`/api/erp/vendors`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(vendorData),
+ });
+
+ if (!erpResponse.ok) {
+ const errorData = await erpResponse.json();
+ throw new Error(`ERP system error for vendor ${vendor.id}: ${errorData.message || erpResponse.statusText}`);
+ }
+
+ const responseData = await erpResponse.json();
+
+ if (!responseData.success || !responseData.vendorCode) {
+ throw new Error(`Invalid response from ERP system for vendor ${vendor.id}`);
+ }
+
+ // 2-5. 벤더 코드 및 상태 업데이트
+ const vendorCode = responseData.vendorCode;
+
+ const [updated] = await tx
+ .update(vendors)
+ .set({
+ vendorCode,
+ status: "ACTIVE", // 상태를 ACTIVE로 변경
+ updatedAt: new Date(),
+ })
+ .where(eq(vendors.id, vendor.id))
+ .returning();
+
+ // 2-6. 벤더에게 알림 이메일 발송
+ if (vendor.email) {
+ const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기
+
+ const subject =
+ "[eVCP] Vendor Registration Completed";
+
+ const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000'
+
+ const portalUrl = `${baseUrl}/en/partners`;
+
+ await sendEmail({
+ to: vendor.email,
+ subject,
+ template: "vendor-active",
+ context: {
+ vendorName: vendor.vendorName,
+ vendorCode,
+ portalUrl,
+ language: userLang,
+ },
+ });
+ }
+
+ results.push({
+ id: vendor.id,
+ success: true,
+ vendorCode,
+ message: "Successfully sent to ERP system",
+ });
+ } catch (vendorError) {
+ // 개별 벤더 처리 오류 기록
+ results.push({
+ id: vendor.id,
+ success: false,
+ error: getErrorMessage(vendorError),
+ });
+ }
+ }
+
+ // 3. 처리 결과 반환
+ const successCount = results.filter(r => r.success).length;
+ const failCount = results.filter(r => !r.success).length;
+
+ return {
+ totalProcessed: results.length,
+ successCount,
+ failCount,
+ results,
+ };
+ });
+
+ // 캐시 무효화
+ revalidateTag("vendors");
+ revalidateTag("vendor-status-counts");
+
+ return { data: result, error: null };
+ } catch (err) {
+ console.error("Error sending vendors to ERP:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
diff --git a/lib/vendors/table/approve-vendor-dialog.tsx b/lib/vendors/table/approve-vendor-dialog.tsx
new file mode 100644
index 00000000..253c2830
--- /dev/null
+++ b/lib/vendors/table/approve-vendor-dialog.tsx
@@ -0,0 +1,150 @@
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Check } from "lucide-react"
+import { toast } from "sonner"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+import { Vendor } from "@/db/schema/vendors"
+import { approveVendors } from "../service"
+
+interface ApprovalVendorDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ vendors: Row<Vendor>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function ApproveVendorsDialog({
+ vendors,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: ApprovalVendorDialogProps) {
+ const [isApprovePending, startApproveTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onApprove() {
+ startApproveTransition(async () => {
+ const { error } = await approveVendors({
+ ids: vendors.map((vendor) => vendor.id),
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("Vendors successfully approved for review")
+ onSuccess?.()
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Check className="size-4" aria-hidden="true" />
+ Approve ({vendors.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Confirm Vendor Approval</DialogTitle>
+ <DialogDescription>
+ Are you sure you want to approve{" "}
+ <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? " vendor" : " vendors"}?
+ After approval, vendors will be notified and can login to submit PQ information.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DialogClose>
+ <Button
+ aria-label="Approve selected vendors"
+ variant="default"
+ onClick={onApprove}
+ disabled={isApprovePending}
+ >
+ {isApprovePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Approve
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Check className="size-4" aria-hidden="true" />
+ Approve ({vendors.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>Confirm Vendor Approval</DrawerTitle>
+ <DrawerDescription>
+ Are you sure you want to approve{" "}
+ <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? " vendor" : " vendors"}?
+ After approval, vendors will be notified and can login to submit PQ information.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DrawerClose>
+ <Button
+ aria-label="Approve selected vendors"
+ variant="default"
+ onClick={onApprove}
+ disabled={isApprovePending}
+ >
+ {isApprovePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ Approve
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/lib/vendors/table/attachmentButton.tsx b/lib/vendors/table/attachmentButton.tsx
new file mode 100644
index 00000000..a82f59e1
--- /dev/null
+++ b/lib/vendors/table/attachmentButton.tsx
@@ -0,0 +1,69 @@
+'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 { downloadVendorAttachments } 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 downloadVendorAttachments(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 (
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={handleDownload}
+ title={`${attachmentsList.length}개 파일 다운로드`}
+ >
+ <PaperclipIcon className="h-4 w-4" />
+ {attachmentsList.length > 1 && (
+ <Badge variant="outline" className="ml-1 h-5 min-w-5 px-1">
+ {attachmentsList.length}
+ </Badge>
+ )}
+ </Button>
+ );
+}
diff --git a/lib/vendors/table/feature-flags-provider.tsx b/lib/vendors/table/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/vendors/table/feature-flags-provider.tsx
@@ -0,0 +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>
+ )
+}
diff --git a/lib/vendors/table/request-vendor-pg-dialog.tsx b/lib/vendors/table/request-vendor-pg-dialog.tsx
new file mode 100644
index 00000000..b417f846
--- /dev/null
+++ b/lib/vendors/table/request-vendor-pg-dialog.tsx
@@ -0,0 +1,150 @@
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Check, SendHorizonal } from "lucide-react"
+import { toast } from "sonner"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+import { Vendor } from "@/db/schema/vendors"
+import { requestPQVendors } from "../service"
+
+interface ApprovalVendorDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ vendors: Row<Vendor>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function RequestPQVendorsDialog({
+ vendors,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: ApprovalVendorDialogProps) {
+ const [isApprovePending, startApproveTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onApprove() {
+ startApproveTransition(async () => {
+ const { error } = await requestPQVendors({
+ ids: vendors.map((vendor) => vendor.id),
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("PQ successfully sent to vendors")
+ onSuccess?.()
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <SendHorizonal className="size-4" aria-hidden="true" />
+ Request ({vendors.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Confirm Vendor PQ requst</DialogTitle>
+ <DialogDescription>
+ Are you sure you want to request{" "}
+ <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? " vendor" : " vendors"}?
+ After sent, vendors will be notified and can submit PQ information.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DialogClose>
+ <Button
+ aria-label="Request selected vendors"
+ variant="default"
+ onClick={onApprove}
+ disabled={isApprovePending}
+ >
+ {isApprovePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Request
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Check className="size-4" aria-hidden="true" />
+ Request ({vendors.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>Confirm Vendor Approval</DrawerTitle>
+ <DrawerDescription>
+ Are you sure you want to request{" "}
+ <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? " vendor" : " vendors"}?
+ After sent, vendors will be notified and can submit PQ information.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DrawerClose>
+ <Button
+ aria-label="Request selected vendors"
+ variant="default"
+ onClick={onApprove}
+ disabled={isApprovePending}
+ >
+ {isApprovePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ Request
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/lib/vendors/table/send-vendor-dialog.tsx b/lib/vendors/table/send-vendor-dialog.tsx
new file mode 100644
index 00000000..a34abb77
--- /dev/null
+++ b/lib/vendors/table/send-vendor-dialog.tsx
@@ -0,0 +1,150 @@
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Check, Send } from "lucide-react"
+import { toast } from "sonner"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+import { Vendor } from "@/db/schema/vendors"
+import { requestPQVendors, sendVendors } from "../service"
+
+interface ApprovalVendorDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ vendors: Row<Vendor>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function SendVendorsDialog({
+ vendors,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: ApprovalVendorDialogProps) {
+ const [isApprovePending, startApproveTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onApprove() {
+ startApproveTransition(async () => {
+ const { error } = await sendVendors({
+ ids: vendors.map((vendor) => vendor.id),
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("PQ successfully sent to vendors")
+ onSuccess?.()
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Send className="size-4" aria-hidden="true" />
+ Send ({vendors.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Confirm to send Vendor Information</DialogTitle>
+ <DialogDescription>
+ Are you sure you want to send{" "}
+ <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? " vendor" : " vendors"}?
+ After vendor information is sent, vendor code will be generated.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DialogClose>
+ <Button
+ aria-label="Send selected vendors"
+ variant="default"
+ onClick={onApprove}
+ disabled={isApprovePending}
+ >
+ {isApprovePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Send
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Send className="size-4" aria-hidden="true" />
+ Send ({vendors.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>Confirm to send Vendor Information</DrawerTitle>
+ <DrawerDescription>
+ Are you sure you want to send{" "}
+ <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? " vendor" : " vendors"}?
+ After vendor information is sent, vendor code will be generated.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DrawerClose>
+ <Button
+ aria-label="Send selected vendors"
+ variant="default"
+ onClick={onApprove}
+ disabled={isApprovePending}
+ >
+ {isApprovePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ Send
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/lib/vendors/table/update-vendor-sheet.tsx b/lib/vendors/table/update-vendor-sheet.tsx
new file mode 100644
index 00000000..e65c4b1c
--- /dev/null
+++ b/lib/vendors/table/update-vendor-sheet.tsx
@@ -0,0 +1,270 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import { Loader } 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 { Vendor } from "@/db/schema/vendors"
+import { updateVendorSchema, type UpdateVendorSchema } from "../validations"
+import { modifyVendor } from "../service"
+// 예: import { modifyVendor } from "@/lib/vendors/service"
+
+interface UpdateVendorSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ vendor: Vendor | null
+}
+
+// 폼 컴포넌트
+export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps) {
+ const [isPending, startTransition] = React.useTransition()
+
+ console.log(vendor)
+
+ // RHF + Zod
+ const form = useForm<UpdateVendorSchema>({
+ resolver: zodResolver(updateVendorSchema),
+ defaultValues: {
+ vendorName: vendor?.vendorName ?? "",
+ vendorCode: vendor?.vendorCode ?? "",
+ address: vendor?.address ?? "",
+ country: vendor?.country ?? "",
+ phone: vendor?.phone ?? "",
+ email: vendor?.email ?? "",
+ website: vendor?.website ?? "",
+ 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 ?? "",
+ status: vendor?.status ?? "ACTIVE",
+ });
+ }
+ }, [vendor, form]);
+
+ console.log(form.getValues())
+ // 제출 핸들러
+ async function onSubmit(data: UpdateVendorSchema) {
+ if (!vendor) return
+
+ startTransition(async () => {
+ // 서버 액션 or API
+ // const { error } = await modifyVendor({ id: vendor.id, ...data })
+ // 여기선 간단 예시
+ try {
+ // 예시:
+ const { error } = await modifyVendor({ id: String(vendor.id), ...data })
+ if (error) throw new Error(error)
+
+ toast.success("Vendor updated!")
+ form.reset()
+ props.onOpenChange?.(false)
+ } catch (err: any) {
+ toast.error(String(err))
+ }
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>Update Vendor</SheetTitle>
+ <SheetDescription>
+ Update the vendor details and save the changes
+ </SheetDescription>
+ </SheetHeader>
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
+ {/* vendorName */}
+ <FormField
+ control={form.control}
+ name="vendorName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Vendor Name</FormLabel>
+ <FormControl>
+ <Input placeholder="Vendor Name" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* vendorCode */}
+ <FormField
+ control={form.control}
+ name="vendorCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Vendor Code</FormLabel>
+ <FormControl>
+ <Input placeholder="Code123" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* address */}
+ <FormField
+ control={form.control}
+ name="address"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Address</FormLabel>
+ <FormControl>
+ <Input placeholder="123 Main St" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* country */}
+ <FormField
+ control={form.control}
+ name="country"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Country</FormLabel>
+ <FormControl>
+ <Input placeholder="USA" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* phone */}
+ <FormField
+ control={form.control}
+ name="phone"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Phone</FormLabel>
+ <FormControl>
+ <Input placeholder="+1 555-1234" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* email */}
+ <FormField
+ control={form.control}
+ name="email"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Email</FormLabel>
+ <FormControl>
+ <Input placeholder="vendor@example.com" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* website */}
+ <FormField
+ control={form.control}
+ name="website"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Website</FormLabel>
+ <FormControl>
+ <Input placeholder="https://www.vendor.com" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* status */}
+ <FormField
+ control={form.control}
+ name="status"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Status</FormLabel>
+ <FormControl>
+ <Select
+ value={field.value}
+ onValueChange={field.onChange}
+ >
+ <SelectTrigger className="capitalize">
+ <SelectValue placeholder="Select a status" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectGroup>
+ {/* enum ["ACTIVE","INACTIVE","BLACKLISTED"] */}
+ <SelectItem value="ACTIVE">ACTIVE</SelectItem>
+ <SelectItem value="INACTIVE">INACTIVE</SelectItem>
+ <SelectItem value="BLACKLISTED">BLACKLISTED</SelectItem>
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ Cancel
+ </Button>
+ </SheetClose>
+ <Button disabled={isPending}>
+ {isPending && (
+ <Loader className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
+ )}
+ Save
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/vendors/table/vendors-table-columns.tsx b/lib/vendors/table/vendors-table-columns.tsx
new file mode 100644
index 00000000..c503e369
--- /dev/null
+++ b/lib/vendors/table/vendors-table-columns.tsx
@@ -0,0 +1,279 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis, PaperclipIcon } 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,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
+import { useRouter } from "next/navigation"
+
+import { Vendor, vendors, VendorWithAttachments } from "@/db/schema/vendors"
+import { modifyVendor } from "../service"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { vendorColumnsConfig } from "@/config/vendorColumnsConfig"
+import { Separator } from "@/components/ui/separator"
+import { AttachmentsButton } from "./attachmentButton"
+
+
+type NextRouter = ReturnType<typeof useRouter>;
+
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<Vendor> | null>>;
+ router: NextRouter;
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef<Vendor>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<Vendor> = {
+ 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<Vendor> = {
+ 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-40">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "update" })}
+ >
+ Edit
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onSelect={() => {
+ // 1) 만약 rowAction을 열고 싶다면
+ // setRowAction({ row, type: "update" })
+
+ // 2) 자세히 보기 페이지로 클라이언트 라우팅
+ router.push(`/evcp/vendors/${row.original.id}/info`);
+ }}
+ >
+ Details
+ </DropdownMenuItem>
+ <Separator />
+ <DropdownMenuSub>
+ <DropdownMenuSubTrigger>Status</DropdownMenuSubTrigger>
+ <DropdownMenuSubContent>
+ <DropdownMenuRadioGroup
+ value={row.original.status}
+ onValueChange={(value) => {
+ startUpdateTransition(() => {
+ toast.promise(
+ modifyVendor({
+ id: String(row.original.id),
+ status: value as Vendor["status"],
+ }),
+ {
+ loading: "Updating...",
+ success: "Label updated",
+ error: (err) => getErrorMessage(err),
+ }
+ )
+ })
+ }}
+ >
+ {vendors.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<Vendor>[] }
+ const groupMap: Record<string, ColumnDef<Vendor>[]> = {}
+
+ vendorColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<Vendor> = {
+ 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 === "status") {
+ const statusVal = row.original.status
+ if (!statusVal) return null
+ // const Icon = getStatusIcon(statusVal)
+ return (
+ <div className="flex w-[6.25rem] items-center">
+ {/* <Icon className="mr-2 size-4 text-muted-foreground" aria-hidden="true" /> */}
+ <span className="capitalize">{statusVal}</span>
+ </div>
+ )
+ }
+
+
+ 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<Vendor>[] = []
+
+ // 순서를 고정하고 싶다면 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,
+ })
+ }
+ })
+
+ const attachmentsColumn: ColumnDef<VendorWithAttachments> = {
+ id: "attachments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="" />
+ ),
+ cell: ({ row }) => {
+ // hasAttachments 및 attachmentsList 속성이 추가되었다고 가정
+ const hasAttachments = row.original.hasAttachments;
+ const attachmentsList = row.original.attachmentsList || [];
+
+ if(hasAttachments){
+
+ // 서버 액션을 사용하는 컴포넌트로 교체
+ return (
+ <AttachmentsButton
+ vendorId={row.original.id}
+ hasAttachments={hasAttachments}
+ attachmentsList={attachmentsList}
+ />
+ );}{
+ return null
+ }
+ },
+ enableSorting: false,
+ enableHiding: false,
+ minSize: 45,
+ };
+
+
+ // ----------------------------------------------------------------
+ // 4) 최종 컬럼 배열: select, nestedColumns, actions
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ attachmentsColumn,
+ ...nestedColumns,
+ actionsColumn,
+ ]
+} \ No newline at end of file
diff --git a/lib/vendors/table/vendors-table-floating-bar.tsx b/lib/vendors/table/vendors-table-floating-bar.tsx
new file mode 100644
index 00000000..791fb760
--- /dev/null
+++ b/lib/vendors/table/vendors-table-floating-bar.tsx
@@ -0,0 +1,241 @@
+"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 { modifyVendors } from "../service"
+
+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"
+ >()
+ const [popoverOpen, setPopoverOpen] = React.useState(false)
+
+ // 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 modifyVendors({
+ ids: rows.map((row) => String(row.original.id)),
+ status: newStatus,
+ })
+ 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/vendors/table/vendors-table-toolbar-actions.tsx b/lib/vendors/table/vendors-table-toolbar-actions.tsx
new file mode 100644
index 00000000..c0605191
--- /dev/null
+++ b/lib/vendors/table/vendors-table-toolbar-actions.tsx
@@ -0,0 +1,97 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, Upload, Check } from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { Vendor } from "@/db/schema/vendors"
+import { ApproveVendorsDialog } from "./approve-vendor-dialog"
+import { RequestPQVendorsDialog } from "./request-vendor-pg-dialog"
+import { SendVendorsDialog } from "./send-vendor-dialog"
+
+interface VendorsTableToolbarActionsProps {
+ table: Table<Vendor>
+}
+
+export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActionsProps) {
+ // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+ // 선택된 벤더 중 PENDING_REVIEW 상태인 벤더만 필터링
+ const pendingReviewVendors = React.useMemo(() => {
+ return table
+ .getFilteredSelectedRowModel()
+ .rows
+ .map(row => row.original)
+ .filter(vendor => vendor.status === "PENDING_REVIEW");
+ }, [table.getFilteredSelectedRowModel().rows]);
+
+
+ // 선택된 벤더 중 PENDING_REVIEW 상태인 벤더만 필터링
+ const inReviewVendors = React.useMemo(() => {
+ return table
+ .getFilteredSelectedRowModel()
+ .rows
+ .map(row => row.original)
+ .filter(vendor => vendor.status === "IN_REVIEW");
+ }, [table.getFilteredSelectedRowModel().rows]);
+
+ const approvedVendors = React.useMemo(() => {
+ return table
+ .getFilteredSelectedRowModel()
+ .rows
+ .map(row => row.original)
+ .filter(vendor => vendor.status === "APPROVED");
+ }, [table.getFilteredSelectedRowModel().rows]);
+
+
+
+ return (
+ <div className="flex items-center gap-2">
+
+
+
+ {/* 승인 다이얼로그: PENDING_REVIEW 상태인 벤더가 있을 때만 표시 */}
+ {pendingReviewVendors.length > 0 && (
+ <ApproveVendorsDialog
+ vendors={pendingReviewVendors}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ )}
+
+ {inReviewVendors.length > 0 && (
+ <RequestPQVendorsDialog
+ vendors={inReviewVendors}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ )}
+
+ {approvedVendors.length > 0 && (
+ <SendVendorsDialog
+ vendors={approvedVendors}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ )}
+
+
+ {/** 4) Export 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "vendors",
+ 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/vendors/table/vendors-table.tsx b/lib/vendors/table/vendors-table.tsx
new file mode 100644
index 00000000..c04d57a9
--- /dev/null
+++ b/lib/vendors/table/vendors-table.tsx
@@ -0,0 +1,121 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+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 "./vendors-table-columns"
+import { getVendors, getVendorStatusCounts } from "../service"
+import { Vendor, vendors } from "@/db/schema/vendors"
+import { VendorsTableToolbarActions } from "./vendors-table-toolbar-actions"
+import { VendorsTableFloatingBar } from "./vendors-table-floating-bar"
+import { UpdateTaskSheet } from "@/lib/tasks/table/update-task-sheet"
+import { UpdateVendorSheet } from "./update-vendor-sheet"
+
+interface VendorsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getVendors>>,
+ Awaited<ReturnType<typeof getVendorStatusCounts>>
+ ]
+ >
+}
+
+export function VendorsTable({ promises }: VendorsTableProps) {
+ const { featureFlags } = useFeatureFlags()
+
+ // Suspense로 받아온 데이터
+ const [{ data, pageCount }, statusCounts] = React.use(promises)
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<Vendor> | null>(null)
+
+ // **router** 획득
+ const router = useRouter()
+
+ // getColumns() 호출 시, router를 주입
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction, router }),
+ [setRowAction, router]
+ )
+
+ const filterFields: DataTableFilterField<Vendor>[] = [
+ {
+ id: "status",
+ label: "Status",
+ options: vendors.status.enumValues.map((status) => ({
+ label: toSentenceCase(status),
+ value: status,
+ count: statusCounts[status],
+ })),
+ },
+
+ { id: "vendorCode", label: "Vendor Code" },
+
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField<Vendor>[] = [
+ { id: "vendorName", label: "Vendor Name", type: "text" },
+ { id: "vendorCode", label: "Vendor Code", type: "text" },
+ { id: "email", label: "Email", type: "text" },
+ { id: "country", label: "Country", type: "text" },
+ {
+ id: "status",
+ label: "Status",
+ type: "multi-select",
+ options: vendors.status.enumValues.map((status) => ({
+ label: toSentenceCase(status),
+ value: status,
+ count: statusCounts[status],
+ })),
+ },
+ { 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}
+ // floatingBar={<VendorsTableFloatingBar table={table} />}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <VendorsTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ <UpdateVendorSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ vendor={rowAction?.row.original ?? null}
+ />
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/vendors/validations.ts b/lib/vendors/validations.ts
new file mode 100644
index 00000000..14efc8dc
--- /dev/null
+++ b/lib/vendors/validations.ts
@@ -0,0 +1,341 @@
+import { tasks, type Task } from "@/db/schema/tasks";
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { Vendor, VendorContact, VendorItemsView, vendors } from "@/db/schema/vendors";
+import { rfqs } from "@/db/schema/rfq"
+
+
+export const searchParamsCache = createSearchParamsCache({
+
+ // 공통 플래그
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+
+ // 페이징
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 정렬 (vendors 테이블에 맞춰 Vendor 타입 지정)
+ sort: getSortingStateParser<Vendor>().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"]),
+
+ // 벤더명 검색
+ vendorName: parseAsString.withDefault(""),
+
+ // 국가 검색
+ country: parseAsString.withDefault(""),
+
+ // 예) 코드 검색
+ vendorCode: parseAsString.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),
+
+ // 정렬 (vendors 테이블에 맞춰 Vendor 타입 지정)
+ sort: getSortingStateParser<VendorContact>().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),
+
+ // 정렬 (vendors 테이블에 맞춰 Vendor 타입 지정)
+ sort: getSortingStateParser<VendorItemsView>().withDefault([
+ { id: "createdAt", desc: true }, // createdAt 기준 내림차순
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 검색 키워드
+ search: parseAsString.withDefault(""),
+
+
+ itemName: parseAsString.withDefault(""),
+ itemCode: parseAsString.withDefault(""),
+ description: parseAsString.withDefault(""),
+});
+
+
+export const updateVendorSchema = z.object({
+ vendorName: z.string().min(1, "Vendor name is required").max(255, "Max length 255").optional(),
+ vendorCode: z.string().max(100, "Max length 100").optional(),
+ address: z.string().optional(),
+ country: z.string().max(100, "Max length 100").optional(),
+ phone: z.string().max(50, "Max length 50").optional(),
+ email: z.string().email("Invalid email").max(255).optional(),
+ website: z.string().url("Invalid URL").max(255).optional(),
+
+ // status는 특정 값만 허용하도록 enum 사용 예시
+ // 필요 시 'SUSPENDED', 'BLACKLISTED' 등 추가하거나 제거 가능
+ status: z.enum(vendors.status.enumValues)
+ .optional()
+ .default("ACTIVE"),
+});
+
+
+const contactSchema = z.object({
+ 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()})
+
+const vendorStatusEnum = z.enum(vendors.status.enumValues)
+// CREATE 시: 일부 필드는 필수, 일부는 optional
+export const createVendorSchema = z
+ .object({
+
+ vendorName: z
+ .string()
+ .min(1, "Vendor name is required")
+ .max(255, "Max length 255"),
+ email: z.string().email("Invalid email").max(255),
+ taxId: z.string().max(100, "Max length 100"),
+
+ // 나머지 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().url("Invalid URL").max(255).optional(),
+
+ creditRatingAttachment: z.any().optional(), // 신용평가 첨부
+ cashFlowRatingAttachment: z.any().optional(), // 현금흐름 첨부
+ attachedFiles: z.any()
+ .refine(
+ val => {
+ // Validate that files exist and there's at least one file
+ return val &&
+ (Array.isArray(val) ? val.length > 0 :
+ val instanceof FileList ? val.length > 0 :
+ val && typeof val === 'object' && 'length' in val && val.length > 0);
+ },
+ { message: "첨부 파일은 필수입니다." }
+ ),
+ status: vendorStatusEnum.default("PENDING_REVIEW"),
+
+ representativeName: z.union([z.string().max(255), z.literal("")]).optional(),
+ representativeBirth: z.union([z.string().max(20), z.literal("")]).optional(),
+ representativeEmail: z.union([z.string().email("Invalid email").max(255), z.literal("")]).optional(),
+ representativePhone: z.union([z.string().max(50), z.literal("")]).optional(),
+ corporateRegistrationNumber: z.union([z.string().max(100), z.literal("")]).optional(),
+
+ creditAgency: z.string().max(50).optional(),
+ creditRating: z.string().max(50).optional(),
+ cashFlowRating: z.string().max(50).optional(),
+
+ 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) 업체일 경우 필수입니다.",
+ })
+ }
+ if (!data.corporateRegistrationNumber) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["corporateRegistrationNumber"],
+ message: "법인등록번호는 한국(KR) 업체일 경우 필수입니다.",
+ })
+ }
+
+ // 2) 신용/현금흐름 등급도 필수라면
+ if (!data.creditAgency) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["creditAgency"],
+ message: "신용평가사 선택은 한국(KR) 업체일 경우 필수입니다.",
+ })
+ }
+ if (!data.creditRating) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["creditRating"],
+ message: "신용평가등급은 한국(KR) 업체일 경우 필수입니다.",
+ })
+ }
+ if (!data.cashFlowRating) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["cashFlowRating"],
+ message: "현금흐름등급은 한국(KR) 업체일 경우 필수입니다.",
+ })
+ }
+ }
+ }
+)
+
+export const createVendorContactSchema = 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(),
+ isPrimary: z.boolean(),
+});
+
+
+export const updateVendorContactSchema = 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(),
+ isPrimary: z.boolean().optional(),
+});
+
+
+
+export const createVendorItemSchema = z.object({
+ vendorId: z.number(),
+ itemCode: z.string().max(100, "Max length 100"),
+
+});
+
+
+export const updateVendorItemSchema = z.object({
+ itemName: z.string().optional(),
+ itemCode: z.string().max(100, "Max length 100"),
+ description: z.string().optional()
+});
+
+export const searchParamsRfqHistoryCache = createSearchParamsCache({
+ // 공통 플래그
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+
+ // 페이징
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 정렬
+ sort: getSortingStateParser<typeof rfqs.$inferSelect>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 검색 키워드
+ search: parseAsString.withDefault(""),
+
+ // RFQ 특화 필터
+ rfqCode: parseAsString.withDefault(""),
+ projectCode: parseAsString.withDefault(""),
+ projectName: parseAsString.withDefault(""),
+ status: parseAsStringEnum(["DRAFT", "IN_PROGRESS", "COMPLETED", "CANCELLED"]),
+ vendorStatus: parseAsStringEnum(["INVITED", "ACCEPTED", "DECLINED", "SUBMITTED", "AWARDED", "REJECTED"]),
+ dueDate: parseAsString.withDefault(""),
+});
+
+export type GetVendorsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
+export type GetVendorContactsSchema = Awaited<ReturnType<typeof searchParamsContactCache.parse>>
+export type GetVendorItemsSchema = Awaited<ReturnType<typeof searchParamsItemCache.parse>>
+
+export type UpdateVendorSchema = z.infer<typeof updateVendorSchema>
+export type CreateVendorSchema = z.infer<typeof createVendorSchema>
+export type CreateVendorContactSchema = z.infer<typeof createVendorContactSchema>
+export type UpdateVendorContactSchema = z.infer<typeof updateVendorContactSchema>
+export type CreateVendorItemSchema = z.infer<typeof createVendorItemSchema>
+export type UpdateVendorItemSchema = z.infer<typeof updateVendorItemSchema>
+export type GetRfqHistorySchema = Awaited<ReturnType<typeof searchParamsRfqHistoryCache.parse>>