diff options
Diffstat (limited to 'lib')
24 files changed, 5385 insertions, 0 deletions
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<CreateTechVendorContactSchema>({ + 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 ( + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + {/* 모달을 열기 위한 버튼 */} + <DialogTrigger asChild> + <Button variant="default" size="sm"> + Add Contact + </Button> + </DialogTrigger> + + <DialogContent> + <DialogHeader> + <DialogTitle>Create New Contact</DialogTitle> + <DialogDescription> + 새 Contact 정보를 입력하고 <b>Create</b> 버튼을 누르세요. + </DialogDescription> + </DialogHeader> + + {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */} + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <div className="space-y-4 py-4"> + <FormField + control={form.control} + name="contactName" + render={({ field }) => ( + <FormItem> + <FormLabel>Contact Name</FormLabel> + <FormControl> + <Input placeholder="예: 홍길동" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="contactPosition" + render={({ field }) => ( + <FormItem> + <FormLabel>Position / Title</FormLabel> + <FormControl> + <Input placeholder="예: 과장" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="contactEmail" + render={({ field }) => ( + <FormItem> + <FormLabel>Email</FormLabel> + <FormControl> + <Input placeholder="name@company.com" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="contactPhone" + render={({ field }) => ( + <FormItem> + <FormLabel>Phone</FormLabel> + <FormControl> + <Input placeholder="010-1234-5678" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 단순 checkbox */} + <FormField + control={form.control} + name="isPrimary" + render={({ field }) => ( + <FormItem> + <div className="flex items-center space-x-2 mt-2"> + <input + type="checkbox" + checked={field.value} + onChange={(e) => field.onChange(e.target.checked)} + /> + <FormLabel>Is Primary?</FormLabel> + </div> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <DialogFooter> + <Button type="button" variant="outline" onClick={() => setOpen(false)}> + Cancel + </Button> + <Button type="submit" disabled={form.formState.isSubmitting}> + Create + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/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<React.SetStateAction<DataTableRowAction<VendorContact> | null>>; +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorContact>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<VendorContact> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size:40, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef<VendorContact> = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem + onSelect={() => { + setRowAction({ row, type: "update" }) + + }} + > + Edit + </DropdownMenuItem> + + <DropdownMenuSeparator /> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "delete" })} + > + Delete + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + } + + // ---------------------------------------------------------------- + // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 3-1) groupMap: { [groupName]: ColumnDef<VendorContact>[] } + const groupMap: Record<string, ColumnDef<VendorContact>[]> = {} + + vendorContactsColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef<VendorContact> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + + + if (cfg.id === "createdAt") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) + } + + if (cfg.id === "updatedAt") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) + } + + + // code etc... + return row.getValue(cfg.id) ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // ---------------------------------------------------------------- + // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef<VendorContact>[] = [] + + // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 + // 여기서는 그냥 Object.entries 순서 + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + // 그룹 없음 → 그냥 최상위 레벨 컬럼 + nestedColumns.push(...colDefs) + } else { + // 상위 컬럼 + nestedColumns.push({ + id: groupName, + header: groupName, // "Basic Info", "Metadata" 등 + columns: colDefs, + }) + } + }) + + // ---------------------------------------------------------------- + // 4) 최종 컬럼 배열: select, nestedColumns, actions + // ---------------------------------------------------------------- + return [ + selectColumn, + ...nestedColumns, + actionsColumn, + ] +}
\ No newline at end of file diff --git a/lib/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<TechVendorContact> + vendorId: number +} + +export function TechVendorContactsTableToolbarActions({ table, vendorId }: TechVendorContactsTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + const fileInputRef = React.useRef<HTMLInputElement>(null) + + // 파일이 선택되었을 때 처리 + async function onFileChange(event: React.ChangeEvent<HTMLInputElement>) { + const file = event.target.files?.[0] + if (!file) return + + // 파일 초기화 (동일 파일 재업로드 시에도 onChange가 트리거되도록) + event.target.value = "" + + // 서버 액션 or API 호출 + try { + // 예: 서버 액션 호출 + const { errorFile, errorMessage } = await importTasksExcel(file) + + if (errorMessage) { + toast.error(errorMessage) + } + if (errorFile) { + // 에러 엑셀을 다운로드 + const url = URL.createObjectURL(errorFile) + const link = document.createElement("a") + link.href = url + link.download = "errors.xlsx" + link.click() + URL.revokeObjectURL(url) + } else { + // 성공 + toast.success("Import success") + // 필요 시 revalidateTag("tasks") 등 + } + + } catch (error) { + toast.error("파일 업로드 중 오류가 발생했습니다.") + + } + } + + function handleImportClick() { + // 숨겨진 <input type="file" /> 요소를 클릭 + fileInputRef.current?.click() + } + + return ( + <div className="flex items-center gap-2"> + + <AddContactDialog vendorId={vendorId}/> + + {/** 3) Import 버튼 (파일 업로드) */} + <Button variant="outline" size="sm" className="gap-2" onClick={handleImportClick}> + <Upload className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Import</span> + </Button> + {/* + 실제로는 숨겨진 input과 연결: + - accept=".xlsx,.xls" 등으로 Excel 파일만 업로드 허용 + */} + <input + ref={fileInputRef} + type="file" + accept=".xlsx,.xls" + className="hidden" + onChange={onFileChange} + /> + + {/** 4) Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "tech-vendor-contacts", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ) +}
\ 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<ReturnType<typeof getTechVendorContacts>>, + ] + >, + vendorId:number +} + +export function TechVendorContactsTable({ promises , vendorId}: TechVendorContactsTableProps) { + const { featureFlags } = useFeatureFlags() + + // Suspense로 받아온 데이터 + const [{ data, pageCount }] = React.use(promises) + + const [rowAction, setRowAction] = React.useState<DataTableRowAction<TechVendorContact> | null>(null) + + // getColumns() 호출 시, router를 주입 + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + const filterFields: DataTableFilterField<TechVendorContact>[] = [ + + ] + + const advancedFilterFields: DataTableAdvancedFilterField<TechVendorContact>[] = [ + { id: "contactName", label: "Contact Name", type: "text" }, + { id: "contactPosition", label: "Contact Position", type: "text" }, + { id: "contactEmail", label: "Contact Email", type: "text" }, + { id: "contactPhone", label: "Contact Phone", type: "text" }, + { id: "createdAt", label: "Created at", type: "date" }, + { id: "updatedAt", label: "Updated at", type: "date" }, + ] + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable + table={table} + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <TechVendorContactsTableToolbarActions table={table} vendorId={vendorId} /> + </DataTableAdvancedToolbar> + </DataTable> + </> + ) +}
\ 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<FeatureFlagsContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useFeatureFlags() { + const context = React.useContext(FeatureFlagsContext) + if (!context) { + throw new Error( + "useFeatureFlags must be used within a FeatureFlagsProvider" + ) + } + return context +} + +interface FeatureFlagsProviderProps { + children: React.ReactNode +} + +export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "flags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + shallow: false, + } + ) + + return ( + <FeatureFlagsContext.Provider + value={{ + featureFlags, + setFeatureFlags: (value) => void setFeatureFlags(value), + }} + > + <div className="w-full overflow-x-auto"> + <ToggleGroup + type="multiple" + variant="outline" + size="sm" + value={featureFlags} + onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className={cn( + "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", + { + "rounded-l-sm border-r-0": index === 0, + "rounded-r-sm": + index === dataTableConfig.featureFlags.length - 1, + } + )} + asChild + > + <TooltipTrigger> + <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> + {flag.label} + </TooltipTrigger> + </ToggleGroupItem> + <TooltipContent + align="start" + side="bottom" + sideOffset={6} + className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" + > + <div>{flag.tooltipTitle}</div> + <div className="text-xs text-muted-foreground"> + {flag.tooltipDescription} + </div> + </TooltipContent> + </Tooltip> + ))} + </ToggleGroup> + </div> + {children} + </FeatureFlagsContext.Provider> + ) +} diff --git a/lib/tech-vendors/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<ItemDropdownOption[]>([]) + const [filteredItems, setFilteredItems] = React.useState<ItemDropdownOption[]>([]) + const [isLoading, setIsLoading] = React.useState(false) + const [searchTerm, setSearchTerm] = React.useState("") + + const [selectedItem, setSelectedItem] = React.useState<{ + itemName: string; + description: string; + } | null>(null) + + const form = useForm<CreateTechVendorItemSchema>({ + 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 ( + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + <DialogTrigger asChild> + <Button variant="default" size="sm"> + Add Item + </Button> + </DialogTrigger> + + <DialogContent className="max-h-[90vh] overflow-hidden flex flex-col"> + <DialogHeader> + <DialogTitle>Create New Item</DialogTitle> + <DialogDescription> + 아이템을 선택한 후 <b>Create</b> 버튼을 누르세요. + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form onSubmit={(e) => { + console.log(`[AddItemDialog] 폼 제출 이벤트 발생`) + form.handleSubmit(onSubmit)(e) + }} className="flex flex-col flex-1 overflow-hidden"> + <div className="space-y-4 py-4 flex-1 overflow-y-auto"> + <div> + <FormLabel className="text-sm font-medium">아이템 선택</FormLabel> + <Popover open={commandOpen} onOpenChange={setCommandOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={commandOpen} + className="w-full justify-between mt-1" + > + {selectedItemCode + ? `${selectedItemCode} - ${displayItemName}` + : "아이템 선택..."} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[400px] p-0"> + <Command> + <CommandInput + placeholder="아이템 코드/이름 검색..." + onValueChange={setSearchTerm} + /> + <CommandList className="max-h-[200px]"> + <CommandEmpty>검색 결과가 없습니다</CommandEmpty> + {isLoading ? ( + <div className="py-6 text-center text-sm">로딩 중...</div> + ) : ( + <CommandGroup> + {filteredItems.map((item) => ( + <CommandItem + key={item.itemCode} + value={`${item.itemCode} ${item.itemName}`} + onSelect={() => handleSelectItem(item)} + > + <Check + className={cn( + "mr-2 h-4 w-4", + selectedItemCode === item.itemCode + ? "opacity-100" + : "opacity-0" + )} + /> + <span className="font-medium">{item.itemCode}</span> + <span className="ml-2 text-gray-500 truncate">- {item.itemName}</span> + </CommandItem> + ))} + </CommandGroup> + )} + </CommandList> + </Command> + </PopoverContent> + </Popover> + </div> + + {selectedItem && ( + <div className="rounded-md border p-3 mt-4 overflow-hidden"> + <h3 className="font-medium text-sm mb-2">선택된 아이템 정보</h3> + + <FormField + control={form.control} + name="itemCode" + render={({ field }) => ( + <FormItem className="hidden"> + <FormControl> + <Input {...field} /> + </FormControl> + </FormItem> + )} + /> + + <div className="mb-2"> + <div className="text-sm font-medium text-gray-500">Item Name</div> + <div className="text-sm">{selectedItem.itemName}</div> + </div> + + {selectedItem.description && ( + <div> + <div className="text-sm font-medium text-gray-500">Description</div> + <div className="text-sm">{selectedItem.description}</div> + </div> + )} + </div> + )} + </div> + + <DialogFooter className="pt-4 border-t"> + <Button type="submit" disabled={!selectedItemCode}> + Create + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ 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<FeatureFlagsContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useFeatureFlags() { + const context = React.useContext(FeatureFlagsContext) + if (!context) { + throw new Error( + "useFeatureFlags must be used within a FeatureFlagsProvider" + ) + } + return context +} + +interface FeatureFlagsProviderProps { + children: React.ReactNode +} + +export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "flags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + shallow: false, + } + ) + + return ( + <FeatureFlagsContext.Provider + value={{ + featureFlags, + setFeatureFlags: (value) => void setFeatureFlags(value), + }} + > + <div className="w-full overflow-x-auto"> + <ToggleGroup + type="multiple" + variant="outline" + size="sm" + value={featureFlags} + onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className={cn( + "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", + { + "rounded-l-sm border-r-0": index === 0, + "rounded-r-sm": + index === dataTableConfig.featureFlags.length - 1, + } + )} + asChild + > + <TooltipTrigger> + <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> + {flag.label} + </TooltipTrigger> + </ToggleGroupItem> + <TooltipContent + align="start" + side="bottom" + sideOffset={6} + className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" + > + <div>{flag.tooltipTitle}</div> + <div className="text-xs text-muted-foreground"> + {flag.tooltipDescription} + </div> + </TooltipContent> + </Tooltip> + ))} + </ToggleGroup> + </div> + {children} + </FeatureFlagsContext.Provider> + ) +} diff --git a/lib/tech-vendors/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<React.SetStateAction<DataTableRowAction<TechVendorItemsView> | null>> + vendorType: string +} + +export function getColumns({ setRowAction, vendorType }: GetColumnsOptions): ColumnDef<TechVendorItemsView>[] { + // 벤더 타입에 따라 적절한 컬럼 설정 선택 + const columnsConfig = (() => { + switch (vendorType) { + case "조선": + return shipbuildingColumnsConfig; + case "해양TOP": + return offshoreTopColumnsConfig; + case "해양HULL": + return offshoreHullColumnsConfig; + default: + return techVendorItemsColumnsConfig; + } + })(); + + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<TechVendorItemsView> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-[2px]" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-[2px]" + /> + ), + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + // const actionsColumn: ColumnDef<TechVendorItemsView> = { + // id: "actions", + // cell: ({ row }) => { + // return ( + // <DropdownMenu> + // <DropdownMenuTrigger asChild> + // <Button variant="ghost" className="h-8 w-8 p-0"> + // <span className="sr-only">Open menu</span> + // <MoreHorizontal className="h-4 w-4" /> + // </Button> + // </DropdownMenuTrigger> + // <DropdownMenuContent align="end"> + // <DropdownMenuLabel>Actions</DropdownMenuLabel> + // <DropdownMenuItem onClick={() => + // setRowAction({ + // type: "update", + // row, + // }) + // }> + // View Details + // </DropdownMenuItem> + // </DropdownMenuContent> + // </DropdownMenu> + // ) + // }, + // } + + // ---------------------------------------------------------------- + // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + const groupMap: Record<string, ColumnDef<TechVendorItemsView>[]> = {} + + columnsConfig.forEach((cfg: ColumnConfig) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef<TechVendorItemsView> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + minSize: cfg.minWidth, + size: cfg.defaultWidth, + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + if (cfg.id === "createdAt" || 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 ? ( + <Badge variant="outline" className="capitalize"> + {type} + </Badge> + ) : null + } + + return row.getValue(cfg.id) ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // ---------------------------------------------------------------- + // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef<TechVendorItemsView>[] = [] + + 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<TechVendorItemsView> + vendorId: number + vendorType: string +} + +export function TechVendorItemsTableToolbarActions({ table, vendorId, vendorType }: TechVendorItemsTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + const fileInputRef = React.useRef<HTMLInputElement>(null) + + // 파일이 선택되었을 때 처리 + async function onFileChange(event: React.ChangeEvent<HTMLInputElement>) { + const file = event.target.files?.[0] + if (!file) return + + // 파일 초기화 (동일 파일 재업로드 시에도 onChange가 트리거되도록) + event.target.value = "" + + // 서버 액션 or API 호출 + try { + // 예: 서버 액션 호출 + const { errorFile, errorMessage } = await importTasksExcel(file) + + if (errorMessage) { + toast.error(errorMessage) + } + if (errorFile) { + // 에러 엑셀을 다운로드 + const url = URL.createObjectURL(errorFile) + const link = document.createElement("a") + link.href = url + link.download = "errors.xlsx" + link.click() + URL.revokeObjectURL(url) + } else { + // 성공 + toast.success("Import success") + // 필요 시 revalidateTag("tasks") 등 + } + + } catch (err) { + console.error("파일 업로드 중 오류가 발생했습니다:", err) + toast.error("파일 업로드 중 오류가 발생했습니다.") + } + } + + function handleImportClick() { + // 숨겨진 <input type="file" /> 요소를 클릭 + fileInputRef.current?.click() + } + + return ( + <div className="flex items-center gap-2"> + + <AddItemDialog vendorId={vendorId} vendorType={vendorType} /> + + {/** 3) Import 버튼 (파일 업로드) */} + <Button variant="outline" size="sm" className="gap-2" onClick={handleImportClick}> + <Upload className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Import</span> + </Button> + {/* + 실제로는 숨겨진 input과 연결: + - accept=".xlsx,.xls" 등으로 Excel 파일만 업로드 허용 + */} + <input + ref={fileInputRef} + type="file" + accept=".xlsx,.xls" + className="hidden" + onChange={onFileChange} + /> + + {/** 4) Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "tech-vendor-items", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ) +}
\ No newline at end of file diff --git a/lib/tech-vendors/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<DataTableRowAction<TechVendorItemsView> | null>(null) + + const columns = React.useMemo( + () => getColumns({ + setRowAction, + vendorType + }), + [vendorType] + ) + + const filterFields: DataTableFilterField<TechVendorItemsView>[] = [] + + const advancedFilterFields: DataTableAdvancedFilterField<TechVendorItemsView>[] = [ + { 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 ( + <> + <DataTable + table={table} + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <TechVendorItemsTableToolbarActions + table={table} + vendorId={vendorId} + vendorType={vendorType} + /> + </DataTableAdvancedToolbar> + </DataTable> + </> + ) +}
\ 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<unknown>; + orderBy?: SQL<unknown>[]; + } & PaginationParams +) { + const query = tx + .select({ + id: techVendors.id, + vendorName: techVendors.vendorName, + vendorCode: techVendors.vendorCode, + taxId: techVendors.taxId, + address: techVendors.address, + country: techVendors.country, + phone: techVendors.phone, + email: techVendors.email, + website: techVendors.website, + status: techVendors.status, + techVendorType: techVendors.techVendorType, + representativeName: techVendors.representativeName, + representativeEmail: techVendors.representativeEmail, + representativePhone: techVendors.representativePhone, + representativeBirth: techVendors.representativeBirth, + 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<unknown> +) { + const query = tx.select({ count: count() }).from(techVendors); + + if (where) { + query.where(where); + } + + const result = await query; + return result[0].count; +} + +// 기술영업 벤더 조회 +export async function selectTechVendors( + tx: any, + params: { + where?: SQL<unknown>; + orderBy?: SQL<unknown>[]; + } & PaginationParams +) { + const query = tx.select().from(techVendors); + + if (params.where) { + query.where(params.where); + } + + if (params.orderBy && params.orderBy.length > 0) { + query.orderBy(...params.orderBy); + } else { + query.orderBy(desc(techVendors.createdAt)); + } + + query.offset(params.offset).limit(params.limit); + + return query; +} + +// 기술영업 벤더 수 카운트 +export async function countTechVendors(tx: any, where?: SQL<unknown>) { + const query = tx.select({ count: count() }).from(techVendors); + + if (where) { + query.where(where); + } + + const result = await query; + return result[0].count; +} + +// 벤더 상태별 카운트 +export async function groupByTechVendorStatus(tx: any) { + const result = await tx + .select({ + status: techVendors.status, + count: count(), + }) + .from(techVendors) + .groupBy(techVendors.status); + + return result; +} + +// 벤더 상세 정보 조회 +export async function getTechVendorById(id: number) { + const result = await db + .select() + .from(techVendors) + .where(eq(techVendors.id, id)); + + return result.length > 0 ? result[0] : null; +} + +// 벤더 연락처 정보 조회 +export async function getTechVendorContactsById(id: number) { + const result = await db + .select() + .from(techVendorContacts) + .where(eq(techVendorContacts.id, id)); + + return result.length > 0 ? result[0] : null; +} + +// 신규 벤더 생성 +export async function insertTechVendor( + tx: any, + data: Omit<TechVendor, "id" | "createdAt" | "updatedAt"> +) { + return tx + .insert(techVendors) + .values({ + ...data, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); +} + +// 벤더 정보 업데이트 (단일) +export async function updateTechVendor( + tx: any, + id: string | number, + data: Partial<TechVendor> +) { + return tx + .update(techVendors) + .set({ + ...data, + updatedAt: new Date(), + }) + .where(eq(techVendors.id, Number(id))) + .returning(); +} + +// 벤더 정보 업데이트 (다수) +export async function updateTechVendors( + tx: any, + ids: (string | number)[], + data: Partial<TechVendor> +) { + return tx + .update(techVendors) + .set({ + ...data, + updatedAt: new Date(), + }) + .where(inArray(techVendors.id, ids.map(id => Number(id)))) + .returning(); +} + +// 벤더 연락처 조회 +export async function selectTechVendorContacts( + tx: any, + params: { + where?: SQL<unknown>; + orderBy?: SQL<unknown>[]; + } & PaginationParams +) { + const query = tx.select().from(techVendorContacts); + + if (params.where) { + query.where(params.where); + } + + if (params.orderBy && params.orderBy.length > 0) { + query.orderBy(...params.orderBy); + } else { + query.orderBy(desc(techVendorContacts.createdAt)); + } + + query.offset(params.offset).limit(params.limit); + + return query; +} + +// 벤더 연락처 수 카운트 +export async function countTechVendorContacts(tx: any, where?: SQL<unknown>) { + const query = tx.select({ count: count() }).from(techVendorContacts); + + if (where) { + query.where(where); + } + + const result = await query; + return result[0].count; +} + +// 연락처 생성 +export async function insertTechVendorContact( + tx: any, + data: Omit<TechVendorContact, "id" | "createdAt" | "updatedAt"> +) { + return tx + .insert(techVendorContacts) + .values({ + ...data, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); +} + +// 아이템 목록 조회 +export async function selectTechVendorItems( + tx: any, + params: { + where?: SQL<unknown>; + orderBy?: SQL<unknown>[]; + } & PaginationParams +) { + const query = tx.select().from(techVendorItemsView); + + if (params.where) { + query.where(params.where); + } + + if (params.orderBy && params.orderBy.length > 0) { + query.orderBy(...params.orderBy); + } else { + query.orderBy(desc(techVendorItemsView.createdAt)); + } + + query.offset(params.offset).limit(params.limit); + + return query; +} + +// 아이템 수 카운트 +export async function countTechVendorItems(tx: any, where?: SQL<unknown>) { + const query = tx.select({ count: count() }).from(techVendorItemsView); + + if (where) { + query.where(where); + } + + const result = await query; + return result[0].count; +} + +// 아이템 생성 +export async function insertTechVendorItem( + tx: any, + data: Omit<TechVendorItem, "id" | "createdAt" | "updatedAt"> +) { + return tx + .insert(techVendorPossibleItems) + .values({ + ...data, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); +} + 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<TechVendor["status"], number> = { + "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<Record<TechVendor["status"], number>>((acc, { status, count }) => { + acc[status] = count; + return acc; + }, initial); + }); + + return result; + } catch (err) { + return {} as Record<TechVendor["status"], number>; + } + }, + ["tech-vendor-status-counts"], // 캐싱 키 + { + revalidate: 3600, + } + )(); +} + +/** + * 벤더 상세 정보 조회 + */ +export async function getTechVendorById(id: number) { + return unstable_cache( + async () => { + try { + const result = await getTechVendorDetailById(id); + return { data: result }; + } catch (err) { + console.error("기술영업 벤더 상세 조회 오류:", err); + return { data: null }; + } + }, + [`tech-vendor-${id}`], + { + revalidate: 3600, + tags: ["tech-vendors", `tech-vendor-${id}`], + } + )(); +} + +/* ----------------------------------------------------- + 2) 생성(Create) +----------------------------------------------------- */ + +/** + * 첨부파일 저장 헬퍼 함수 + */ +async function storeTechVendorFiles( + tx: any, + vendorId: number, + files: File[], + attachmentType: string +) { + 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<TechVendor | null> => { + try { + // 직접 DB에서 조회 + const vendor = await db + .select() + .from(techVendors) + .where(eq(techVendors.id, id)) + .limit(1) + .then(rows => rows[0] || null); + + if (!vendor) { + console.error(`Vendor not found with id: ${id}`); + return null; + } + + return vendor; + } catch (error) { + console.error('Error fetching vendor:', error); + return null; + } +}; + +/** + * 기술영업 벤더 엑셀 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 && + <Button + variant="ghost" + size="icon" + onClick={handleDownload} + title={`${attachmentsList.length}개 파일 다운로드`} + > + <PaperclipIcon className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + {/* {attachmentsList.length > 1 && ( + <Badge + variant="secondary" + className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.425rem] leading-none flex items-center justify-center" + > + {attachmentsList.length} + </Badge> + )} */} + </Button> + } + </> + ); +} diff --git a/lib/tech-vendors/table/excel-template-download.tsx b/lib/tech-vendors/table/excel-template-download.tsx 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<FeatureFlagsContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useFeatureFlags() { + const context = React.useContext(FeatureFlagsContext) + if (!context) { + throw new Error( + "useFeatureFlags must be used within a FeatureFlagsProvider" + ) + } + return context +} + +interface FeatureFlagsProviderProps { + children: React.ReactNode +} + +export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "flags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + shallow: false, + } + ) + + return ( + <FeatureFlagsContext.Provider + value={{ + featureFlags, + setFeatureFlags: (value) => void setFeatureFlags(value), + }} + > + <div className="w-full overflow-x-auto"> + <ToggleGroup + type="multiple" + variant="outline" + size="sm" + value={featureFlags} + onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className={cn( + "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", + { + "rounded-l-sm border-r-0": index === 0, + "rounded-r-sm": + index === dataTableConfig.featureFlags.length - 1, + } + )} + asChild + > + <TooltipTrigger> + <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> + {flag.label} + </TooltipTrigger> + </ToggleGroupItem> + <TooltipContent + align="start" + side="bottom" + sideOffset={6} + className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" + > + <div>{flag.tooltipTitle}</div> + <div className="text-xs text-muted-foreground"> + {flag.tooltipDescription} + </div> + </TooltipContent> + </Tooltip> + ))} + </ToggleGroup> + </div> + {children} + </FeatureFlagsContext.Provider> + ) +} diff --git a/lib/tech-vendors/table/import-button.tsx b/lib/tech-vendors/table/import-button.tsx 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<File | null>(null);
+ const [isUploading, setIsUploading] = React.useState(false);
+ const [progress, setProgress] = React.useState(0);
+ const [error, setError] = React.useState<string | null>(null);
+
+ const fileInputRef = React.useRef<HTMLInputElement>(null);
+
+ // 파일 선택 처리
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ const selectedFile = e.target.files?.[0];
+ if (!selectedFile) return;
+
+ if (!selectedFile.name.endsWith('.xlsx') && !selectedFile.name.endsWith('.xls')) {
+ setError("Excel 파일(.xlsx 또는 .xls)만 가능합니다.");
+ return;
+ }
+
+ setFile(selectedFile);
+ setError(null);
+ };
+
+ // 데이터 가져오기 처리
+ const handleImport = async () => {
+ if (!file) {
+ setError("가져올 파일을 선택해주세요.");
+ return;
+ }
+
+ try {
+ setIsUploading(true);
+ setProgress(0);
+ setError(null);
+
+ // DRM 복호화 처리
+ let arrayBuffer: ArrayBuffer;
+ try {
+ setProgress(10);
+ toast.info("파일 복호화 중...");
+ arrayBuffer = await decryptWithServerAction(file);
+ setProgress(30);
+ } catch (decryptError) {
+ console.error("파일 복호화 실패, 원본 파일 사용:", decryptError);
+ toast.warning("파일 복호화에 실패하여 원본 파일을 사용합니다.");
+ arrayBuffer = await file.arrayBuffer();
+ }
+
+ // ExcelJS 워크북 로드
+ const workbook = new ExcelJS.Workbook();
+ await workbook.xlsx.load(arrayBuffer);
+
+ // 첫 번째 워크시트 가져오기
+ const worksheet = workbook.worksheets[0];
+ if (!worksheet) {
+ throw new Error("Excel 파일에 워크시트가 없습니다.");
+ }
+
+ // 헤더 행 찾기
+ let headerRowIndex = 1;
+ let headerRow: ExcelJS.Row | undefined;
+ let headerValues: (string | null)[] = [];
+
+ worksheet.eachRow((row, rowNumber) => {
+ const values = row.values as (string | null)[];
+ if (!headerRow && values.some(v => v === "업체명" || v === "vendorName")) {
+ headerRowIndex = rowNumber;
+ headerRow = row;
+ headerValues = [...values];
+ }
+ });
+
+ if (!headerRow) {
+ throw new Error("Excel 파일에서 헤더 행을 찾을 수 없습니다.");
+ }
+
+ // 헤더를 기반으로 인덱스 매핑 생성
+ const headerMapping: Record<string, number> = {};
+ headerValues.forEach((value, index) => {
+ if (typeof value === 'string') {
+ headerMapping[value] = index;
+ }
+ });
+
+ // 필수 헤더 확인
+ const requiredHeaders = ["업체명", "이메일", "사업자등록번호", "벤더타입"];
+ const alternativeHeaders = {
+ "업체명": ["vendorName"],
+ "이메일": ["email"],
+ "사업자등록번호": ["taxId"],
+ "벤더타입": ["techVendorType"],
+ "주소": ["address"],
+ "국가": ["country"],
+ "전화번호": ["phone"],
+ "웹사이트": ["website"],
+ "아이템": ["items"]
+ };
+
+ // 헤더 매핑 확인 (대체 이름 포함)
+ const missingHeaders = requiredHeaders.filter(header => {
+ const alternatives = alternativeHeaders[header as keyof typeof alternativeHeaders] || [];
+ return !(header in headerMapping) &&
+ !alternatives.some(alt => alt in headerMapping);
+ });
+
+ if (missingHeaders.length > 0) {
+ throw new Error(`다음 필수 헤더가 누락되었습니다: ${missingHeaders.join(", ")}`);
+ }
+
+ // 데이터 행 추출
+ const dataRows: Record<string, any>[] = [];
+
+ worksheet.eachRow((row, rowNumber) => {
+ if (rowNumber > headerRowIndex) {
+ const rowData: Record<string, any> = {};
+ const values = row.values as (string | null | undefined)[];
+
+ // 헤더 매핑에 따라 데이터 추출
+ Object.entries(headerMapping).forEach(([header, index]) => {
+ rowData[header] = values[index] || "";
+ });
+
+ // 빈 행이 아닌 경우만 추가
+ if (Object.values(rowData).some(value => value && value.toString().trim() !== "")) {
+ dataRows.push(rowData);
+ }
+ }
+ });
+
+ if (dataRows.length === 0) {
+ throw new Error("Excel 파일에 가져올 데이터가 없습니다.");
+ }
+
+ // 진행 상황 업데이트를 위한 콜백
+ const updateProgress = (current: number, total: number) => {
+ const percentage = Math.round((current / total) * 100);
+ setProgress(percentage);
+ };
+
+ // 벤더 데이터 처리
+ const vendors = dataRows.map(row => ({
+ vendorName: row["업체명"] || row["vendorName"] || "",
+ email: row["이메일"] || row["email"] || "",
+ taxId: row["사업자등록번호"] || row["taxId"] || "",
+ techVendorType: row["벤더타입"] || row["techVendorType"] || "",
+ address: row["주소"] || row["address"] || null,
+ country: row["국가"] || row["country"] || null,
+ phone: row["전화번호"] || row["phone"] || null,
+ website: row["웹사이트"] || row["website"] || null,
+ items: row["아이템"] || row["items"] || ""
+ }));
+
+ // 벤더 데이터 가져오기 실행
+ const result = await importTechVendorsFromExcel(vendors);
+
+ if (result.success) {
+ toast.success(`${vendors.length}개의 기술영업 벤더가 성공적으로 가져와졌습니다.`);
+ } else {
+ toast.error(result.error || "벤더 가져오기에 실패했습니다.");
+ }
+
+ // 상태 초기화 및 다이얼로그 닫기
+ setFile(null);
+ setOpen(false);
+
+ // 성공 콜백 호출
+ if (onSuccess) {
+ onSuccess();
+ }
+ } catch (error) {
+ console.error("Excel 파일 처리 중 오류 발생:", error);
+ setError(error instanceof Error ? error.message : "파일 처리 중 오류가 발생했습니다.");
+ } finally {
+ setIsUploading(false);
+ }
+ };
+
+ // 다이얼로그 열기/닫기 핸들러
+ const handleOpenChange = (newOpen: boolean) => {
+ if (!newOpen) {
+ // 닫을 때 상태 초기화
+ setFile(null);
+ setError(null);
+ setProgress(0);
+ if (fileInputRef.current) {
+ fileInputRef.current.value = "";
+ }
+ }
+ setOpen(newOpen);
+ };
+
+ return (
+ <>
+ <Button
+ variant="outline"
+ size="sm"
+ className="gap-2"
+ onClick={() => setOpen(true)}
+ disabled={isUploading}
+ >
+ <Upload className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Import</span>
+ </Button>
+
+ <Dialog open={open} onOpenChange={handleOpenChange}>
+ <DialogContent className="sm:max-w-[500px]">
+ <DialogHeader>
+ <DialogTitle>기술영업 벤더 가져오기</DialogTitle>
+ <DialogDescription>
+ 기술영업 벤더를 Excel 파일에서 가져옵니다.
+ <br />
+ 올바른 형식의 Excel 파일(.xlsx)을 업로드하세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4 py-4">
+ <div className="flex items-center gap-4">
+ <input
+ type="file"
+ ref={fileInputRef}
+ className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-foreground file:font-medium"
+ accept=".xlsx,.xls"
+ onChange={handleFileChange}
+ disabled={isUploading}
+ />
+ </div>
+
+ {file && (
+ <div className="text-sm text-muted-foreground">
+ 선택된 파일: <span className="font-medium">{file.name}</span> ({(file.size / 1024).toFixed(1)} KB)
+ </div>
+ )}
+
+ {isUploading && (
+ <div className="space-y-2">
+ <Progress value={progress} />
+ <p className="text-sm text-muted-foreground text-center">
+ {progress}% 완료
+ </p>
+ </div>
+ )}
+
+ {error && (
+ <div className="text-sm font-medium text-destructive">
+ {error}
+ </div>
+ )}
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => setOpen(false)}
+ disabled={isUploading}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={handleImport}
+ disabled={!file || isUploading}
+ >
+ {isUploading ? "처리 중..." : "가져오기"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </>
+ );
+}
\ 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<typeof useRouter>; + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TechVendor> | null>>; + router: NextRouter; +} + + + + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef<TechVendor>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<TechVendor> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef<TechVendor> = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-56"> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "update" })} + > + 레코드 편집 + </DropdownMenuItem> + + <DropdownMenuItem + onSelect={() => { + // 1) 만약 rowAction을 열고 싶다면 + // setRowAction({ row, type: "update" }) + + // 2) 자세히 보기 페이지로 클라이언트 라우팅 + router.push(`/evcp/tech-vendors/${row.original.id}/info`); + }} + > + 상세보기 + </DropdownMenuItem> + <DropdownMenuItem + onSelect={() => { + // 새창으로 열기 위해 window.open() 사용 + window.open(`/evcp/tech-vendors/${row.original.id}/info`, '_blank'); + }} + > + 상세보기(새창) + </DropdownMenuItem> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "log" })} + > + 감사 로그 보기 + </DropdownMenuItem> + + <Separator /> + <DropdownMenuSub> + <DropdownMenuSubTrigger>Status</DropdownMenuSubTrigger> + <DropdownMenuSubContent> + <DropdownMenuRadioGroup + value={row.original.status} + onValueChange={(value) => { + startUpdateTransition(() => { + toast.promise( + modifyTechVendor({ + id: String(row.original.id), + status: value as TechVendor["status"], + vendorName: row.original.vendorName, // Required field from UpdateVendorSchema + }), + { + loading: "Updating...", + success: "Label updated", + error: (err) => getErrorMessage(err), + } + ) + }) + }} + > + {techVendors.status.enumValues.map((status) => ( + <DropdownMenuRadioItem + key={status} + value={status} + className="capitalize" + disabled={isUpdatePending} + > + {status} + </DropdownMenuRadioItem> + ))} + </DropdownMenuRadioGroup> + </DropdownMenuSubContent> + </DropdownMenuSub> + + + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + } + + // ---------------------------------------------------------------- + // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 3-1) groupMap: { [groupName]: ColumnDef<TechVendor>[] } + const groupMap: Record<string, ColumnDef<TechVendor>[]> = {} + + techVendorColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef<TechVendor> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + // Status 컬럼 렌더링 개선 - 아이콘과 더 선명한 배경색 사용 + if (cfg.id === "status") { + const statusVal = row.original.status; + if (!statusVal) return null; + + // Status badge variant mapping - 더 뚜렷한 색상으로 변경 + const getStatusConfig = (status: StatusType): StatusConfig & { iconColor: string } => { + switch (status) { + case "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 ( + <div className="flex items-center gap-2"> + <Badge + variant={statusConfig.variant} + className={statusConfig.className} + > + <StatusIcon className={`mr-1 h-3.5 w-3.5 ${statusConfig.iconColor}`} /> + {displayText} + </Badge> + </div> + ); + } + + // 날짜 컬럼 포맷팅 + if (cfg.type === "date" && cell.getValue()) { + return formatDate(cell.getValue() as Date); + } + + return cell.getValue(); + }, + }; + + groupMap[groupName].push(childCol); + }); + + // 3-2) groupMap -> columns (그룹별 -> 중첩 헤더 ColumnDef[] 배열 변환) + const columns: ColumnDef<TechVendor>[] = [ + selectColumn, // 1) 체크박스 + ]; + + // 3-3) 그룹이 있는 컬럼들은 중첩 헤더로, 없는 것들은 그냥 컬럼으로 + Object.entries(groupMap).forEach(([groupName, childColumns]) => { + if (groupName === "_noGroup") { + // 그룹이 없는 컬럼들은 그냥 추가 + columns.push(...childColumns); + } else { + // 그룹이 있는 컬럼들은 헤더 아래 자식으로 중첩 + columns.push({ + id: groupName, + header: groupName, // 그룹명을 헤더로 + columns: childColumns, // 그룹에 속한 컬럼들을 자식으로 + }); + } + }); + + columns.push(actionsColumn); // 마지막에 액션 컬럼 추가 + + return columns; +}
\ No newline at end of file diff --git a/lib/tech-vendors/table/tech-vendors-table-floating-bar.tsx b/lib/tech-vendors/table/tech-vendors-table-floating-bar.tsx 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<Vendor> +} + + +export function VendorsTableFloatingBar({ table }: VendorsTableFloatingBarProps) { + const rows = table.getFilteredSelectedRowModel().rows + + const [isPending, startTransition] = React.useTransition() + const [action, setAction] = React.useState< + "update-status" | "export" | "delete" + >() + // Clear selection on Escape key press + React.useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + table.toggleAllRowsSelected(false) + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [table]) + + + + // 공용 confirm dialog state + const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false) + const [confirmProps, setConfirmProps] = React.useState<{ + title: string + description?: string + onConfirm: () => Promise<void> | void + }>({ + title: "", + description: "", + onConfirm: () => { }, + }) + + + // 2) + function handleSelectStatus(newStatus: Vendor["status"]) { + setAction("update-status") + + setConfirmProps({ + title: `Update ${rows.length} vendor${rows.length > 1 ? "s" : ""} with status: ${newStatus}?`, + description: "This action will override their current status.", + onConfirm: async () => { + startTransition(async () => { + const { error } = await modifyTechVendors({ + ids: rows.map((row) => String(row.original.id)), + status: newStatus as TechVendor["status"], + }) + if (error) { + toast.error(error) + return + } + toast.success("Vendors updated") + setConfirmDialogOpen(false) + }) + }, + }) + setConfirmDialogOpen(true) + } + + + return ( + <Portal > + <div className="fixed inset-x-0 bottom-10 z-50 mx-auto w-fit px-2.5" style={{ bottom: '1.5rem' }}> + <div className="w-full overflow-x-auto"> + <div className="mx-auto flex w-fit items-center gap-2 rounded-md border bg-background p-2 text-foreground shadow"> + <div className="flex h-7 items-center rounded-md border border-dashed pl-2.5 pr-1"> + <span className="whitespace-nowrap text-xs"> + {rows.length} selected + </span> + <Separator orientation="vertical" className="ml-2 mr-1" /> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + className="size-5 hover:border" + onClick={() => table.toggleAllRowsSelected(false)} + > + <X className="size-3.5 shrink-0" aria-hidden="true" /> + </Button> + </TooltipTrigger> + <TooltipContent className="flex items-center border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900"> + <p className="mr-2">Clear selection</p> + <Kbd abbrTitle="Escape" variant="outline"> + Esc + </Kbd> + </TooltipContent> + </Tooltip> + </div> + <Separator orientation="vertical" className="hidden h-5 sm:block" /> + <div className="flex items-center gap-1.5"> + <Select + onValueChange={(value: Vendor["status"]) => { + handleSelectStatus(value) + }} + > + <Tooltip> + <SelectTrigger asChild> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border data-[state=open]:bg-accent data-[state=open]:text-accent-foreground" + disabled={isPending} + > + {isPending && action === "update-status" ? ( + <Loader + className="size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <CheckCircle2 + className="size-3.5" + aria-hidden="true" + /> + )} + </Button> + </TooltipTrigger> + </SelectTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Update status</p> + </TooltipContent> + </Tooltip> + <SelectContent align="center"> + <SelectGroup> + {vendors.status.enumValues.map((status) => ( + <SelectItem + key={status} + value={status} + className="capitalize" + > + {status} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border" + onClick={() => { + setAction("export") + + startTransition(() => { + exportTableToExcel(table, { + excludeColumns: ["select", "actions"], + onlySelected: true, + }) + }) + }} + disabled={isPending} + > + {isPending && action === "export" ? ( + <Loader + className="size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <Download className="size-3.5" aria-hidden="true" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Export vendors</p> + </TooltipContent> + </Tooltip> + + </div> + </div> + </div> + </div> + + + {/* 공용 Confirm Dialog */} + <ActionConfirmDialog + open={confirmDialogOpen} + onOpenChange={setConfirmDialogOpen} + title={confirmProps.title} + description={confirmProps.description} + onConfirm={confirmProps.onConfirm} + isLoading={isPending && (action === "delete" || action === "update-status")} + confirmLabel={ + action === "delete" + ? "Delete" + : action === "update-status" + ? "Update" + : "Confirm" + } + confirmVariant={ + action === "delete" ? "destructive" : "default" + } + /> + </Portal> + ) +} diff --git a/lib/tech-vendors/table/tech-vendors-table-toolbar-actions.tsx b/lib/tech-vendors/table/tech-vendors-table-toolbar-actions.tsx 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<TechVendor> +} + +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 ( + <div className="flex items-center gap-2"> + {/* Import 버튼 추가 */} + <ImportTechVendorButton + onSuccess={() => { + // 성공 시 테이블 새로고침 + toast.success("업체 정보 가져오기가 완료되었습니다."); + }} + /> + + {/* Export 드롭다운 메뉴로 변경 */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + variant="outline" + size="sm" + className="gap-2" + disabled={isExporting} + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline"> + {isExporting ? "내보내는 중..." : "Export"} + </span> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + {/* 템플릿 다운로드 추가 */} + <DropdownMenuItem + onClick={() => exportTechVendorTemplate()} + disabled={isExporting} + > + <FileText className="mr-2 size-4" /> + <span>Excel 템플릿 다운로드</span> + </DropdownMenuItem> + + <DropdownMenuSeparator /> + + {/* 기본 내보내기 - 현재 테이블에 보이는 데이터만 */} + <DropdownMenuItem + onClick={() => + exportTableToExcel(table, { + filename: "vendors", + excludeColumns: ["select", "actions"], + }) + } + disabled={isExporting} + > + <FileText className="mr-2 size-4" /> + <span>현재 테이블 데이터 내보내기</span> + </DropdownMenuItem> + + <DropdownMenuSeparator /> + + {/* 선택된 벤더만 상세 내보내기 */} + <DropdownMenuItem + onClick={handleSelectedExport} + disabled={selectedVendors.length === 0 || isExporting} + > + <FileSpreadsheet className="mr-2 size-4" /> + <span>선택한 업체 상세 정보 내보내기</span> + {selectedVendors.length > 0 && ( + <span className="ml-1 text-xs text-muted-foreground">({selectedVendors.length}개)</span> + )} + </DropdownMenuItem> + + {/* 모든 필터링된 벤더 상세 내보내기 */} + <DropdownMenuItem + onClick={handleAllFilteredExport} + disabled={allFilteredVendors.length === 0 || isExporting} + > + <Download className="mr-2 size-4" /> + <span>모든 업체 상세 정보 내보내기</span> + {allFilteredVendors.length > 0 && ( + <span className="ml-1 text-xs text-muted-foreground">({allFilteredVendors.length}개)</span> + )} + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </div> + ) +}
\ No newline at end of file diff --git a/lib/tech-vendors/table/tech-vendors-table.tsx b/lib/tech-vendors/table/tech-vendors-table.tsx 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<ReturnType<typeof getTechVendors>>, + Awaited<ReturnType<typeof getTechVendorStatusCounts>> + ] + > +} + +export function TechVendorsTable({ promises }: TechVendorsTableProps) { + // Suspense로 받아온 데이터 + const [{ data, pageCount }, statusCounts] = React.use(promises) + const [isCompact, setIsCompact] = React.useState<boolean>(false) + + const [rowAction, setRowAction] = React.useState<DataTableRowAction<TechVendor> | null>(null) + + // **router** 획득 + const router = useRouter() + + // getColumns() 호출 시, router를 주입 + const columns = React.useMemo( + () => getColumns({ setRowAction, router }), + [setRowAction, router] + ) + + // 상태 한글 변환 유틸리티 함수 + const getStatusDisplay = (status: string): string => { + const statusMap: Record<string, string> = { + "PENDING_REVIEW": "가입 신청 중", + "IN_REVIEW": "심사 중", + "REJECTED": "심사 거부됨", + "ACTIVE": "활성 상태", + "INACTIVE": "비활성 상태", + "BLACKLISTED": "거래 금지" + }; + + return statusMap[status] || status; + }; + + const filterFields: DataTableFilterField<TechVendor>[] = [ + { + id: "status", + label: "상태", + options: techVendors.status.enumValues.map((status) => ({ + label: getStatusDisplay(status), + value: status, + count: statusCounts[status], + })), + }, + + { id: "vendorCode", label: "업체 코드" }, + ] + + const advancedFilterFields: DataTableAdvancedFilterField<TechVendor>[] = [ + { 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 ( + <> + <DataTable + table={table} + compact={isCompact} + // floatingBar={<TechVendorsTableFloatingBar table={table} />} + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + enableCompactToggle={true} + compactStorageKey="techVendorsTableCompact" + onCompactChange={handleCompactChange} + > + <TechVendorsTableToolbarActions table={table} /> + </DataTableAdvancedToolbar> + </DataTable> + <UpdateVendorSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + vendor={rowAction?.row.original ?? null} + /> + + {/* ViewTechVendorLogsDialog 컴포넌트는 아직 구현되지 않았습니다. + <ViewTechVendorLogsDialog + open={rowAction?.type === "log"} + onOpenChange={() => setRowAction(null)} + vendorId={rowAction?.row.original?.id ?? null} + /> */} + </> + ) +}
\ No newline at end of file diff --git a/lib/tech-vendors/table/update-vendor-sheet.tsx b/lib/tech-vendors/table/update-vendor-sheet.tsx 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<typeof Sheet> { + vendor: TechVendor | null +} +type StatusType = (typeof techVendors.status.enumValues)[number]; + +type StatusConfig = { + Icon: React.ElementType; + className: string; + label: string; +}; + +// 상태 표시 유틸리티 함수 +const getStatusConfig = (status: StatusType): StatusConfig => { + switch(status) { + case "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<UpdateTechVendorSchema>({ + resolver: zodResolver(updateTechVendorSchema), + defaultValues: { + // 업체 기본 정보 + vendorName: vendor?.vendorName ?? "", + vendorCode: vendor?.vendorCode ?? "", + address: vendor?.address ?? "", + country: vendor?.country ?? "", + phone: vendor?.phone ?? "", + email: vendor?.email ?? "", + website: vendor?.website ?? "", + 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 ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-lg overflow-y-auto"> + <SheetHeader className="text-left"> + <SheetTitle>업체 정보 수정</SheetTitle> + <SheetDescription> + 업체 세부 정보를 수정하고 변경 사항을 저장하세요 + </SheetDescription> + </SheetHeader> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6"> + {/* 업체 기본 정보 섹션 */} + <div className="space-y-4"> + <div className="flex items-center"> + <Building className="mr-2 h-5 w-5 text-muted-foreground" /> + <h3 className="text-sm font-medium">업체 기본 정보</h3> + </div> + <FormDescription> + 업체가 제공한 기본 정보입니다. 필요시 수정하세요. + </FormDescription> + <div className="grid grid-cols-1 gap-4 md:grid-cols-2"> + {/* vendorName */} + <FormField + control={form.control} + name="vendorName" + render={({ field }) => ( + <FormItem> + <FormLabel>업체명</FormLabel> + <FormControl> + <Input placeholder="업체명 입력" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* vendorCode */} + <FormField + control={form.control} + name="vendorCode" + render={({ field }) => ( + <FormItem> + <FormLabel>업체 코드</FormLabel> + <FormControl> + <Input placeholder="예: ABC123" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* address */} + <FormField + control={form.control} + name="address" + render={({ field }) => ( + <FormItem className="md:col-span-2"> + <FormLabel>주소</FormLabel> + <FormControl> + <Input placeholder="주소 입력" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* country */} + <FormField + control={form.control} + name="country" + render={({ field }) => ( + <FormItem> + <FormLabel>국가</FormLabel> + <FormControl> + <Input placeholder="예: 대한민국" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* phone */} + <FormField + control={form.control} + name="phone" + render={({ field }) => ( + <FormItem> + <FormLabel>전화번호</FormLabel> + <FormControl> + <Input placeholder="예: 010-1234-5678" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* email */} + <FormField + control={form.control} + name="email" + render={({ field }) => ( + <FormItem> + <FormLabel>이메일</FormLabel> + <FormControl> + <Input placeholder="예: info@company.com" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* website */} + <FormField + control={form.control} + name="website" + render={({ field }) => ( + <FormItem> + <FormLabel>웹사이트</FormLabel> + <FormControl> + <Input placeholder="예: https://www.company.com" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* status with icons */} + <FormField + control={form.control} + name="status" + render={({ field }) => { + // 현재 선택된 상태의 구성 정보 가져오기 + const selectedConfig = getStatusConfig(field.value ?? "ACTIVE"); + const SelectedIcon = selectedConfig?.Icon || CircleIcon; + + return ( + <FormItem> + <FormLabel>업체승인상태</FormLabel> + <FormControl> + <Select + value={field.value} + onValueChange={field.onChange} + > + <SelectTrigger className="w-full"> + <SelectValue> + {field.value && ( + <div className="flex items-center"> + <SelectedIcon className={`mr-2 h-4 w-4 ${selectedConfig.className}`} /> + <span>{selectedConfig.label}</span> + </div> + )} + </SelectValue> + </SelectTrigger> + <SelectContent> + <SelectGroup> + {techVendors.status.enumValues.map((status) => { + const config = getStatusConfig(status); + const StatusIcon = config.Icon; + return ( + <SelectItem key={status} value={status}> + <div className="flex items-center"> + <StatusIcon className={`mr-2 h-4 w-4 ${config.className}`} /> + <span>{config.label}</span> + </div> + </SelectItem> + ); + })} + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + + + + + </div> + </div> + + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <SheetClose asChild> + <Button type="button" variant="outline"> + 취소 + </Button> + </SheetClose> + <Button disabled={isPending}> + {isPending && ( + <Loader className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" /> + )} + 저장 + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ 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<void> { + if (!vendors.length) return; + + // 선택된 벤더 ID 목록 + const vendorIds = vendors.map(vendor => vendor.id); + + try { + // 서버로부터 모든 관련 데이터 가져오기 + const vendorsWithDetails = await exportTechVendorDetails(vendorIds); + + if (!vendorsWithDetails.length) { + throw new Error("내보내기 데이터를 가져오는 중 오류가 발생했습니다."); + } + + // 워크북 생성 + const workbook = new ExcelJS.Workbook(); + + // 데이터 타입 확인 (서비스에서 반환하는 실제 데이터 형태) + const vendorData = vendorsWithDetails as unknown as any[]; + + // ===== 1. 기본 정보 시트 ===== + createBasicInfoSheet(workbook, vendorData); + + // ===== 2. 연락처 시트 ===== + createContactsSheet(workbook, vendorData); + + // ===== 3. 아이템 시트 ===== + createItemsSheet(workbook, vendorData); + + + // 파일 다운로드 + const buffer = await workbook.xlsx.writeBuffer(); + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `${filename}-${new Date().toISOString().split("T")[0]}.xlsx`; + link.click(); + URL.revokeObjectURL(url); + + return; + } catch (error) { + console.error("Export error:", error); + throw error; + } +} + +// 기본 정보 시트 생성 함수 +function createBasicInfoSheet( + workbook: ExcelJS.Workbook, + vendors: TechVendor[] +): void { + const basicInfoSheet = workbook.addWorksheet("기본정보"); + + // 기본 정보 시트 헤더 설정 + basicInfoSheet.columns = [ + { header: "업체코드", key: "vendorCode", width: 15 }, + { header: "업체명", key: "vendorName", width: 20 }, + { header: "세금ID", key: "taxId", width: 15 }, + { header: "국가", key: "country", width: 10 }, + { header: "상태", key: "status", width: 15 }, + { header: "이메일", key: "email", width: 20 }, + { header: "전화번호", key: "phone", width: 15 }, + { header: "웹사이트", key: "website", width: 20 }, + { header: "주소", key: "address", width: 30 }, + { header: "대표자명", key: "representativeName", width: 15 }, + { header: "생성일", key: "createdAt", width: 15 }, + ]; + + // 헤더 스타일 설정 + 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<string, string> = { + "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<TechVendor>().withDefault([ + { id: "createdAt", desc: true }, // createdAt 기준 내림차순 + ]), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 검색 키워드 + search: parseAsString.withDefault(""), + + // ----------------------------------------------------------------- + // 기술영업 협력업체에 특화된 검색 필드 + // ----------------------------------------------------------------- + // 상태 (ACTIVE, INACTIVE, BLACKLISTED 등) 중에서 선택 + status: parseAsStringEnum(["ACTIVE", "INACTIVE", "BLACKLISTED", "PENDING_REVIEW", "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<TechVendorContact>().withDefault([ + { id: "createdAt", desc: true }, // createdAt 기준 내림차순 + ]), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 검색 키워드 + search: parseAsString.withDefault(""), + + // 특정 필드 검색 + contactName: parseAsString.withDefault(""), + contactPosition: parseAsString.withDefault(""), + contactEmail: parseAsString.withDefault(""), + contactPhone: parseAsString.withDefault(""), +}); + +export const searchParamsItemCache = createSearchParamsCache({ + // 공통 플래그 + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + + // 페이징 + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 정렬 + sort: getSortingStateParser<TechVendorItemsView>().withDefault([ + { id: "createdAt", desc: true }, // createdAt 기준 내림차순 + ]), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 검색 키워드 + search: parseAsString.withDefault(""), + + // 특정 필드 검색 + itemName: parseAsString.withDefault(""), + itemCode: parseAsString.withDefault(""), +}); + +// 기술영업 벤더 기본 정보 업데이트 스키마 +export const updateTechVendorSchema = z.object({ + vendorName: z.string().min(1, "업체명은 필수 입력사항입니다"), + vendorCode: z.string().optional(), + address: z.string().optional(), + country: z.string().optional(), + phone: z.string().optional(), + email: z.string().email("유효한 이메일 주소를 입력해주세요").optional(), + website: z.string().url("유효한 URL을 입력해주세요").optional(), + 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<ReturnType<typeof searchParamsCache.parse>> +export type GetTechVendorContactsSchema = Awaited<ReturnType<typeof searchParamsContactCache.parse>> +export type GetTechVendorItemsSchema = Awaited<ReturnType<typeof searchParamsItemCache.parse>> + +export type UpdateTechVendorSchema = z.infer<typeof updateTechVendorSchema> +export type CreateTechVendorSchema = z.infer<typeof createTechVendorSchema> +export type CreateTechVendorContactSchema = z.infer<typeof createTechVendorContactSchema> +export type UpdateTechVendorContactSchema = z.infer<typeof updateTechVendorContactSchema> +export type CreateTechVendorItemSchema = z.infer<typeof createTechVendorItemSchema> +export type UpdateTechVendorItemSchema = z.infer<typeof updateTechVendorItemSchema>
\ No newline at end of file |
