diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-03-26 00:37:41 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-03-26 00:37:41 +0000 |
| commit | e0dfb55c5457aec489fc084c4567e791b4c65eb1 (patch) | |
| tree | 68543a65d88f5afb3a0202925804103daa91bc6f /lib/vendors | |
3/25 까지의 대표님 작업사항
Diffstat (limited to 'lib/vendors')
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>> |
