From 0547ab2fe1701d84753d0e078bba718a79b07a0c Mon Sep 17 00:00:00 2001 From: dujinkim Date: Fri, 23 May 2025 05:26:26 +0000 Subject: (최겸)기술영업 벤더 개발 초안(index 스키마 미포함 상태) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../contacts-table/add-contact-dialog.tsx | 175 +++ .../contacts-table/contact-table-columns.tsx | 195 ++++ .../contact-table-toolbar-actions.tsx | 103 ++ lib/tech-vendors/contacts-table/contact-table.tsx | 87 ++ .../contacts-table/feature-flags-provider.tsx | 108 ++ lib/tech-vendors/items-table/add-item-dialog.tsx | 317 ++++++ .../items-table/feature-flags-provider.tsx | 108 ++ .../items-table/item-table-columns.tsx | 192 ++++ .../items-table/item-table-toolbar-actions.tsx | 104 ++ lib/tech-vendors/items-table/item-table.tsx | 78 ++ lib/tech-vendors/repository.ts | 324 ++++++ lib/tech-vendors/service.ts | 1174 ++++++++++++++++++++ lib/tech-vendors/table/attachmentButton.tsx | 76 ++ lib/tech-vendors/table/excel-template-download.tsx | 128 +++ lib/tech-vendors/table/feature-flags-provider.tsx | 108 ++ lib/tech-vendors/table/import-button.tsx | 293 +++++ .../table/tech-vendors-table-columns.tsx | 331 ++++++ .../table/tech-vendors-table-floating-bar.tsx | 240 ++++ .../table/tech-vendors-table-toolbar-actions.tsx | 166 +++ lib/tech-vendors/table/tech-vendors-table.tsx | 148 +++ lib/tech-vendors/table/update-vendor-sheet.tsx | 390 +++++++ lib/tech-vendors/table/vendor-all-export.ts | 252 +++++ lib/tech-vendors/utils.ts | 28 + lib/tech-vendors/validations.ts | 260 +++++ 24 files changed, 5385 insertions(+) create mode 100644 lib/tech-vendors/contacts-table/add-contact-dialog.tsx create mode 100644 lib/tech-vendors/contacts-table/contact-table-columns.tsx create mode 100644 lib/tech-vendors/contacts-table/contact-table-toolbar-actions.tsx create mode 100644 lib/tech-vendors/contacts-table/contact-table.tsx create mode 100644 lib/tech-vendors/contacts-table/feature-flags-provider.tsx create mode 100644 lib/tech-vendors/items-table/add-item-dialog.tsx create mode 100644 lib/tech-vendors/items-table/feature-flags-provider.tsx create mode 100644 lib/tech-vendors/items-table/item-table-columns.tsx create mode 100644 lib/tech-vendors/items-table/item-table-toolbar-actions.tsx create mode 100644 lib/tech-vendors/items-table/item-table.tsx create mode 100644 lib/tech-vendors/repository.ts create mode 100644 lib/tech-vendors/service.ts create mode 100644 lib/tech-vendors/table/attachmentButton.tsx create mode 100644 lib/tech-vendors/table/excel-template-download.tsx create mode 100644 lib/tech-vendors/table/feature-flags-provider.tsx create mode 100644 lib/tech-vendors/table/import-button.tsx create mode 100644 lib/tech-vendors/table/tech-vendors-table-columns.tsx create mode 100644 lib/tech-vendors/table/tech-vendors-table-floating-bar.tsx create mode 100644 lib/tech-vendors/table/tech-vendors-table-toolbar-actions.tsx create mode 100644 lib/tech-vendors/table/tech-vendors-table.tsx create mode 100644 lib/tech-vendors/table/update-vendor-sheet.tsx create mode 100644 lib/tech-vendors/table/vendor-all-export.ts create mode 100644 lib/tech-vendors/utils.ts create mode 100644 lib/tech-vendors/validations.ts (limited to 'lib') diff --git a/lib/tech-vendors/contacts-table/add-contact-dialog.tsx b/lib/tech-vendors/contacts-table/add-contact-dialog.tsx new file mode 100644 index 00000000..05e5092e --- /dev/null +++ b/lib/tech-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 { + createTechVendorContactSchema, + type CreateTechVendorContactSchema, +} from "@/lib/tech-vendors/validations" +import { createTechVendorContact } from "@/lib/tech-vendors/service" + +interface AddContactDialogProps { + vendorId: number +} + +export function AddContactDialog({ vendorId }: AddContactDialogProps) { + const [open, setOpen] = React.useState(false) + + // react-hook-form 세팅 + const form = useForm({ + resolver: zodResolver(createTechVendorContactSchema), + defaultValues: { + // vendorId는 form에 표시할 필요가 없다면 hidden으로 관리하거나, submit 시 추가 + vendorId, + contactName: "", + contactPosition: "", + contactEmail: "", + contactPhone: "", + isPrimary: false, + }, + }) + + async function onSubmit(data: CreateTechVendorContactSchema) { + // 혹은 여기서 data.vendorId = vendorId; 해줘도 됨 + const result = await createTechVendorContact(data) + if (result.error) { + alert(`에러: ${result.error}`) + return + } + // 성공 시 모달 닫고 폼 리셋 + form.reset() + setOpen(false) + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset() + } + setOpen(nextOpen) + } + + return ( + + {/* 모달을 열기 위한 버튼 */} + + + + + + + Create New Contact + + 새 Contact 정보를 입력하고 Create 버튼을 누르세요. + + + + {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */} +
+ +
+ ( + + Contact Name + + + + + + )} + /> + + ( + + Position / Title + + + + + + )} + /> + + ( + + Email + + + + + + )} + /> + + ( + + Phone + + + + + + )} + /> + + {/* 단순 checkbox */} + ( + +
+ field.onChange(e.target.checked)} + /> + Is Primary? +
+ +
+ )} + /> +
+ + + + + +
+ +
+
+ ) +} \ No newline at end of file diff --git a/lib/tech-vendors/contacts-table/contact-table-columns.tsx b/lib/tech-vendors/contacts-table/contact-table-columns.tsx new file mode 100644 index 00000000..f80fae33 --- /dev/null +++ b/lib/tech-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 | null>>; +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef = { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size:40, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + return ( + + + + + + { + setRowAction({ row, type: "update" }) + + }} + > + Edit + + + + setRowAction({ row, type: "delete" })} + > + Delete + ⌘⌫ + + + + ) + }, + size: 40, + } + + // ---------------------------------------------------------------- + // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 3-1) groupMap: { [groupName]: ColumnDef[] } + const groupMap: Record[]> = {} + + vendorContactsColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + + ), + 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[] = [] + + // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 + // 여기서는 그냥 Object.entries 순서 + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + // 그룹 없음 → 그냥 최상위 레벨 컬럼 + nestedColumns.push(...colDefs) + } else { + // 상위 컬럼 + nestedColumns.push({ + id: groupName, + header: groupName, // "Basic Info", "Metadata" 등 + columns: colDefs, + }) + } + }) + + // ---------------------------------------------------------------- + // 4) 최종 컬럼 배열: select, nestedColumns, actions + // ---------------------------------------------------------------- + return [ + selectColumn, + ...nestedColumns, + actionsColumn, + ] +} \ No newline at end of file diff --git a/lib/tech-vendors/contacts-table/contact-table-toolbar-actions.tsx b/lib/tech-vendors/contacts-table/contact-table-toolbar-actions.tsx new file mode 100644 index 00000000..7622c6d6 --- /dev/null +++ b/lib/tech-vendors/contacts-table/contact-table-toolbar-actions.tsx @@ -0,0 +1,103 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, Upload } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { TechVendorContact } from "@/db/schema/techVendors" +import { AddContactDialog } from "./add-contact-dialog" +import { importTasksExcel } from "@/lib/tasks/service" + +interface TechVendorContactsTableToolbarActionsProps { + table: Table + vendorId: number +} + +export function TechVendorContactsTableToolbarActions({ table, vendorId }: TechVendorContactsTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + const fileInputRef = React.useRef(null) + + // 파일이 선택되었을 때 처리 + async function onFileChange(event: React.ChangeEvent) { + const file = event.target.files?.[0] + if (!file) return + + // 파일 초기화 (동일 파일 재업로드 시에도 onChange가 트리거되도록) + event.target.value = "" + + // 서버 액션 or API 호출 + try { + // 예: 서버 액션 호출 + const { errorFile, errorMessage } = await importTasksExcel(file) + + if (errorMessage) { + toast.error(errorMessage) + } + if (errorFile) { + // 에러 엑셀을 다운로드 + const url = URL.createObjectURL(errorFile) + const link = document.createElement("a") + link.href = url + link.download = "errors.xlsx" + link.click() + URL.revokeObjectURL(url) + } else { + // 성공 + toast.success("Import success") + // 필요 시 revalidateTag("tasks") 등 + } + + } catch (error) { + toast.error("파일 업로드 중 오류가 발생했습니다.") + + } + } + + function handleImportClick() { + // 숨겨진 요소를 클릭 + fileInputRef.current?.click() + } + + return ( +
+ + + + {/** 3) Import 버튼 (파일 업로드) */} + + {/* + 실제로는 숨겨진 input과 연결: + - accept=".xlsx,.xls" 등으로 Excel 파일만 업로드 허용 + */} + + + {/** 4) Export 버튼 */} + +
+ ) +} \ No newline at end of file diff --git a/lib/tech-vendors/contacts-table/contact-table.tsx b/lib/tech-vendors/contacts-table/contact-table.tsx new file mode 100644 index 00000000..cccf490c --- /dev/null +++ b/lib/tech-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 { getTechVendorContacts } from "../service" +import { TechVendorContact } from "@/db/schema/techVendors" +import { TechVendorContactsTableToolbarActions } from "./contact-table-toolbar-actions" + +interface TechVendorContactsTableProps { + promises: Promise< + [ + Awaited>, + ] + >, + vendorId:number +} + +export function TechVendorContactsTable({ promises , vendorId}: TechVendorContactsTableProps) { + const { featureFlags } = useFeatureFlags() + + // Suspense로 받아온 데이터 + const [{ data, pageCount }] = React.use(promises) + + const [rowAction, setRowAction] = React.useState | null>(null) + + // getColumns() 호출 시, router를 주입 + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + const filterFields: DataTableFilterField[] = [ + + ] + + const advancedFilterFields: DataTableAdvancedFilterField[] = [ + { 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 ( + <> + + + + + + + ) +} \ No newline at end of file diff --git a/lib/tech-vendors/contacts-table/feature-flags-provider.tsx b/lib/tech-vendors/contacts-table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/tech-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({ + 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( + "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 ( + void setFeatureFlags(value), + }} + > +
+ setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + + + + + + +
{flag.tooltipTitle}
+
+ {flag.tooltipDescription} +
+
+
+ ))} +
+
+ {children} +
+ ) +} diff --git a/lib/tech-vendors/items-table/add-item-dialog.tsx b/lib/tech-vendors/items-table/add-item-dialog.tsx new file mode 100644 index 00000000..bd1c32f5 --- /dev/null +++ b/lib/tech-vendors/items-table/add-item-dialog.tsx @@ -0,0 +1,317 @@ +"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 { useRouter } from "next/navigation" + +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, +} 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 { toast } from "sonner" + +import { + createTechVendorItemSchema, + type CreateTechVendorItemSchema, +} from "@/lib/tech-vendors/validations" + +import { createTechVendorItem, getItemsByVendorType, ItemDropdownOption } from "../service" + +interface AddItemDialogProps { + vendorId: number + vendorType: string +} + +export function AddItemDialog({ vendorId, vendorType }: AddItemDialogProps) { + const router = useRouter() + const [open, setOpen] = React.useState(false) + const [commandOpen, setCommandOpen] = React.useState(false) + const [items, setItems] = React.useState([]) + const [filteredItems, setFilteredItems] = React.useState([]) + const [isLoading, setIsLoading] = React.useState(false) + const [searchTerm, setSearchTerm] = React.useState("") + + const [selectedItem, setSelectedItem] = React.useState<{ + itemName: string; + description: string; + } | null>(null) + + const form = useForm({ + resolver: zodResolver(createTechVendorItemSchema), + defaultValues: { + vendorId, + itemCode: "", + }, + }) + + const fetchItems = React.useCallback(async () => { + if (items.length > 0) return + + console.log(`[AddItemDialog] fetchItems - 벤더 타입: ${vendorType || '알 수 없음'}, 벤더 ID: ${vendorId} 시작`) + + if (!vendorType) { + console.error("[AddItemDialog] 벤더 타입이 지정되지 않았습니다. 아이템을 불러올 수 없습니다.") + toast.error("벤더 타입이 지정되지 않아 아이템을 불러올 수 없습니다.") + setIsLoading(false) + return + } + + setIsLoading(true) + try { + console.log(`[AddItemDialog] getItemsByVendorType 호출 - 타입: ${vendorType}`) + const result = await getItemsByVendorType(vendorType, "") + console.log(`[AddItemDialog] getItemsByVendorType 결과:`, result) + + if (result.data) { + const formattedItems = result.data.map(item => ({ + itemCode: item.itemCode, + itemName: "기술영업", + description: "" + })) + console.log(`[AddItemDialog] 포맷된 아이템 목록:`, formattedItems) + setItems(formattedItems) + setFilteredItems(formattedItems) + } + } catch (err) { + console.error("[AddItemDialog] 아이템 조회 실패:", err) + toast.error("아이템 목록을 불러오는데 실패했습니다.") + } finally { + setIsLoading(false) + console.log(`[AddItemDialog] fetchItems 완료`) + } + }, [items.length, vendorType, vendorId]) + + React.useEffect(() => { + if (commandOpen) { + console.log(`[AddItemDialog] Popover 열림 - fetchItems 호출`) + fetchItems() + } + }, [commandOpen, fetchItems]) + + React.useEffect(() => { + if (!items.length) return + + if (!searchTerm.trim()) { + setFilteredItems(items) + return + } + + console.log(`[AddItemDialog] 검색어로 필터링: "${searchTerm}"`) + 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)) + ) + + console.log(`[AddItemDialog] 필터링 결과: ${filtered.length}개 아이템`) + setFilteredItems(filtered) + }, [searchTerm, items]) + + const handleSelectItem = (item: ItemDropdownOption) => { + console.log(`[AddItemDialog] 아이템 선택: ${item.itemCode}`) + form.setValue("itemCode", item.itemCode, { shouldValidate: true }) + setSelectedItem({ + itemName: item.itemName, + description: item.description || "", + }) + console.log(`[AddItemDialog] 선택된 아이템 정보:`, { + itemCode: item.itemCode, + itemName: item.itemName, + description: item.description || "" + }) + setCommandOpen(false) + } + + async function onSubmit(data: CreateTechVendorItemSchema) { + console.log(`[AddItemDialog] 폼 제출 시작 - 데이터:`, data) + try { + if (!data.itemCode) { + console.error(`[AddItemDialog] itemCode가 없습니다.`) + toast.error("아이템을 선택해주세요.") + return + } + + console.log(`[AddItemDialog] createTechVendorItem 호출 - vendorId: ${data.vendorId}, itemCode: ${data.itemCode}`) + const submitData = { + ...data, + itemName: "기술영업" + } + console.log(`[AddItemDialog] 최종 제출 데이터:`, submitData) + + const result = await createTechVendorItem(submitData) + console.log(`[AddItemDialog] createTechVendorItem 결과:`, result) + + if (result.error) { + console.error(`[AddItemDialog] 추가 실패:`, result.error) + toast.error(result.error) + return + } + + console.log(`[AddItemDialog] 아이템 추가 성공`) + toast.success("아이템이 추가되었습니다.") + form.reset() + setSelectedItem(null) + setOpen(false) + console.log(`[AddItemDialog] 화면 새로고침 시작`) + router.refresh() + console.log(`[AddItemDialog] 화면 새로고침 완료`) + } catch (err) { + console.error("[AddItemDialog] 아이템 추가 오류:", err) + toast.error("아이템 추가 중 오류가 발생했습니다.") + } + } + + function handleDialogOpenChange(nextOpen: boolean) { + console.log(`[AddItemDialog] 다이얼로그 상태 변경: ${nextOpen ? '열림' : '닫힘'}`) + if (!nextOpen) { + form.reset() + setSelectedItem(null) + } + setOpen(nextOpen) + } + + const selectedItemCode = form.watch("itemCode") + console.log(`[AddItemDialog] 현재 선택된 itemCode:`, selectedItemCode) + const displayItemName = selectedItem?.itemName || "" + + return ( + + + + + + + + Create New Item + + 아이템을 선택한 후 Create 버튼을 누르세요. + + + +
+ { + console.log(`[AddItemDialog] 폼 제출 이벤트 발생`) + form.handleSubmit(onSubmit)(e) + }} className="flex flex-col flex-1 overflow-hidden"> +
+
+ 아이템 선택 + + + + + + + + + 검색 결과가 없습니다 + {isLoading ? ( +
로딩 중...
+ ) : ( + + {filteredItems.map((item) => ( + handleSelectItem(item)} + > + + {item.itemCode} + - {item.itemName} + + ))} + + )} +
+
+
+
+
+ + {selectedItem && ( +
+

선택된 아이템 정보

+ + ( + + + + + + )} + /> + +
+
Item Name
+
{selectedItem.itemName}
+
+ + {selectedItem.description && ( +
+
Description
+
{selectedItem.description}
+
+ )} +
+ )} +
+ + + + +
+ +
+
+ ) +} \ No newline at end of file diff --git a/lib/tech-vendors/items-table/feature-flags-provider.tsx b/lib/tech-vendors/items-table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/tech-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({ + 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( + "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 ( + void setFeatureFlags(value), + }} + > +
+ setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + + + + + + +
{flag.tooltipTitle}
+
+ {flag.tooltipDescription} +
+
+
+ ))} +
+
+ {children} +
+ ) +} diff --git a/lib/tech-vendors/items-table/item-table-columns.tsx b/lib/tech-vendors/items-table/item-table-columns.tsx new file mode 100644 index 00000000..72986849 --- /dev/null +++ b/lib/tech-vendors/items-table/item-table-columns.tsx @@ -0,0 +1,192 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { MoreHorizontal } from "lucide-react" +import { format } from "date-fns" +import { ko } from "date-fns/locale" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +import { TechVendorItemsView } from "@/db/schema/techVendors" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { + techVendorItemsColumnsConfig, + shipbuildingColumnsConfig, + offshoreTopColumnsConfig, + offshoreHullColumnsConfig +} from "@/config/techVendorItemsColumnsConfig" + +interface ColumnConfig { + id: string + label: string + excelHeader: string + type: string + minWidth: number + defaultWidth: number + group?: string +} + +interface GetColumnsOptions { + setRowAction: React.Dispatch | null>> + vendorType: string +} + +export function getColumns({ setRowAction, vendorType }: GetColumnsOptions): ColumnDef[] { + // 벤더 타입에 따라 적절한 컬럼 설정 선택 + const columnsConfig = (() => { + switch (vendorType) { + case "조선": + return shipbuildingColumnsConfig; + case "해양TOP": + return offshoreTopColumnsConfig; + case "해양HULL": + return offshoreHullColumnsConfig; + default: + return techVendorItemsColumnsConfig; + } + })(); + + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef = { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-[2px]" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-[2px]" + /> + ), + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + // const actionsColumn: ColumnDef = { + // id: "actions", + // cell: ({ row }) => { + // return ( + // + // + // + // + // + // Actions + // + // setRowAction({ + // type: "update", + // row, + // }) + // }> + // View Details + // + // + // + // ) + // }, + // } + + // ---------------------------------------------------------------- + // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + const groupMap: Record[]> = {} + + columnsConfig.forEach((cfg: ColumnConfig) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + + ), + minSize: cfg.minWidth, + size: cfg.defaultWidth, + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + if (cfg.id === "createdAt" || cfg.id === "updatedAt") { + const dateVal = cell.getValue() as Date + return format(dateVal, "PPP", { locale: ko }) + } + + if (cfg.id === "techVendorType") { + const type = cell.getValue() as string + return type ? ( + + {type} + + ) : null + } + + return row.getValue(cfg.id) ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // ---------------------------------------------------------------- + // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef[] = [] + + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + nestedColumns.push(...colDefs) + } else { + nestedColumns.push({ + id: groupName, + header: groupName, + columns: colDefs, + }) + } + }) + + // ---------------------------------------------------------------- + // 4) 최종 컬럼 배열: select, nestedColumns, actions + // ---------------------------------------------------------------- + return [ + selectColumn, + ...nestedColumns, + // actionsColumn, + ] +} \ No newline at end of file diff --git a/lib/tech-vendors/items-table/item-table-toolbar-actions.tsx b/lib/tech-vendors/items-table/item-table-toolbar-actions.tsx new file mode 100644 index 00000000..68a20816 --- /dev/null +++ b/lib/tech-vendors/items-table/item-table-toolbar-actions.tsx @@ -0,0 +1,104 @@ +"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 { TechVendorItemsView } from "@/db/schema/techVendors" +import { AddItemDialog } from "./add-item-dialog" +import { importTasksExcel } from "@/lib/tasks/service" + +interface TechVendorItemsTableToolbarActionsProps { + table: Table + vendorId: number + vendorType: string +} + +export function TechVendorItemsTableToolbarActions({ table, vendorId, vendorType }: TechVendorItemsTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + const fileInputRef = React.useRef(null) + + // 파일이 선택되었을 때 처리 + async function onFileChange(event: React.ChangeEvent) { + 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) { + console.error("파일 업로드 중 오류가 발생했습니다:", err) + toast.error("파일 업로드 중 오류가 발생했습니다.") + } + } + + function handleImportClick() { + // 숨겨진 요소를 클릭 + fileInputRef.current?.click() + } + + return ( +
+ + + + {/** 3) Import 버튼 (파일 업로드) */} + + {/* + 실제로는 숨겨진 input과 연결: + - accept=".xlsx,.xls" 등으로 Excel 파일만 업로드 허용 + */} + + + {/** 4) Export 버튼 */} + +
+ ) +} \ No newline at end of file diff --git a/lib/tech-vendors/items-table/item-table.tsx b/lib/tech-vendors/items-table/item-table.tsx new file mode 100644 index 00000000..52e5a57f --- /dev/null +++ b/lib/tech-vendors/items-table/item-table.tsx @@ -0,0 +1,78 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { getColumns } from "./item-table-columns" +import { TechVendorItemsView } from "@/db/schema/techVendors" +import { TechVendorItemsTableToolbarActions } from "./item-table-toolbar-actions" + +interface TechVendorItemsTableProps { + data: (TechVendorItemsView & { techVendorType?: string })[] + vendorId: number + vendorType: string +} + +export function TechVendorItemsTable({ data, vendorId, vendorType }: TechVendorItemsTableProps) { + const [rowAction, setRowAction] = React.useState | null>(null) + + const columns = React.useMemo( + () => getColumns({ + setRowAction, + vendorType + }), + [vendorType] + ) + + const filterFields: DataTableFilterField[] = [] + + const advancedFilterFields: DataTableAdvancedFilterField[] = [ + { id: "itemName", label: "Item Name", type: "text" }, + { id: "itemCode", label: "Item Code", type: "text" }, + { id: "createdAt", label: "Created at", type: "date" }, + { id: "updatedAt", label: "Updated at", type: "date" }, + ] + + const { table } = useDataTable({ + data, + columns, + pageCount: 1, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.itemCode), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + + + + + + + ) +} \ No newline at end of file diff --git a/lib/tech-vendors/repository.ts b/lib/tech-vendors/repository.ts new file mode 100644 index 00000000..b71fb32d --- /dev/null +++ b/lib/tech-vendors/repository.ts @@ -0,0 +1,324 @@ +// src/lib/vendors/repository.ts + +import { eq, inArray, count, desc } from "drizzle-orm"; +import db from '@/db/db'; +import { sql, SQL } from "drizzle-orm"; +import { techVendors, techVendorContacts, techVendorPossibleItems, techVendorItemsView, type TechVendor, type TechVendorContact, type TechVendorItem, type TechVendorWithAttachments, techVendorAttachments } from "@/db/schema/techVendors"; + +export type NewTechVendorContact = typeof techVendorContacts.$inferInsert +export type NewTechVendorItem = typeof techVendorPossibleItems.$inferInsert + +type PaginationParams = { + offset: number; + limit: number; +}; + +// 메인 벤더 목록 조회 (첨부파일 정보 포함) +export async function selectTechVendorsWithAttachments( + tx: any, + params: { + where?: SQL; + orderBy?: SQL[]; + } & PaginationParams +) { + const query = tx + .select({ + id: techVendors.id, + vendorName: techVendors.vendorName, + vendorCode: techVendors.vendorCode, + taxId: techVendors.taxId, + address: techVendors.address, + country: techVendors.country, + phone: techVendors.phone, + email: techVendors.email, + website: techVendors.website, + status: techVendors.status, + techVendorType: techVendors.techVendorType, + representativeName: techVendors.representativeName, + representativeEmail: techVendors.representativeEmail, + representativePhone: techVendors.representativePhone, + representativeBirth: techVendors.representativeBirth, + corporateRegistrationNumber: techVendors.corporateRegistrationNumber, + items: techVendors.items, + createdAt: techVendors.createdAt, + updatedAt: techVendors.updatedAt, + }) + .from(techVendors); + + // where 조건이 있는 경우 + if (params.where) { + query.where(params.where); + } + + // 정렬 조건이 있는 경우 + if (params.orderBy && params.orderBy.length > 0) { + query.orderBy(...params.orderBy); + } else { + // 기본 정렬: 생성일 기준 내림차순 + query.orderBy(desc(techVendors.createdAt)); + } + + // 페이지네이션 적용 + query.offset(params.offset).limit(params.limit); + + const vendors = await query; + + // 첨부파일 정보 가져오기 + const vendorsWithAttachments = await Promise.all( + vendors.map(async (vendor: TechVendor) => { + const attachments = await tx + .select({ + id: techVendorAttachments.id, + fileName: techVendorAttachments.fileName, + filePath: techVendorAttachments.filePath, + }) + .from(techVendorAttachments) + .where(eq(techVendorAttachments.vendorId, vendor.id)); + + return { + ...vendor, + hasAttachments: attachments.length > 0, + attachmentsList: attachments, + } as TechVendorWithAttachments; + }) + ); + + return vendorsWithAttachments; +} + +// 메인 벤더 목록 수 조회 (첨부파일 정보 포함) +export async function countTechVendorsWithAttachments( + tx: any, + where?: SQL +) { + const query = tx.select({ count: count() }).from(techVendors); + + if (where) { + query.where(where); + } + + const result = await query; + return result[0].count; +} + +// 기술영업 벤더 조회 +export async function selectTechVendors( + tx: any, + params: { + where?: SQL; + orderBy?: SQL[]; + } & PaginationParams +) { + const query = tx.select().from(techVendors); + + if (params.where) { + query.where(params.where); + } + + if (params.orderBy && params.orderBy.length > 0) { + query.orderBy(...params.orderBy); + } else { + query.orderBy(desc(techVendors.createdAt)); + } + + query.offset(params.offset).limit(params.limit); + + return query; +} + +// 기술영업 벤더 수 카운트 +export async function countTechVendors(tx: any, where?: SQL) { + const query = tx.select({ count: count() }).from(techVendors); + + if (where) { + query.where(where); + } + + const result = await query; + return result[0].count; +} + +// 벤더 상태별 카운트 +export async function groupByTechVendorStatus(tx: any) { + const result = await tx + .select({ + status: techVendors.status, + count: count(), + }) + .from(techVendors) + .groupBy(techVendors.status); + + return result; +} + +// 벤더 상세 정보 조회 +export async function getTechVendorById(id: number) { + const result = await db + .select() + .from(techVendors) + .where(eq(techVendors.id, id)); + + return result.length > 0 ? result[0] : null; +} + +// 벤더 연락처 정보 조회 +export async function getTechVendorContactsById(id: number) { + const result = await db + .select() + .from(techVendorContacts) + .where(eq(techVendorContacts.id, id)); + + return result.length > 0 ? result[0] : null; +} + +// 신규 벤더 생성 +export async function insertTechVendor( + tx: any, + data: Omit +) { + return tx + .insert(techVendors) + .values({ + ...data, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); +} + +// 벤더 정보 업데이트 (단일) +export async function updateTechVendor( + tx: any, + id: string | number, + data: Partial +) { + return tx + .update(techVendors) + .set({ + ...data, + updatedAt: new Date(), + }) + .where(eq(techVendors.id, Number(id))) + .returning(); +} + +// 벤더 정보 업데이트 (다수) +export async function updateTechVendors( + tx: any, + ids: (string | number)[], + data: Partial +) { + return tx + .update(techVendors) + .set({ + ...data, + updatedAt: new Date(), + }) + .where(inArray(techVendors.id, ids.map(id => Number(id)))) + .returning(); +} + +// 벤더 연락처 조회 +export async function selectTechVendorContacts( + tx: any, + params: { + where?: SQL; + orderBy?: SQL[]; + } & PaginationParams +) { + const query = tx.select().from(techVendorContacts); + + if (params.where) { + query.where(params.where); + } + + if (params.orderBy && params.orderBy.length > 0) { + query.orderBy(...params.orderBy); + } else { + query.orderBy(desc(techVendorContacts.createdAt)); + } + + query.offset(params.offset).limit(params.limit); + + return query; +} + +// 벤더 연락처 수 카운트 +export async function countTechVendorContacts(tx: any, where?: SQL) { + const query = tx.select({ count: count() }).from(techVendorContacts); + + if (where) { + query.where(where); + } + + const result = await query; + return result[0].count; +} + +// 연락처 생성 +export async function insertTechVendorContact( + tx: any, + data: Omit +) { + return tx + .insert(techVendorContacts) + .values({ + ...data, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); +} + +// 아이템 목록 조회 +export async function selectTechVendorItems( + tx: any, + params: { + where?: SQL; + orderBy?: SQL[]; + } & PaginationParams +) { + const query = tx.select().from(techVendorItemsView); + + if (params.where) { + query.where(params.where); + } + + if (params.orderBy && params.orderBy.length > 0) { + query.orderBy(...params.orderBy); + } else { + query.orderBy(desc(techVendorItemsView.createdAt)); + } + + query.offset(params.offset).limit(params.limit); + + return query; +} + +// 아이템 수 카운트 +export async function countTechVendorItems(tx: any, where?: SQL) { + const query = tx.select({ count: count() }).from(techVendorItemsView); + + if (where) { + query.where(where); + } + + const result = await query; + return result[0].count; +} + +// 아이템 생성 +export async function insertTechVendorItem( + tx: any, + data: Omit +) { + return tx + .insert(techVendorPossibleItems) + .values({ + ...data, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); +} + diff --git a/lib/tech-vendors/service.ts b/lib/tech-vendors/service.ts new file mode 100644 index 00000000..657314e6 --- /dev/null +++ b/lib/tech-vendors/service.ts @@ -0,0 +1,1174 @@ +"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) + +import { revalidateTag, unstable_noStore } from "next/cache"; +import db from "@/db/db"; +import { techVendorAttachments, techVendorContacts, techVendorPossibleItems, techVendors, techVendorItemsView, type TechVendor } from "@/db/schema/techVendors"; +import { items, itemShipbuilding, itemOffshoreTop, itemOffshoreHull } from "@/db/schema/items"; + +import { filterColumns } from "@/lib/filter-columns"; +import { unstable_cache } from "@/lib/unstable-cache"; +import { getErrorMessage } from "@/lib/handle-error"; + +import { + insertTechVendor, + updateTechVendor, + groupByTechVendorStatus, + selectTechVendorContacts, + countTechVendorContacts, + insertTechVendorContact, + selectTechVendorItems, + countTechVendorItems, + insertTechVendorItem, + selectTechVendorsWithAttachments, + countTechVendorsWithAttachments, + updateTechVendors, +} from "./repository"; + +import type { + CreateTechVendorSchema, + UpdateTechVendorSchema, + GetTechVendorsSchema, + GetTechVendorContactsSchema, + CreateTechVendorContactSchema, + GetTechVendorItemsSchema, + CreateTechVendorItemSchema, +} from "./validations"; + +import { asc, desc, ilike, inArray, and, or, eq, isNull } from "drizzle-orm"; +import path from "path"; +import fs from "fs/promises"; +import { randomUUID } from "crypto"; +import { sql } from "drizzle-orm"; +import { users } from "@/db/schema/users"; + +/* ----------------------------------------------------- + 1) 조회 관련 +----------------------------------------------------- */ + +/** + * 복잡한 조건으로 기술영업 Vendor 목록을 조회 (+ pagination) 하고, + * 총 개수에 따라 pageCount를 계산해서 리턴. + * Next.js의 unstable_cache를 사용해 일정 시간 캐시. + */ +export async function getTechVendors(input: GetTechVendorsSchema) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // 1) 고급 필터 + const advancedWhere = filterColumns({ + table: techVendors, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // 2) 글로벌 검색 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(techVendors.vendorName, s), + ilike(techVendors.vendorCode, s), + ilike(techVendors.email, s), + ilike(techVendors.status, s) + ); + } + + // 최종 where 결합 + const finalWhere = and(advancedWhere, globalWhere); + + // 간단 검색 (advancedTable=false) 시 예시 + const simpleWhere = and( + input.vendorName + ? ilike(techVendors.vendorName, `%${input.vendorName}%`) + : undefined, + input.status ? ilike(techVendors.status, input.status) : undefined, + input.country + ? ilike(techVendors.country, `%${input.country}%`) + : undefined + ); + + // 실제 사용될 where + const where = finalWhere; + + // 정렬 + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(techVendors[item.id]) : asc(techVendors[item.id]) + ) + : [asc(techVendors.createdAt)]; + + // 트랜잭션 내에서 데이터 조회 + const { data, total } = await db.transaction(async (tx) => { + // 1) vendor 목록 조회 (with attachments) + const vendorsData = await selectTechVendorsWithAttachments(tx, { + where, + orderBy, + offset, + limit: input.perPage, + }); + + // 2) 전체 개수 + const total = await countTechVendorsWithAttachments(tx, where); + return { data: vendorsData, total }; + }); + + // 페이지 수 + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount }; + } catch (err) { + console.error("Error fetching tech vendors:", err); + // 에러 발생 시 + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input)], // 캐싱 키 + { + revalidate: 3600, + tags: ["tech-vendors"], // revalidateTag("tech-vendors") 호출 시 무효화 + } + )(); +} + +/** + * 기술영업 벤더 상태별 카운트 조회 + */ +export async function getTechVendorStatusCounts() { + return unstable_cache( + async () => { + try { + const initial: Record = { + "ACTIVE": 0, + "INACTIVE": 0, + "BLACKLISTED": 0, + "PENDING_REVIEW": 0, + "IN_REVIEW": 0, + "REJECTED": 0 + }; + + const result = await db.transaction(async (tx) => { + const rows = await groupByTechVendorStatus(tx); + type StatusCountRow = { status: TechVendor["status"]; count: number }; + return (rows as StatusCountRow[]).reduce>((acc, { status, count }) => { + acc[status] = count; + return acc; + }, initial); + }); + + return result; + } catch (err) { + return {} as Record; + } + }, + ["tech-vendor-status-counts"], // 캐싱 키 + { + revalidate: 3600, + } + )(); +} + +/** + * 벤더 상세 정보 조회 + */ +export async function getTechVendorById(id: number) { + return unstable_cache( + async () => { + try { + const result = await getTechVendorDetailById(id); + return { data: result }; + } catch (err) { + console.error("기술영업 벤더 상세 조회 오류:", err); + return { data: null }; + } + }, + [`tech-vendor-${id}`], + { + revalidate: 3600, + tags: ["tech-vendors", `tech-vendor-${id}`], + } + )(); +} + +/* ----------------------------------------------------- + 2) 생성(Create) +----------------------------------------------------- */ + +/** + * 첨부파일 저장 헬퍼 함수 + */ +async function storeTechVendorFiles( + tx: any, + vendorId: number, + files: File[], + attachmentType: string +) { + const vendorDir = path.join( + process.cwd(), + "public", + "tech-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("tech-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(techVendorAttachments).values({ + vendorId, + fileName: file.name, + filePath: "/" + relativePath.replace(/\\/g, "/"), + attachmentType, + }); + } +} + +/** + * 신규 기술영업 벤더 생성 + */ +export async function createTechVendor(input: CreateTechVendorSchema) { + unstable_noStore(); + + try { + // taxId 중복 검사 + const existingVendor = await db + .select({ id: techVendors.id }) + .from(techVendors) + .where(eq(techVendors.taxId, input.taxId)) + .limit(1); + + // 이미 동일한 taxId를 가진 업체가 존재하면 에러 반환 + if (existingVendor.length > 0) { + return { + success: false, + data: null, + error: `이미 등록된 사업자등록번호입니다. (Tax ID ${input.taxId} already exists in the system)` + }; + } + + const result = await db.transaction(async (tx) => { + // 1. 벤더 생성 + const [newVendor] = await insertTechVendor(tx, { + vendorName: input.vendorName, + vendorCode: input.vendorCode || null, + taxId: input.taxId, + address: input.address || null, + country: input.country, + phone: input.phone || null, + email: input.email, + website: input.website || null, + techVendorType: input.techVendorType as "조선" | "해양TOP" | "해양HULL", + representativeName: input.representativeName || null, + representativeBirth: input.representativeBirth || null, + representativeEmail: input.representativeEmail || null, + representativePhone: input.representativePhone || null, + corporateRegistrationNumber: input.corporateRegistrationNumber || null, + items: input.items || null, + status: "PENDING_REVIEW" + }); + + // 2. 연락처 정보 등록 + for (const contact of input.contacts) { + await insertTechVendorContact(tx, { + vendorId: newVendor.id, + contactName: contact.contactName, + contactPosition: contact.contactPosition || null, + contactEmail: contact.contactEmail, + contactPhone: contact.contactPhone || null, + isPrimary: contact.isPrimary ?? false, + }); + } + + // 3. 첨부파일 저장 + if (input.files && input.files.length > 0) { + await storeTechVendorFiles(tx, newVendor.id, input.files, "GENERAL"); + } + + return newVendor; + }); + + revalidateTag("tech-vendors"); + + return { + success: true, + data: result, + error: null + }; + } catch (err) { + console.error("기술영업 벤더 생성 오류:", err); + + return { + success: false, + data: null, + error: getErrorMessage(err) + }; + } +} + +/* ----------------------------------------------------- + 3) 업데이트 (단건/복수) +----------------------------------------------------- */ + +/** 단건 업데이트 */ +export async function modifyTechVendor( + input: UpdateTechVendorSchema & { id: string; } +) { + unstable_noStore(); + try { + const updated = await db.transaction(async (tx) => { + // 벤더 정보 업데이트 + const [res] = await updateTechVendor(tx, input.id, { + vendorName: input.vendorName, + vendorCode: input.vendorCode, + address: input.address, + country: input.country, + phone: input.phone, + email: input.email, + website: input.website, + status: input.status, + }); + + return res; + }); + + // 캐시 무효화 + revalidateTag("tech-vendors"); + revalidateTag(`tech-vendor-${input.id}`); + + return { data: updated, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +/** 복수 업데이트 */ +export async function modifyTechVendors(input: { + ids: string[]; + status?: TechVendor["status"]; +}) { + unstable_noStore(); + try { + const data = await db.transaction(async (tx) => { + // 여러 협력업체 일괄 업데이트 + const [updated] = await updateTechVendors(tx, input.ids, { + // 예: 상태만 일괄 변경 + status: input.status, + }); + return updated; + }); + + // 캐시 무효화 + revalidateTag("tech-vendors"); + revalidateTag("tech-vendor-status-counts"); + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +/* ----------------------------------------------------- + 4) 연락처 관리 +----------------------------------------------------- */ + +export async function getTechVendorContacts(input: GetTechVendorContactsSchema, id: number) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // 필터링 설정 + const advancedWhere = filterColumns({ + table: techVendorContacts, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // 검색 조건 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(techVendorContacts.contactName, s), + ilike(techVendorContacts.contactPosition, s), + ilike(techVendorContacts.contactEmail, s), + ilike(techVendorContacts.contactPhone, s) + ); + } + + // 해당 벤더 조건 + const vendorWhere = eq(techVendorContacts.vendorId, id); + + // 최종 조건 결합 + const finalWhere = and(advancedWhere, globalWhere, vendorWhere); + + // 정렬 조건 + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(techVendorContacts[item.id]) : asc(techVendorContacts[item.id]) + ) + : [asc(techVendorContacts.createdAt)]; + + // 트랜잭션 내부에서 Repository 호출 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectTechVendorContacts(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }); + const total = await countTechVendorContacts(tx, finalWhere); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount }; + } catch (err) { + // 에러 발생 시 디폴트 + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input), String(id)], // 캐싱 키 + { + revalidate: 3600, + tags: [`tech-vendor-contacts-${id}`], + } + )(); +} + +export async function createTechVendorContact(input: CreateTechVendorContactSchema) { + unstable_noStore(); + try { + await db.transaction(async (tx) => { + // DB Insert + const [newContact] = await insertTechVendorContact(tx, { + vendorId: input.vendorId, + contactName: input.contactName, + contactPosition: input.contactPosition || "", + contactEmail: input.contactEmail, + contactPhone: input.contactPhone || "", + isPrimary: input.isPrimary || false, + }); + return newContact; + }); + + // 캐시 무효화 + revalidateTag(`tech-vendor-contacts-${input.vendorId}`); + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +/* ----------------------------------------------------- + 5) 아이템 관리 +----------------------------------------------------- */ + +export async function getTechVendorItems(input: GetTechVendorItemsSchema, id: number) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // 필터링 설정 + const advancedWhere = filterColumns({ + table: techVendorItemsView, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // 검색 조건 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(techVendorItemsView.itemCode, s), + ilike(techVendorItemsView.itemName, s) + ); + } + + // 해당 벤더 조건 + const vendorWhere = eq(techVendorItemsView.vendorId, id); + + // 최종 조건 결합 + const finalWhere = and(advancedWhere, globalWhere, vendorWhere); + + // 정렬 조건 + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(techVendorItemsView[item.id]) : asc(techVendorItemsView[item.id]) + ) + : [asc(techVendorItemsView.createdAt)]; + + // 트랜잭션 내부에서 Repository 호출 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectTechVendorItems(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }); + const total = await countTechVendorItems(tx, finalWhere); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount }; + } catch (err) { + // 에러 발생 시 디폴트 + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input), String(id)], // 캐싱 키 + { + revalidate: 3600, + tags: [`tech-vendor-items-${id}`], + } + )(); +} + +export interface ItemDropdownOption { + itemCode: string; + itemName: string; + description: string | null; +} + +/** + * Vendor Item 추가 시 사용할 아이템 목록 조회 (전체 목록 반환) + * 아이템 코드, 이름, 설명만 간소화해서 반환 + */ +export async function getItemsForTechVendor(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( + techVendorPossibleItems, + eq(items.itemCode, techVendorPossibleItems.itemCode) + ) + // vendorPossibleItems.vendorId가 이 vendorId인 행이 없는(즉 아직 등록되지 않은) 아이템만 + .where( + isNull(techVendorPossibleItems.id) + ) + .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 tech vendor dropdown:", err); + return { + data: [], + error: "아이템 목록을 불러오는데 실패했습니다.", + }; + } + }, + // 캐시 키를 vendorId 별로 달리 해야 한다. + ["items-for-tech-vendor", String(vendorId)], + { + revalidate: 3600, // 1시간 캐싱 + tags: ["items"], // revalidateTag("items") 호출 시 무효화 + } + )(); +} + +/** + * 벤더 타입과 아이템 코드에 따른 아이템 조회 + */ +export async function getItemsByVendorType(vendorType: string, itemCode: string) { + try { + let items: (typeof itemShipbuilding.$inferSelect | typeof itemOffshoreTop.$inferSelect | typeof itemOffshoreHull.$inferSelect)[] = []; + + switch (vendorType) { + case "조선": + const shipbuildingResults = await db + .select({ + id: itemShipbuilding.id, + itemCode: itemShipbuilding.itemCode, + workType: itemShipbuilding.workType, + shipTypes: itemShipbuilding.shipTypes, + itemList: itemShipbuilding.itemList, + createdAt: itemShipbuilding.createdAt, + updatedAt: itemShipbuilding.updatedAt, + }) + .from(itemShipbuilding) + .where(itemCode ? eq(itemShipbuilding.itemCode, itemCode) : undefined); + items = shipbuildingResults; + break; + + case "해양TOP": + const offshoreTopResults = await db + .select({ + id: itemOffshoreTop.id, + itemCode: itemOffshoreTop.itemCode, + workType: itemOffshoreTop.workType, + itemList: itemOffshoreTop.itemList, + subItemList: itemOffshoreTop.subItemList, + createdAt: itemOffshoreTop.createdAt, + updatedAt: itemOffshoreTop.updatedAt, + }) + .from(itemOffshoreTop) + .where(itemCode ? eq(itemOffshoreTop.itemCode, itemCode) : undefined); + items = offshoreTopResults; + break; + + case "해양HULL": + const offshoreHullResults = await db + .select({ + id: itemOffshoreHull.id, + itemCode: itemOffshoreHull.itemCode, + workType: itemOffshoreHull.workType, + itemList: itemOffshoreHull.itemList, + subItemList: itemOffshoreHull.subItemList, + createdAt: itemOffshoreHull.createdAt, + updatedAt: itemOffshoreHull.updatedAt, + }) + .from(itemOffshoreHull) + .where(itemCode ? eq(itemOffshoreHull.itemCode, itemCode) : undefined); + items = offshoreHullResults; + break; + + default: + items = []; + } + + const result = items.map(item => ({ + ...item, + techVendorType: vendorType + })); + + return { data: result, error: null }; + } catch (error) { + return { data: [], error: "Failed to fetch items" }; + } +} + +/** + * 벤더의 possible_items를 조회하고 해당 아이템 코드로 각 타입별 테이블을 조회 + */ +export async function getVendorItemsByType(vendorId: number, vendorType: string) { + try { + // 벤더의 possible_items 조회 + const possibleItems = await db.query.techVendorPossibleItems.findMany({ + where: eq(techVendorPossibleItems.vendorId, vendorId), + columns: { + itemCode: true + } + }) + + const itemCodes = possibleItems.map(item => item.itemCode) + + // 벤더 타입에 따라 해당하는 테이블에서 아이템 조회 + switch (vendorType) { + case "조선": + const shipbuildingItems = await db.query.itemShipbuilding.findMany({ + where: inArray(itemShipbuilding.itemCode, itemCodes) + }) + return { + data: shipbuildingItems.map(item => ({ + ...item, + techVendorType: "조선" + })) + } + + case "해양TOP": + const offshoreTopItems = await db.query.itemOffshoreTop.findMany({ + where: inArray(itemOffshoreTop.itemCode, itemCodes) + }) + return { + data: offshoreTopItems.map(item => ({ + ...item, + techVendorType: "해양TOP" + })) + } + + case "해양HULL": + const offshoreHullItems = await db.query.itemOffshoreHull.findMany({ + where: inArray(itemOffshoreHull.itemCode, itemCodes) + }) + return { + data: offshoreHullItems.map(item => ({ + ...item, + techVendorType: "해양HULL" + })) + } + + default: + throw new Error(`Unsupported vendor type: ${vendorType}`) + } + } catch (error) { + throw error + } +} + +export async function createTechVendorItem(input: CreateTechVendorItemSchema & { itemName: string }) { + unstable_noStore(); + try { + // DB에 이미 존재하는지 확인 + const existingItem = await db + .select({ id: techVendorPossibleItems.id }) + .from(techVendorPossibleItems) + .where( + and( + eq(techVendorPossibleItems.vendorId, input.vendorId), + eq(techVendorPossibleItems.itemCode, input.itemCode) + ) + ) + .limit(1); + + if (existingItem.length > 0) { + return { data: null, error: "이미 추가된 아이템입니다." }; + } + + await db.transaction(async (tx) => { + // DB Insert + const [newItem] = await tx + .insert(techVendorPossibleItems) + .values({ + vendorId: input.vendorId, + itemCode: input.itemCode, + itemName: input.itemName || "기술영업", + }) + .returning(); + return newItem; + }); + + // 캐시 무효화 + revalidateTag(`tech-vendor-items-${input.vendorId}`); + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +/* ----------------------------------------------------- + 6) 기술영업 벤더 승인/거부 +----------------------------------------------------- */ + +interface ApproveTechVendorsInput { + ids: string[]; +} + +/** + * 기술영업 벤더 승인 (상태를 ACTIVE로 변경) + */ +export async function approveTechVendors(input: ApproveTechVendorsInput) { + unstable_noStore(); + + try { + // 트랜잭션 내에서 협력업체 상태 업데이트 + const result = await db.transaction(async (tx) => { + // 협력업체 상태 업데이트 + const [updated] = await tx + .update(techVendors) + .set({ + status: "ACTIVE", + updatedAt: new Date() + }) + .where(inArray(techVendors.id, input.ids.map(id => parseInt(id)))) + .returning(); + + return updated; + }); + + // 캐시 무효화 + revalidateTag("tech-vendors"); + revalidateTag("tech-vendor-status-counts"); + + return { data: result, error: null }; + } catch (err) { + console.error("Error approving tech vendors:", err); + return { data: null, error: getErrorMessage(err) }; + } +} + +/** + * 기술영업 벤더 거부 (상태를 REJECTED로 변경) + */ +export async function rejectTechVendors(input: ApproveTechVendorsInput) { + unstable_noStore(); + + try { + // 트랜잭션 내에서 협력업체 상태 업데이트 + const result = await db.transaction(async (tx) => { + // 협력업체 상태 업데이트 + const [updated] = await tx + .update(techVendors) + .set({ + status: "REJECTED", + updatedAt: new Date() + }) + .where(inArray(techVendors.id, input.ids.map(id => parseInt(id)))) + .returning(); + + return updated; + }); + + // 캐시 무효화 + revalidateTag("tech-vendors"); + revalidateTag("tech-vendor-status-counts"); + + return { data: result, error: null }; + } catch (err) { + console.error("Error rejecting tech vendors:", err); + return { data: null, error: getErrorMessage(err) }; + } +} + +/* ----------------------------------------------------- + 7) 엑셀 내보내기 +----------------------------------------------------- */ + +/** + * 벤더 연락처 목록 엑셀 내보내기 + */ +export async function exportTechVendorContacts(vendorId: number) { + try { + const contacts = await db + .select() + .from(techVendorContacts) + .where(eq(techVendorContacts.vendorId, vendorId)) + .orderBy(techVendorContacts.isPrimary, techVendorContacts.contactName); + + return contacts; + } catch (err) { + console.error("기술영업 벤더 연락처 내보내기 오류:", err); + return []; + } +} + +/** + * 벤더 아이템 목록 엑셀 내보내기 + */ +export async function exportTechVendorItems(vendorId: number) { + try { + const items = await db + .select({ + id: techVendorItemsView.vendorItemId, + vendorId: techVendorItemsView.vendorId, + itemName: techVendorItemsView.itemName, + itemCode: techVendorItemsView.itemCode, + createdAt: techVendorItemsView.createdAt, + updatedAt: techVendorItemsView.updatedAt, + }) + .from(techVendorItemsView) + .where(eq(techVendorItemsView.vendorId, vendorId)) + .orderBy(techVendorItemsView.itemName); + + return items; + } catch (err) { + console.error("기술영업 벤더 아이템 내보내기 오류:", err); + return []; + } +} + +/** + * 벤더 정보 엑셀 내보내기 + */ +export async function exportTechVendorDetails(vendorIds: number[]) { + try { + if (!vendorIds.length) return []; + + // 벤더 기본 정보 조회 + const vendorsData = await db + .select({ + id: techVendors.id, + vendorName: techVendors.vendorName, + vendorCode: techVendors.vendorCode, + taxId: techVendors.taxId, + address: techVendors.address, + country: techVendors.country, + phone: techVendors.phone, + email: techVendors.email, + website: techVendors.website, + status: techVendors.status, + representativeName: techVendors.representativeName, + representativeEmail: techVendors.representativeEmail, + representativePhone: techVendors.representativePhone, + representativeBirth: techVendors.representativeBirth, + corporateRegistrationNumber: techVendors.corporateRegistrationNumber, + items: techVendors.items, + createdAt: techVendors.createdAt, + updatedAt: techVendors.updatedAt, + }) + .from(techVendors) + .where( + vendorIds.length === 1 + ? eq(techVendors.id, vendorIds[0]) + : inArray(techVendors.id, vendorIds) + ); + + // 벤더별 상세 정보를 포함하여 반환 + const vendorsWithDetails = await Promise.all( + vendorsData.map(async (vendor) => { + // 연락처 조회 + const contacts = await exportTechVendorContacts(vendor.id); + + // 아이템 조회 + const items = await exportTechVendorItems(vendor.id); + + return { + ...vendor, + vendorContacts: contacts, + vendorItems: items, + }; + }) + ); + + return vendorsWithDetails; + } catch (err) { + console.error("기술영업 벤더 상세 내보내기 오류:", err); + return []; + } +} + +/** + * 기술영업 벤더 상세 정보 조회 + */ +async function getTechVendorDetailById(id: number) { + try { + const vendor = await db.select().from(techVendors).where(eq(techVendors.id, id)).limit(1); + + if (!vendor || vendor.length === 0) { + console.error(`Vendor not found with id: ${id}`); + return null; + } + + const contacts = await db.select().from(techVendorContacts).where(eq(techVendorContacts.vendorId, id)); + const attachments = await db.select().from(techVendorAttachments).where(eq(techVendorAttachments.vendorId, id)); + const possibleItems = await db.select().from(techVendorPossibleItems).where(eq(techVendorPossibleItems.vendorId, id)); + + return { + ...vendor[0], + contacts, + attachments, + possibleItems + }; + } catch (error) { + console.error("Error fetching tech vendor detail:", error); + return null; + } +} + +/** + * 기술영업 벤더 첨부파일 다운로드를 위한 서버 액션 + * @param vendorId 기술영업 벤더 ID + * @param fileId 특정 파일 ID (단일 파일 다운로드시) + * @returns 다운로드할 수 있는 임시 URL + */ +export async function downloadTechVendorAttachments(vendorId:number, fileId?:number) { + try { + // API 경로 생성 (단일 파일 또는 모든 파일) + const url = fileId + ? `/api/tech-vendors/attachments/download?id=${fileId}&vendorId=${vendorId}` + : `/api/tech-vendors/attachments/download-all?vendorId=${vendorId}`; + + // fetch 요청 (기본적으로 Blob으로 응답 받기) + const response = await fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Server responded with ${response.status}: ${response.statusText}`); + } + + // 파일명 가져오기 (Content-Disposition 헤더에서) + const contentDisposition = response.headers.get('content-disposition'); + let fileName = fileId ? `file-${fileId}.zip` : `tech-vendor-${vendorId}-files.zip`; + + if (contentDisposition) { + const matches = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(contentDisposition); + if (matches && matches[1]) { + fileName = matches[1].replace(/['"]/g, ''); + } + } + + // Blob으로 응답 변환 + const blob = await response.blob(); + + // Blob URL 생성 + const blobUrl = window.URL.createObjectURL(blob); + + return { + url: blobUrl, + fileName, + blob + }; + } catch (error) { + console.error('Download API error:', error); + throw error; + } +} + +/** + * 임시 ZIP 파일 정리를 위한 서버 액션 + * @param fileName 정리할 파일명 + */ +export async function cleanupTechTempFiles(fileName: string) { + 'use server'; + + try { + const tempDir = path.join(process.cwd(), 'tmp'); + const filePath = path.join(tempDir, fileName); + + try { + // 파일 존재 확인 + await fs.access(filePath, fs.constants.F_OK); + // 파일 삭제 + await fs.unlink(filePath); + } catch { + // 파일이 없으면 무시 + } + + return { success: true }; + } catch (error) { + console.error('임시 파일 정리 오류:', error); + return { success: false, error: '임시 파일 정리 중 오류가 발생했습니다.' }; + } +} + +export const findVendorById = async (id: number): Promise => { + try { + // 직접 DB에서 조회 + const vendor = await db + .select() + .from(techVendors) + .where(eq(techVendors.id, id)) + .limit(1) + .then(rows => rows[0] || null); + + if (!vendor) { + console.error(`Vendor not found with id: ${id}`); + return null; + } + + return vendor; + } catch (error) { + console.error('Error fetching vendor:', error); + return null; + } +}; + +/** + * 기술영업 벤더 엑셀 import 시 유저 생성 및 아이템 등록 + */ +export async function importTechVendorsFromExcel( + vendors: Array<{ + vendorName: string; + email: string; + taxId: string; + address?: string; + country?: string; + phone?: string; + website?: string; + techVendorType: string; + items: string; // 쉼표로 구분된 아이템 코드들 + }>, +) { + unstable_noStore(); + + try { + const result = await db.transaction(async (tx) => { + const createdVendors = []; + + for (const vendor of vendors) { + // 1. 벤더 생성 + const [newVendor] = await tx.insert(techVendors).values({ + vendorName: vendor.vendorName, + vendorCode: null, // 자동 생성 + taxId: vendor.taxId, + address: vendor.address || null, + country: vendor.country || null, + phone: vendor.phone || null, + email: vendor.email, + website: vendor.website || null, + techVendorType: vendor.techVendorType as "조선" | "해양TOP" | "해양HULL", + status: "PENDING_REVIEW" + }).returning(); + + // 2. 유저 생성 (이메일이 있는 경우) + if (vendor.email) { + // 이미 존재하는 유저인지 확인 + const existingUser = await tx.query.users.findFirst({ + where: eq(users.email, vendor.email), + columns: { id: true } + }); + + // 유저가 존재하지 않는 경우에만 생성 + if (!existingUser) { + await tx.insert(users).values({ + name: vendor.vendorName, + email: vendor.email, + companyId: newVendor.id, + domain: "partners", + }); + } + } + + // 3. 아이템 등록 + if (vendor.items) { + const itemCodes = vendor.items.split(',').map(code => code.trim()); + for (const itemCode of itemCodes) { + // 아이템 정보 조회 + const [item] = await tx.select().from(items).where(eq(items.itemCode, itemCode)); + if (item && item.itemCode && item.itemName) { + await tx.insert(techVendorPossibleItems).values({ + vendorId: newVendor.id, + itemCode: item.itemCode, + itemName: item.itemName, + }); + } + } + } + + createdVendors.push(newVendor); + } + + return createdVendors; + }); + + // 캐시 무효화 + revalidateTag("tech-vendors"); + revalidateTag("users"); + + return { success: true, data: result }; + } catch (error) { + console.error("Failed to import tech vendors:", error); + return { success: false, error: getErrorMessage(error) }; + } +} \ No newline at end of file diff --git a/lib/tech-vendors/table/attachmentButton.tsx b/lib/tech-vendors/table/attachmentButton.tsx new file mode 100644 index 00000000..12dc6f77 --- /dev/null +++ b/lib/tech-vendors/table/attachmentButton.tsx @@ -0,0 +1,76 @@ +'use client'; + +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { PaperclipIcon } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { toast } from 'sonner'; +import { type VendorAttach } from '@/db/schema/vendors'; +import { downloadTechVendorAttachments } from '../service'; + +interface AttachmentsButtonProps { + vendorId: number; + hasAttachments: boolean; + attachmentsList?: VendorAttach[]; +} + +export function AttachmentsButton({ vendorId, hasAttachments, attachmentsList = [] }: AttachmentsButtonProps) { + if (!hasAttachments) return null; + + const handleDownload = async () => { + try { + toast.loading('첨부파일을 준비하는 중...'); + + // 서버 액션 호출 + const result = await downloadTechVendorAttachments(vendorId); + + // 로딩 토스트 닫기 + toast.dismiss(); + + if (!result || !result.url) { + toast.error('다운로드 준비 중 오류가 발생했습니다.'); + return; + } + + // 파일 다운로드 트리거 + toast.success('첨부파일 다운로드가 시작되었습니다.'); + + // 다운로드 링크 열기 + const a = document.createElement('a'); + a.href = result.url; + a.download = result.fileName || '첨부파일.zip'; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + + } catch (error) { + toast.dismiss(); + toast.error('첨부파일 다운로드에 실패했습니다.'); + console.error('첨부파일 다운로드 오류:', error); + } + }; + + return ( + <> + {attachmentsList && attachmentsList.length > 0 && + + } + + ); +} diff --git a/lib/tech-vendors/table/excel-template-download.tsx b/lib/tech-vendors/table/excel-template-download.tsx new file mode 100644 index 00000000..65b880da --- /dev/null +++ b/lib/tech-vendors/table/excel-template-download.tsx @@ -0,0 +1,128 @@ +import * as ExcelJS from 'exceljs'; +import { saveAs } from "file-saver"; + +// 벤더 타입 enum +const VENDOR_TYPES = ["조선", "해양TOP", "해양HULL"] as const; + +/** + * 기술영업 벤더 데이터 가져오기를 위한 Excel 템플릿 파일 생성 및 다운로드 + */ +export async function exportTechVendorTemplate() { + // 워크북 생성 + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'Tech Vendor Management System'; + workbook.created = new Date(); + + // 워크시트 생성 + const worksheet = workbook.addWorksheet('기술영업 벤더'); + + // 컬럼 헤더 정의 및 스타일 적용 + worksheet.columns = [ + { header: '업체명', key: 'vendorName', width: 20 }, + { header: '이메일', key: 'email', width: 25 }, + { header: '사업자등록번호', key: 'taxId', width: 15 }, + { header: '벤더타입', key: 'techVendorType', width: 15 }, + { header: '주소', key: 'address', width: 30 }, + { header: '국가', key: 'country', width: 15 }, + { header: '전화번호', key: 'phone', width: 15 }, + { header: '웹사이트', key: 'website', width: 25 }, + { header: '아이템', key: 'items', width: 30 }, + ]; + + // 헤더 스타일 적용 + const headerRow = worksheet.getRow(1); + headerRow.font = { bold: true }; + headerRow.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE0E0E0' } + }; + headerRow.alignment = { vertical: 'middle', horizontal: 'center' }; + + // 테두리 스타일 적용 + headerRow.eachCell((cell) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + }); + + // 샘플 데이터 추가 + const sampleData = [ + { + vendorName: '샘플 업체 1', + email: 'sample1@example.com', + taxId: '123-45-67890', + techVendorType: '조선', + address: '서울시 강남구', + country: '대한민국', + phone: '02-1234-5678', + website: 'https://example1.com', + items: 'ITEM001,ITEM002' + }, + { + vendorName: '샘플 업체 2', + email: 'sample2@example.com', + taxId: '234-56-78901', + techVendorType: '해양TOP', + address: '부산시 해운대구', + country: '대한민국', + phone: '051-234-5678', + website: 'https://example2.com', + items: 'ITEM003,ITEM004' + } + ]; + + // 데이터 행 추가 + sampleData.forEach(item => { + worksheet.addRow(item); + }); + + // 데이터 행 스타일 적용 + worksheet.eachRow((row, rowNumber) => { + if (rowNumber > 1) { // 헤더를 제외한 데이터 행 + row.eachCell((cell) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + }); + } + }); + + // 워크시트에 벤더 타입 관련 메모 추가 + const infoRow = worksheet.addRow(['벤더 타입 안내: ' + VENDOR_TYPES.join(', ')]); + infoRow.font = { bold: true, color: { argb: 'FF0000FF' } }; + worksheet.mergeCells(`A${infoRow.number}:I${infoRow.number}`); + + // 워크시트 보호 (선택적) + worksheet.protect('', { + selectLockedCells: true, + selectUnlockedCells: true, + formatColumns: true, + formatRows: true, + insertColumns: false, + insertRows: true, + insertHyperlinks: false, + deleteColumns: false, + deleteRows: true, + sort: true, + autoFilter: true, + pivotTables: false + }); + + try { + // 워크북을 Blob으로 변환 + const buffer = await workbook.xlsx.writeBuffer(); + const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); + saveAs(blob, 'tech-vendor-template.xlsx'); + return true; + } catch (error) { + console.error('Excel 템플릿 생성 오류:', error); + throw error; + } +} \ No newline at end of file diff --git a/lib/tech-vendors/table/feature-flags-provider.tsx b/lib/tech-vendors/table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/tech-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({ + 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( + "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 ( + void setFeatureFlags(value), + }} + > +
+ setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + + + + + + +
{flag.tooltipTitle}
+
+ {flag.tooltipDescription} +
+
+
+ ))} +
+
+ {children} +
+ ) +} diff --git a/lib/tech-vendors/table/import-button.tsx b/lib/tech-vendors/table/import-button.tsx new file mode 100644 index 00000000..7346e5fe --- /dev/null +++ b/lib/tech-vendors/table/import-button.tsx @@ -0,0 +1,293 @@ +"use client" + +import * as React from "react" +import { Upload } from "lucide-react" +import { toast } from "sonner" +import * as ExcelJS from 'exceljs' + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Progress } from "@/components/ui/progress" +import { importTechVendorsFromExcel } from "../service" +import { decryptWithServerAction } from "@/components/drm/drmUtils" + +interface ImportTechVendorButtonProps { + onSuccess?: () => void; +} + +export function ImportTechVendorButton({ onSuccess }: ImportTechVendorButtonProps) { + const [open, setOpen] = React.useState(false); + const [file, setFile] = React.useState(null); + const [isUploading, setIsUploading] = React.useState(false); + const [progress, setProgress] = React.useState(0); + const [error, setError] = React.useState(null); + + const fileInputRef = React.useRef(null); + + // 파일 선택 처리 + const handleFileChange = (e: React.ChangeEvent) => { + const selectedFile = e.target.files?.[0]; + if (!selectedFile) return; + + if (!selectedFile.name.endsWith('.xlsx') && !selectedFile.name.endsWith('.xls')) { + setError("Excel 파일(.xlsx 또는 .xls)만 가능합니다."); + return; + } + + setFile(selectedFile); + setError(null); + }; + + // 데이터 가져오기 처리 + const handleImport = async () => { + if (!file) { + setError("가져올 파일을 선택해주세요."); + return; + } + + try { + setIsUploading(true); + setProgress(0); + setError(null); + + // DRM 복호화 처리 + let arrayBuffer: ArrayBuffer; + try { + setProgress(10); + toast.info("파일 복호화 중..."); + arrayBuffer = await decryptWithServerAction(file); + setProgress(30); + } catch (decryptError) { + console.error("파일 복호화 실패, 원본 파일 사용:", decryptError); + toast.warning("파일 복호화에 실패하여 원본 파일을 사용합니다."); + arrayBuffer = await file.arrayBuffer(); + } + + // ExcelJS 워크북 로드 + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(arrayBuffer); + + // 첫 번째 워크시트 가져오기 + const worksheet = workbook.worksheets[0]; + if (!worksheet) { + throw new Error("Excel 파일에 워크시트가 없습니다."); + } + + // 헤더 행 찾기 + let headerRowIndex = 1; + let headerRow: ExcelJS.Row | undefined; + let headerValues: (string | null)[] = []; + + worksheet.eachRow((row, rowNumber) => { + const values = row.values as (string | null)[]; + if (!headerRow && values.some(v => v === "업체명" || v === "vendorName")) { + headerRowIndex = rowNumber; + headerRow = row; + headerValues = [...values]; + } + }); + + if (!headerRow) { + throw new Error("Excel 파일에서 헤더 행을 찾을 수 없습니다."); + } + + // 헤더를 기반으로 인덱스 매핑 생성 + const headerMapping: Record = {}; + headerValues.forEach((value, index) => { + if (typeof value === 'string') { + headerMapping[value] = index; + } + }); + + // 필수 헤더 확인 + const requiredHeaders = ["업체명", "이메일", "사업자등록번호", "벤더타입"]; + const alternativeHeaders = { + "업체명": ["vendorName"], + "이메일": ["email"], + "사업자등록번호": ["taxId"], + "벤더타입": ["techVendorType"], + "주소": ["address"], + "국가": ["country"], + "전화번호": ["phone"], + "웹사이트": ["website"], + "아이템": ["items"] + }; + + // 헤더 매핑 확인 (대체 이름 포함) + const missingHeaders = requiredHeaders.filter(header => { + const alternatives = alternativeHeaders[header as keyof typeof alternativeHeaders] || []; + return !(header in headerMapping) && + !alternatives.some(alt => alt in headerMapping); + }); + + if (missingHeaders.length > 0) { + throw new Error(`다음 필수 헤더가 누락되었습니다: ${missingHeaders.join(", ")}`); + } + + // 데이터 행 추출 + const dataRows: Record[] = []; + + worksheet.eachRow((row, rowNumber) => { + if (rowNumber > headerRowIndex) { + const rowData: Record = {}; + const values = row.values as (string | null | undefined)[]; + + // 헤더 매핑에 따라 데이터 추출 + Object.entries(headerMapping).forEach(([header, index]) => { + rowData[header] = values[index] || ""; + }); + + // 빈 행이 아닌 경우만 추가 + if (Object.values(rowData).some(value => value && value.toString().trim() !== "")) { + dataRows.push(rowData); + } + } + }); + + if (dataRows.length === 0) { + throw new Error("Excel 파일에 가져올 데이터가 없습니다."); + } + + // 진행 상황 업데이트를 위한 콜백 + const updateProgress = (current: number, total: number) => { + const percentage = Math.round((current / total) * 100); + setProgress(percentage); + }; + + // 벤더 데이터 처리 + const vendors = dataRows.map(row => ({ + vendorName: row["업체명"] || row["vendorName"] || "", + email: row["이메일"] || row["email"] || "", + taxId: row["사업자등록번호"] || row["taxId"] || "", + techVendorType: row["벤더타입"] || row["techVendorType"] || "", + address: row["주소"] || row["address"] || null, + country: row["국가"] || row["country"] || null, + phone: row["전화번호"] || row["phone"] || null, + website: row["웹사이트"] || row["website"] || null, + items: row["아이템"] || row["items"] || "" + })); + + // 벤더 데이터 가져오기 실행 + const result = await importTechVendorsFromExcel(vendors); + + if (result.success) { + toast.success(`${vendors.length}개의 기술영업 벤더가 성공적으로 가져와졌습니다.`); + } else { + toast.error(result.error || "벤더 가져오기에 실패했습니다."); + } + + // 상태 초기화 및 다이얼로그 닫기 + setFile(null); + setOpen(false); + + // 성공 콜백 호출 + if (onSuccess) { + onSuccess(); + } + } catch (error) { + console.error("Excel 파일 처리 중 오류 발생:", error); + setError(error instanceof Error ? error.message : "파일 처리 중 오류가 발생했습니다."); + } finally { + setIsUploading(false); + } + }; + + // 다이얼로그 열기/닫기 핸들러 + const handleOpenChange = (newOpen: boolean) => { + if (!newOpen) { + // 닫을 때 상태 초기화 + setFile(null); + setError(null); + setProgress(0); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + } + setOpen(newOpen); + }; + + return ( + <> + + + + + + 기술영업 벤더 가져오기 + + 기술영업 벤더를 Excel 파일에서 가져옵니다. +
+ 올바른 형식의 Excel 파일(.xlsx)을 업로드하세요. +
+
+ +
+
+ +
+ + {file && ( +
+ 선택된 파일: {file.name} ({(file.size / 1024).toFixed(1)} KB) +
+ )} + + {isUploading && ( +
+ +

+ {progress}% 완료 +

+
+ )} + + {error && ( +
+ {error} +
+ )} +
+ + + + + +
+
+ + ); +} \ No newline at end of file diff --git a/lib/tech-vendors/table/tech-vendors-table-columns.tsx b/lib/tech-vendors/table/tech-vendors-table-columns.tsx new file mode 100644 index 00000000..438f4000 --- /dev/null +++ b/lib/tech-vendors/table/tech-vendors-table-columns.tsx @@ -0,0 +1,331 @@ +"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, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { useRouter } from "next/navigation" + +import { TechVendor, techVendors } from "@/db/schema/techVendors" +import { modifyTechVendor } from "../service" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { techVendorColumnsConfig } from "@/config/techVendorColumnsConfig" +import { Separator } from "@/components/ui/separator" +import { getVendorStatusIcon } from "../utils" + +// 타입 정의 추가 +type StatusType = (typeof techVendors.status.enumValues)[number]; +type BadgeVariantType = "default" | "secondary" | "destructive" | "outline"; +type StatusConfig = { + variant: BadgeVariantType; + className: string; +}; +type StatusDisplayMap = { + [key in StatusType]: string; +}; + +type NextRouter = ReturnType; + +interface GetColumnsProps { + setRowAction: React.Dispatch | null>>; + router: NextRouter; +} + + + + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef = { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + return ( + + + + + + setRowAction({ row, type: "update" })} + > + 레코드 편집 + + + { + // 1) 만약 rowAction을 열고 싶다면 + // setRowAction({ row, type: "update" }) + + // 2) 자세히 보기 페이지로 클라이언트 라우팅 + router.push(`/evcp/tech-vendors/${row.original.id}/info`); + }} + > + 상세보기 + + { + // 새창으로 열기 위해 window.open() 사용 + window.open(`/evcp/tech-vendors/${row.original.id}/info`, '_blank'); + }} + > + 상세보기(새창) + + setRowAction({ row, type: "log" })} + > + 감사 로그 보기 + + + + + Status + + { + startUpdateTransition(() => { + toast.promise( + modifyTechVendor({ + id: String(row.original.id), + status: value as TechVendor["status"], + vendorName: row.original.vendorName, // Required field from UpdateVendorSchema + }), + { + loading: "Updating...", + success: "Label updated", + error: (err) => getErrorMessage(err), + } + ) + }) + }} + > + {techVendors.status.enumValues.map((status) => ( + + {status} + + ))} + + + + + + + + ) + }, + size: 40, + } + + // ---------------------------------------------------------------- + // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 3-1) groupMap: { [groupName]: ColumnDef[] } + const groupMap: Record[]> = {} + + techVendorColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + // Status 컬럼 렌더링 개선 - 아이콘과 더 선명한 배경색 사용 + if (cfg.id === "status") { + const statusVal = row.original.status; + if (!statusVal) return null; + + // Status badge variant mapping - 더 뚜렷한 색상으로 변경 + const getStatusConfig = (status: StatusType): StatusConfig & { iconColor: string } => { + switch (status) { + case "PENDING_REVIEW": + return { + variant: "outline", + className: "bg-yellow-100 text-yellow-800 border-yellow-300", + iconColor: "text-yellow-600" + }; + case "IN_REVIEW": + return { + variant: "outline", + className: "bg-blue-100 text-blue-800 border-blue-300", + iconColor: "text-blue-600" + }; + case "REJECTED": + return { + variant: "outline", + className: "bg-red-100 text-red-800 border-red-300", + iconColor: "text-red-600" + }; + case "ACTIVE": + return { + variant: "outline", + className: "bg-emerald-100 text-emerald-800 border-emerald-300 font-semibold", + iconColor: "text-emerald-600" + }; + case "INACTIVE": + return { + variant: "outline", + className: "bg-gray-100 text-gray-800 border-gray-300", + iconColor: "text-gray-600" + }; + case "BLACKLISTED": + return { + variant: "outline", + className: "bg-slate-800 text-white border-slate-900", + iconColor: "text-white" + }; + default: + return { + variant: "outline", + className: "bg-gray-100 text-gray-800 border-gray-300", + iconColor: "text-gray-600" + }; + } + }; + + // 상태 표시 텍스트 + const getStatusDisplay = (status: StatusType): string => { + const statusMap: StatusDisplayMap = { + "PENDING_REVIEW": "가입 신청 중", + "IN_REVIEW": "심사 중", + "REJECTED": "심사 거부됨", + "ACTIVE": "활성 상태", + "INACTIVE": "비활성 상태", + "BLACKLISTED": "거래 금지" + }; + + return statusMap[status] || status; + }; + + const statusConfig = getStatusConfig(statusVal); + const displayText = getStatusDisplay(statusVal); + const StatusIcon = getVendorStatusIcon(statusVal); + + return ( +
+ + + {displayText} + +
+ ); + } + + // 날짜 컬럼 포맷팅 + if (cfg.type === "date" && cell.getValue()) { + return formatDate(cell.getValue() as Date); + } + + return cell.getValue(); + }, + }; + + groupMap[groupName].push(childCol); + }); + + // 3-2) groupMap -> columns (그룹별 -> 중첩 헤더 ColumnDef[] 배열 변환) + const columns: ColumnDef[] = [ + selectColumn, // 1) 체크박스 + ]; + + // 3-3) 그룹이 있는 컬럼들은 중첩 헤더로, 없는 것들은 그냥 컬럼으로 + Object.entries(groupMap).forEach(([groupName, childColumns]) => { + if (groupName === "_noGroup") { + // 그룹이 없는 컬럼들은 그냥 추가 + columns.push(...childColumns); + } else { + // 그룹이 있는 컬럼들은 헤더 아래 자식으로 중첩 + columns.push({ + id: groupName, + header: groupName, // 그룹명을 헤더로 + columns: childColumns, // 그룹에 속한 컬럼들을 자식으로 + }); + } + }); + + columns.push(actionsColumn); // 마지막에 액션 컬럼 추가 + + return columns; +} \ No newline at end of file diff --git a/lib/tech-vendors/table/tech-vendors-table-floating-bar.tsx b/lib/tech-vendors/table/tech-vendors-table-floating-bar.tsx new file mode 100644 index 00000000..2cc83105 --- /dev/null +++ b/lib/tech-vendors/table/tech-vendors-table-floating-bar.tsx @@ -0,0 +1,240 @@ +"use client" + +import * as React from "react" +import { SelectTrigger } from "@radix-ui/react-select" +import { type Table } from "@tanstack/react-table" +import { + ArrowUp, + CheckCircle2, + Download, + Loader, + Trash2, + X, +} from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { Portal } from "@/components/ui/portal" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, +} from "@/components/ui/select" +import { Separator } from "@/components/ui/separator" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { Kbd } from "@/components/kbd" + +import { ActionConfirmDialog } from "@/components/ui/action-dialog" +import { Vendor, vendors } from "@/db/schema/vendors" +import { modifyTechVendors } from "../service" +import { TechVendor } from "@/db/schema" + +interface VendorsTableFloatingBarProps { + table: Table +} + + +export function VendorsTableFloatingBar({ table }: VendorsTableFloatingBarProps) { + const rows = table.getFilteredSelectedRowModel().rows + + const [isPending, startTransition] = React.useTransition() + const [action, setAction] = React.useState< + "update-status" | "export" | "delete" + >() + // Clear selection on Escape key press + React.useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + table.toggleAllRowsSelected(false) + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [table]) + + + + // 공용 confirm dialog state + const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false) + const [confirmProps, setConfirmProps] = React.useState<{ + title: string + description?: string + onConfirm: () => Promise | void + }>({ + title: "", + description: "", + onConfirm: () => { }, + }) + + + // 2) + function handleSelectStatus(newStatus: Vendor["status"]) { + setAction("update-status") + + setConfirmProps({ + title: `Update ${rows.length} vendor${rows.length > 1 ? "s" : ""} with status: ${newStatus}?`, + description: "This action will override their current status.", + onConfirm: async () => { + startTransition(async () => { + const { error } = await modifyTechVendors({ + ids: rows.map((row) => String(row.original.id)), + status: newStatus as TechVendor["status"], + }) + if (error) { + toast.error(error) + return + } + toast.success("Vendors updated") + setConfirmDialogOpen(false) + }) + }, + }) + setConfirmDialogOpen(true) + } + + + return ( + +
+
+
+
+ + {rows.length} selected + + + + + + + +

Clear selection

+ + Esc + +
+
+
+ +
+ + + + + + +

Export vendors

+
+
+ +
+
+
+
+ + + {/* 공용 Confirm Dialog */} + +
+ ) +} diff --git a/lib/tech-vendors/table/tech-vendors-table-toolbar-actions.tsx b/lib/tech-vendors/table/tech-vendors-table-toolbar-actions.tsx new file mode 100644 index 00000000..82383a3a --- /dev/null +++ b/lib/tech-vendors/table/tech-vendors-table-toolbar-actions.tsx @@ -0,0 +1,166 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, FileSpreadsheet, Upload, Check, BuildingIcon, FileText } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +import { exportVendorsWithRelatedData } from "./vendor-all-export" +import { TechVendor } from "@/db/schema/techVendors" +import { ImportTechVendorButton } from "./import-button" +import { exportTechVendorTemplate } from "./excel-template-download" + +interface TechVendorsTableToolbarActionsProps { + table: Table +} + +export function TechVendorsTableToolbarActions({ table }: TechVendorsTableToolbarActionsProps) { + const [isExporting, setIsExporting] = React.useState(false); + + // 선택된 모든 벤더 가져오기 + const selectedVendors = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original); + }, [table.getFilteredSelectedRowModel().rows]); + + // 테이블의 모든 벤더 가져오기 (필터링된 결과) + const allFilteredVendors = React.useMemo(() => { + return table + .getFilteredRowModel() + .rows + .map(row => row.original); + }, [table.getFilteredRowModel().rows]); + + // 선택된 벤더 통합 내보내기 함수 실행 + const handleSelectedExport = async () => { + if (selectedVendors.length === 0) { + toast.warning("내보낼 협력업체를 선택해주세요."); + return; + } + + try { + setIsExporting(true); + toast.info(`선택된 ${selectedVendors.length}개 업체의 정보를 내보내는 중입니다...`); + await exportVendorsWithRelatedData(selectedVendors, "selected-vendors-detailed"); + toast.success(`${selectedVendors.length}개 업체 정보 내보내기가 완료되었습니다.`); + } catch (error) { + console.error("상세 정보 내보내기 오류:", error); + toast.error("상세 정보 내보내기 중 오류가 발생했습니다."); + } finally { + setIsExporting(false); + } + }; + + // 모든 벤더 통합 내보내기 함수 실행 + const handleAllFilteredExport = async () => { + if (allFilteredVendors.length === 0) { + toast.warning("내보낼 협력업체가 없습니다."); + return; + } + + try { + setIsExporting(true); + toast.info(`총 ${allFilteredVendors.length}개 업체의 정보를 내보내는 중입니다...`); + await exportVendorsWithRelatedData(allFilteredVendors, "all-vendors-detailed"); + toast.success(`${allFilteredVendors.length}개 업체 정보 내보내기가 완료되었습니다.`); + } catch (error) { + console.error("상세 정보 내보내기 오류:", error); + toast.error("상세 정보 내보내기 중 오류가 발생했습니다."); + } finally { + setIsExporting(false); + } + }; + + return ( +
+ {/* Import 버튼 추가 */} + { + // 성공 시 테이블 새로고침 + toast.success("업체 정보 가져오기가 완료되었습니다."); + }} + /> + + {/* Export 드롭다운 메뉴로 변경 */} + + + + + + {/* 템플릿 다운로드 추가 */} + exportTechVendorTemplate()} + disabled={isExporting} + > + + Excel 템플릿 다운로드 + + + + + {/* 기본 내보내기 - 현재 테이블에 보이는 데이터만 */} + + exportTableToExcel(table, { + filename: "vendors", + excludeColumns: ["select", "actions"], + }) + } + disabled={isExporting} + > + + 현재 테이블 데이터 내보내기 + + + + + {/* 선택된 벤더만 상세 내보내기 */} + + + 선택한 업체 상세 정보 내보내기 + {selectedVendors.length > 0 && ( + ({selectedVendors.length}개) + )} + + + {/* 모든 필터링된 벤더 상세 내보내기 */} + + + 모든 업체 상세 정보 내보내기 + {allFilteredVendors.length > 0 && ( + ({allFilteredVendors.length}개) + )} + + + +
+ ) +} \ No newline at end of file diff --git a/lib/tech-vendors/table/tech-vendors-table.tsx b/lib/tech-vendors/table/tech-vendors-table.tsx new file mode 100644 index 00000000..55632182 --- /dev/null +++ b/lib/tech-vendors/table/tech-vendors-table.tsx @@ -0,0 +1,148 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { getColumns } from "./tech-vendors-table-columns" +import { getTechVendors, getTechVendorStatusCounts } from "../service" +import { TechVendor, techVendors } from "@/db/schema/techVendors" +import { TechVendorsTableToolbarActions } from "./tech-vendors-table-toolbar-actions" +import { UpdateVendorSheet } from "./update-vendor-sheet" +import { getVendorStatusIcon } from "../utils" +// import { ViewTechVendorLogsDialog } from "./view-tech-vendors-logs-dialog" + +interface TechVendorsTableProps { + promises: Promise< + [ + Awaited>, + Awaited> + ] + > +} + +export function TechVendorsTable({ promises }: TechVendorsTableProps) { + // Suspense로 받아온 데이터 + const [{ data, pageCount }, statusCounts] = React.use(promises) + const [isCompact, setIsCompact] = React.useState(false) + + const [rowAction, setRowAction] = React.useState | null>(null) + + // **router** 획득 + const router = useRouter() + + // getColumns() 호출 시, router를 주입 + const columns = React.useMemo( + () => getColumns({ setRowAction, router }), + [setRowAction, router] + ) + + // 상태 한글 변환 유틸리티 함수 + const getStatusDisplay = (status: string): string => { + const statusMap: Record = { + "PENDING_REVIEW": "가입 신청 중", + "IN_REVIEW": "심사 중", + "REJECTED": "심사 거부됨", + "ACTIVE": "활성 상태", + "INACTIVE": "비활성 상태", + "BLACKLISTED": "거래 금지" + }; + + return statusMap[status] || status; + }; + + const filterFields: DataTableFilterField[] = [ + { + id: "status", + label: "상태", + options: techVendors.status.enumValues.map((status) => ({ + label: getStatusDisplay(status), + value: status, + count: statusCounts[status], + })), + }, + + { id: "vendorCode", label: "업체 코드" }, + ] + + const advancedFilterFields: DataTableAdvancedFilterField[] = [ + { id: "vendorName", label: "업체명", type: "text" }, + { id: "vendorCode", label: "업체코드", type: "text" }, + { id: "email", label: "이메일", type: "text" }, + { id: "country", label: "국가", type: "text" }, + { + id: "status", + label: "업체승인상태", + type: "multi-select", + options: techVendors.status.enumValues.map((status) => ({ + label: getStatusDisplay(status), + value: status, + count: statusCounts[status], + icon: getVendorStatusIcon(status), + })), + }, + { id: "createdAt", label: "등록일", type: "date" }, + { id: "updatedAt", label: "수정일", type: "date" }, + ] + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + const handleCompactChange = React.useCallback((compact: boolean) => { + setIsCompact(compact) + }, []) + + + return ( + <> + } + > + + + + + setRowAction(null)} + vendor={rowAction?.row.original ?? null} + /> + + {/* ViewTechVendorLogsDialog 컴포넌트는 아직 구현되지 않았습니다. + setRowAction(null)} + vendorId={rowAction?.row.original?.id ?? null} + /> */} + + ) +} \ No newline at end of file diff --git a/lib/tech-vendors/table/update-vendor-sheet.tsx b/lib/tech-vendors/table/update-vendor-sheet.tsx new file mode 100644 index 00000000..c33bbf03 --- /dev/null +++ b/lib/tech-vendors/table/update-vendor-sheet.tsx @@ -0,0 +1,390 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { + Loader, + Activity, + AlertCircle, + AlertTriangle, + ClipboardList, + FilePenLine, + XCircle, + Circle as CircleIcon, + Building, +} from "lucide-react" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { useSession } from "next-auth/react" // Import useSession + +import { TechVendor, techVendors } from "@/db/schema/techVendors" +import { updateTechVendorSchema, type UpdateTechVendorSchema } from "../validations" +import { modifyTechVendor } from "../service" + +interface UpdateVendorSheetProps + extends React.ComponentPropsWithRef { + vendor: TechVendor | null +} +type StatusType = (typeof techVendors.status.enumValues)[number]; + +type StatusConfig = { + Icon: React.ElementType; + className: string; + label: string; +}; + +// 상태 표시 유틸리티 함수 +const getStatusConfig = (status: StatusType): StatusConfig => { + switch(status) { + case "PENDING_REVIEW": + return { + Icon: ClipboardList, + className: "text-yellow-600", + label: "가입 신청 중" + }; + case "IN_REVIEW": + return { + Icon: FilePenLine, + className: "text-blue-600", + label: "심사 중" + }; + case "REJECTED": + return { + Icon: XCircle, + className: "text-red-600", + label: "심사 거부됨" + }; + case "ACTIVE": + return { + Icon: Activity, + className: "text-emerald-600", + label: "활성 상태" + }; + case "INACTIVE": + return { + Icon: AlertCircle, + className: "text-gray-600", + label: "비활성 상태" + }; + case "BLACKLISTED": + return { + Icon: AlertTriangle, + className: "text-slate-800", + label: "거래 금지" + }; + default: + return { + Icon: CircleIcon, + className: "text-gray-600", + label: status + }; + } +}; + + +// 폼 컴포넌트 +export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps) { + const [isPending, startTransition] = React.useTransition() + const { data: session } = useSession() + // 폼 정의 - UpdateVendorSchema 타입을 직접 사용 + const form = useForm({ + resolver: zodResolver(updateTechVendorSchema), + defaultValues: { + // 업체 기본 정보 + vendorName: vendor?.vendorName ?? "", + vendorCode: vendor?.vendorCode ?? "", + address: vendor?.address ?? "", + country: vendor?.country ?? "", + phone: vendor?.phone ?? "", + email: vendor?.email ?? "", + website: vendor?.website ?? "", + 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]); + + + // 제출 핸들러 + async function onSubmit(data: UpdateTechVendorSchema) { + if (!vendor) return + + if (!session?.user?.id) { + toast.error("사용자 인증 정보를 찾을 수 없습니다.") + return + } + startTransition(async () => { + try { + // Add status change comment if status has changed + const oldStatus = vendor.status ?? "ACTIVE" // Default to ACTIVE if undefined + const newStatus = data.status ?? "ACTIVE" // Default to ACTIVE if undefined + + const statusComment = + oldStatus !== newStatus + ? `상태 변경: ${getStatusConfig(oldStatus).label} → ${getStatusConfig(newStatus).label}` + : "" // Empty string instead of undefined + + // 업체 정보 업데이트 - userId와 상태 변경 코멘트 추가 + const { error } = await modifyTechVendor({ + id: String(vendor.id), + userId: Number(session.user.id), // Add user ID from session + comment: statusComment, // Add comment for status changes + ...data // 모든 데이터 전달 - 서비스 함수에서 필요한 필드만 처리 + }) + + if (error) throw new Error(error) + + toast.success("업체 정보가 업데이트되었습니다!") + form.reset() + props.onOpenChange?.(false) + } catch (err: any) { + toast.error(String(err)) + } + }) +} + + return ( + + + + 업체 정보 수정 + + 업체 세부 정보를 수정하고 변경 사항을 저장하세요 + + +
+ + {/* 업체 기본 정보 섹션 */} +
+
+ +

업체 기본 정보

+
+ + 업체가 제공한 기본 정보입니다. 필요시 수정하세요. + +
+ {/* vendorName */} + ( + + 업체명 + + + + + + )} + /> + + {/* vendorCode */} + ( + + 업체 코드 + + + + + + )} + /> + + {/* address */} + ( + + 주소 + + + + + + )} + /> + + {/* country */} + ( + + 국가 + + + + + + )} + /> + + {/* phone */} + ( + + 전화번호 + + + + + + )} + /> + + {/* email */} + ( + + 이메일 + + + + + + )} + /> + + {/* website */} + ( + + 웹사이트 + + + + + + )} + /> + + {/* status with icons */} + { + // 현재 선택된 상태의 구성 정보 가져오기 + const selectedConfig = getStatusConfig(field.value ?? "ACTIVE"); + const SelectedIcon = selectedConfig?.Icon || CircleIcon; + + return ( + + 업체승인상태 + + + + + + ); + }} + /> + + + + +
+
+ + + + + + + +
+ +
+
+ ) +} \ No newline at end of file diff --git a/lib/tech-vendors/table/vendor-all-export.ts b/lib/tech-vendors/table/vendor-all-export.ts new file mode 100644 index 00000000..4278249a --- /dev/null +++ b/lib/tech-vendors/table/vendor-all-export.ts @@ -0,0 +1,252 @@ +// /lib/vendor-export.ts +import ExcelJS from "exceljs" +import { TechVendor, TechVendorContact, TechVendorItem } from "@/db/schema/techVendors" +import { exportTechVendorDetails } from "../service"; + +/** + * 선택된 벤더의 모든 관련 정보를 통합 시트 형식으로 엑셀로 내보내는 함수 + * - 기본정보 시트 + * - 연락처 시트 + * - 아이템 시트 + * 각 시트에는 식별을 위한 벤더 코드, 벤더명, 세금ID가 포함됨 + */ +export async function exportVendorsWithRelatedData( + vendors: TechVendor[], + filename = "tech-vendors-detailed" +): Promise { + if (!vendors.length) return; + + // 선택된 벤더 ID 목록 + const vendorIds = vendors.map(vendor => vendor.id); + + try { + // 서버로부터 모든 관련 데이터 가져오기 + const vendorsWithDetails = await exportTechVendorDetails(vendorIds); + + if (!vendorsWithDetails.length) { + throw new Error("내보내기 데이터를 가져오는 중 오류가 발생했습니다."); + } + + // 워크북 생성 + const workbook = new ExcelJS.Workbook(); + + // 데이터 타입 확인 (서비스에서 반환하는 실제 데이터 형태) + const vendorData = vendorsWithDetails as unknown as any[]; + + // ===== 1. 기본 정보 시트 ===== + createBasicInfoSheet(workbook, vendorData); + + // ===== 2. 연락처 시트 ===== + createContactsSheet(workbook, vendorData); + + // ===== 3. 아이템 시트 ===== + createItemsSheet(workbook, vendorData); + + + // 파일 다운로드 + const buffer = await workbook.xlsx.writeBuffer(); + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `${filename}-${new Date().toISOString().split("T")[0]}.xlsx`; + link.click(); + URL.revokeObjectURL(url); + + return; + } catch (error) { + console.error("Export error:", error); + throw error; + } +} + +// 기본 정보 시트 생성 함수 +function createBasicInfoSheet( + workbook: ExcelJS.Workbook, + vendors: TechVendor[] +): void { + const basicInfoSheet = workbook.addWorksheet("기본정보"); + + // 기본 정보 시트 헤더 설정 + basicInfoSheet.columns = [ + { header: "업체코드", key: "vendorCode", width: 15 }, + { header: "업체명", key: "vendorName", width: 20 }, + { header: "세금ID", key: "taxId", width: 15 }, + { header: "국가", key: "country", width: 10 }, + { header: "상태", key: "status", width: 15 }, + { header: "이메일", key: "email", width: 20 }, + { header: "전화번호", key: "phone", width: 15 }, + { header: "웹사이트", key: "website", width: 20 }, + { header: "주소", key: "address", width: 30 }, + { header: "대표자명", key: "representativeName", width: 15 }, + { header: "생성일", key: "createdAt", width: 15 }, + ]; + + // 헤더 스타일 설정 + applyHeaderStyle(basicInfoSheet); + + // 벤더 데이터 추가 + vendors.forEach((vendor: TechVendor) => { + basicInfoSheet.addRow({ + vendorCode: vendor.vendorCode || "", + vendorName: vendor.vendorName, + taxId: vendor.taxId, + country: vendor.country, + status: getStatusText(vendor.status), // 상태 코드를 읽기 쉬운 텍스트로 변환 + email: vendor.email, + phone: vendor.phone, + website: vendor.website, + address: vendor.address, + representativeName: vendor.representativeName, + createdAt: vendor.createdAt ? formatDate(vendor.createdAt) : "", + }); + }); +} + +// 연락처 시트 생성 함수 +function createContactsSheet( + workbook: ExcelJS.Workbook, + vendors: TechVendor[] +): void { + const contactsSheet = workbook.addWorksheet("연락처"); + + contactsSheet.columns = [ + // 벤더 식별 정보 + { header: "업체코드", key: "vendorCode", width: 15 }, + { header: "업체명", key: "vendorName", width: 20 }, + { header: "세금ID", key: "taxId", width: 15 }, + // 연락처 정보 + { header: "이름", key: "contactName", width: 15 }, + { header: "직책", key: "contactPosition", width: 15 }, + { header: "이메일", key: "contactEmail", width: 25 }, + { header: "전화번호", key: "contactPhone", width: 15 }, + { header: "주요 연락처", key: "isPrimary", width: 10 }, + ]; + + // 헤더 스타일 설정 + applyHeaderStyle(contactsSheet); + + // 벤더별 연락처 데이터 추가 + vendors.forEach((vendor: TechVendor) => { + if (vendor.contacts && vendor.contacts.length > 0) { + vendor.contacts.forEach((contact: TechVendorContact) => { + contactsSheet.addRow({ + // 벤더 식별 정보 + vendorCode: vendor.vendorCode || "", + vendorName: vendor.vendorName, + taxId: vendor.taxId, + // 연락처 정보 + contactName: contact.contactName, + contactPosition: contact.contactPosition || "", + contactEmail: contact.contactEmail, + contactPhone: contact.contactPhone || "", + isPrimary: contact.isPrimary ? "예" : "아니오", + }); + }); + } else { + // 연락처가 없는 경우에도 벤더 정보만 추가 + contactsSheet.addRow({ + vendorCode: vendor.vendorCode || "", + vendorName: vendor.vendorName, + taxId: vendor.taxId, + contactName: "", + contactPosition: "", + contactEmail: "", + contactPhone: "", + isPrimary: "", + }); + } + }); +} + +// 아이템 시트 생성 함수 +function createItemsSheet( + workbook: ExcelJS.Workbook, + vendors: TechVendor[] +): void { + const itemsSheet = workbook.addWorksheet("아이템"); + + itemsSheet.columns = [ + // 벤더 식별 정보 + { header: "업체코드", key: "vendorCode", width: 15 }, + { header: "업체명", key: "vendorName", width: 20 }, + { header: "세금ID", key: "taxId", width: 15 }, + // 아이템 정보 + { header: "아이템 코드", key: "itemCode", width: 15 }, + { header: "아이템명", key: "itemName", width: 25 }, + { header: "설명", key: "description", width: 30 }, + { header: "등록일", key: "createdAt", width: 15 }, + ]; + + // 헤더 스타일 설정 + applyHeaderStyle(itemsSheet); + + // 벤더별 아이템 데이터 추가 + vendors.forEach((vendor: TechVendor) => { + if (vendor.items && vendor.items.length > 0) { + vendor.items.forEach((item: TechVendorItem) => { + itemsSheet.addRow({ + // 벤더 식별 정보 + vendorCode: vendor.vendorCode || "", + vendorName: vendor.vendorName, + taxId: vendor.taxId, + // 아이템 정보 + itemCode: item.itemCode, + itemName: item.itemName, + createdAt: item.createdAt ? formatDate(item.createdAt) : "", + }); + }); + } else { + // 아이템이 없는 경우에도 벤더 정보만 추가 + itemsSheet.addRow({ + vendorCode: vendor.vendorCode || "", + vendorName: vendor.vendorName, + taxId: vendor.taxId, + itemCode: "", + itemName: "", + createdAt: "", + }); + } + }); +} + + +// 헤더 스타일 적용 함수 +function applyHeaderStyle(sheet: ExcelJS.Worksheet): void { + const headerRow = sheet.getRow(1); + headerRow.font = { bold: true }; + headerRow.alignment = { horizontal: "center" }; + headerRow.eachCell((cell: ExcelJS.Cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, + }; + }); +} + +// 날짜 포맷 함수 +function formatDate(date: Date | string): string { + if (!date) return ""; + if (typeof date === 'string') { + date = new Date(date); + } + return date.toISOString().split('T')[0]; +} + + +// 상태 코드를 읽기 쉬운 텍스트로 변환하는 함수 +function getStatusText(status: string): string { + const statusMap: Record = { + "PENDING_REVIEW": "검토 대기중", + "IN_REVIEW": "검토 중", + "REJECTED": "거부됨", + "ACTIVE": "활성", + "INACTIVE": "비활성", + "BLACKLISTED": "거래 금지" + }; + + return statusMap[status] || status; +} \ No newline at end of file diff --git a/lib/tech-vendors/utils.ts b/lib/tech-vendors/utils.ts new file mode 100644 index 00000000..b0bc33f0 --- /dev/null +++ b/lib/tech-vendors/utils.ts @@ -0,0 +1,28 @@ +import { LucideIcon, Hourglass, CheckCircle2, XCircle, CircleAlert, Clock, ShieldAlert } from "lucide-react"; +import type { TechVendor } from "@/db/schema/techVendors"; + +type StatusType = TechVendor["status"]; + +/** + * 기술벤더 상태에 대한 아이콘을 반환합니다. + */ +export function getVendorStatusIcon(status: StatusType): LucideIcon { + switch (status) { + case "PENDING_REVIEW": + return Clock; + case "IN_REVIEW": + return Hourglass; + case "REJECTED": + return XCircle; + case "ACTIVE": + return CheckCircle2; + case "INACTIVE": + return CircleAlert; + case "BLACKLISTED": + return ShieldAlert; + default: + return CircleAlert; + } +} + + diff --git a/lib/tech-vendors/validations.ts b/lib/tech-vendors/validations.ts new file mode 100644 index 00000000..8bba3103 --- /dev/null +++ b/lib/tech-vendors/validations.ts @@ -0,0 +1,260 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { techVendors, TechVendor, TechVendorContact, TechVendorItemsView, VENDOR_TYPES } from "@/db/schema/techVendors"; + +export const searchParamsCache = createSearchParamsCache({ + // 공통 플래그 + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + + // 페이징 + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 정렬 (techVendors 테이블에 맞춰 TechVendor 타입 지정) + sort: getSortingStateParser().withDefault([ + { id: "createdAt", desc: true }, // createdAt 기준 내림차순 + ]), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 검색 키워드 + search: parseAsString.withDefault(""), + + // ----------------------------------------------------------------- + // 기술영업 협력업체에 특화된 검색 필드 + // ----------------------------------------------------------------- + // 상태 (ACTIVE, INACTIVE, BLACKLISTED 등) 중에서 선택 + status: parseAsStringEnum(["ACTIVE", "INACTIVE", "BLACKLISTED", "PENDING_REVIEW", "IN_REVIEW", "REJECTED"]), + + // 협력업체명 검색 + 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), + + // 정렬 + sort: getSortingStateParser().withDefault([ + { id: "createdAt", desc: true }, // createdAt 기준 내림차순 + ]), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 검색 키워드 + search: parseAsString.withDefault(""), + + // 특정 필드 검색 + contactName: parseAsString.withDefault(""), + contactPosition: parseAsString.withDefault(""), + contactEmail: parseAsString.withDefault(""), + contactPhone: parseAsString.withDefault(""), +}); + +export const searchParamsItemCache = createSearchParamsCache({ + // 공통 플래그 + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + + // 페이징 + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 정렬 + sort: getSortingStateParser().withDefault([ + { id: "createdAt", desc: true }, // createdAt 기준 내림차순 + ]), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 검색 키워드 + search: parseAsString.withDefault(""), + + // 특정 필드 검색 + itemName: parseAsString.withDefault(""), + itemCode: parseAsString.withDefault(""), +}); + +// 기술영업 벤더 기본 정보 업데이트 스키마 +export const updateTechVendorSchema = z.object({ + vendorName: z.string().min(1, "업체명은 필수 입력사항입니다"), + vendorCode: z.string().optional(), + address: z.string().optional(), + country: z.string().optional(), + phone: z.string().optional(), + email: z.string().email("유효한 이메일 주소를 입력해주세요").optional(), + website: z.string().url("유효한 URL을 입력해주세요").optional(), + status: z.enum(techVendors.status.enumValues).optional(), + userId: z.number().optional(), + comment: z.string().optional(), +}); + +// 연락처 스키마 +const contactSchema = z.object({ + id: z.number().optional(), + contactName: z + .string() + .min(1, "Contact name is required") + .max(255, "Max length 255"), + contactPosition: z.string().max(100).optional(), + contactEmail: z.string().email("Invalid email").max(255), + contactPhone: z.string().max(50).optional(), + isPrimary: z.boolean().default(false).optional() +}); + +// 기술영업 벤더 생성 스키마 +export const createTechVendorSchema = z + .object({ + vendorName: z + .string() + .min(1, "Vendor name is required") + .max(255, "Max length 255"), + + email: z.string().email("Invalid email").max(255), + // 나머지 optional + vendorCode: z.string().max(100, "Max length 100").optional(), + address: z.string().optional(), + country: z.string() + .min(1, "국가 선택은 필수입니다.") + .max(100, "Max length 100"), + phone: z.string().max(50, "Max length 50").optional(), + website: z.string().url("유효하지 않은 URL입니다. https:// 혹은 http:// 로 시작하는 주소를 입력해주세요.").max(255).optional(), + + files: z.any().optional(), + status: z.enum(techVendors.status.enumValues).default("PENDING_REVIEW"), + techVendorType: z.enum(VENDOR_TYPES).default("조선"), + + representativeName: z.union([z.string().max(255), z.literal("")]).optional(), + representativeBirth: z.union([z.string().max(20), z.literal("")]).optional(), + representativeEmail: z.union([z.string().email("Invalid email").max(255), z.literal("")]).optional(), + representativePhone: z.union([z.string().max(50), z.literal("")]).optional(), + corporateRegistrationNumber: z.union([z.string().max(100), z.literal("")]).optional(), + taxId: z.string().min(1, { message: "사업자등록번호를 입력해주세요" }), + + items: z.string().min(1, { message: "공급품목을 입력해주세요" }), + + 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) 업체일 경우 필수입니다.", + }) + } + } + }); + +// 연락처 생성 스키마 +export const createTechVendorContactSchema = z.object({ + vendorId: z.number(), + contactName: z.string() + .min(1, "Contact name is required") + .max(255, "Max length 255"), + contactPosition: z.string().max(100, "Max length 100"), + contactEmail: z.string().email(), + contactPhone: z.string().max(50, "Max length 50").optional(), + isPrimary: z.boolean(), +}); + +// 연락처 업데이트 스키마 +export const updateTechVendorContactSchema = z.object({ + contactName: z.string() + .min(1, "Contact name is required") + .max(255, "Max length 255"), + contactPosition: z.string().max(100, "Max length 100").optional(), + contactEmail: z.string().email().optional(), + contactPhone: z.string().max(50, "Max length 50").optional(), + isPrimary: z.boolean().optional(), +}); + +// 아이템 생성 스키마 +export const createTechVendorItemSchema = z.object({ + vendorId: z.number(), + itemCode: z.string().max(100, "Max length 100"), + itemName: z.string().min(1, "Item name is required").max(255, "Max length 255"), +}); + +// 아이템 업데이트 스키마 +export const updateTechVendorItemSchema = z.object({ + itemName: z.string().optional(), + itemCode: z.string().max(100, "Max length 100"), +}); + +// 타입 내보내기 +export type GetTechVendorsSchema = Awaited> +export type GetTechVendorContactsSchema = Awaited> +export type GetTechVendorItemsSchema = Awaited> + +export type UpdateTechVendorSchema = z.infer +export type CreateTechVendorSchema = z.infer +export type CreateTechVendorContactSchema = z.infer +export type UpdateTechVendorContactSchema = z.infer +export type CreateTechVendorItemSchema = z.infer +export type UpdateTechVendorItemSchema = z.infer \ No newline at end of file -- cgit v1.2.3