From 96ba777cda69af8caf3a6e0e8bfc1aca5016fe58 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 23 Jun 2025 08:59:54 +0000 Subject: (최겸) 기술영업 해양 프로젝트 AVL 개발 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../table/accepted-quotations-table-columns.tsx | 324 +++++++++++++++++++++ .../accepted-quotations-table-toolbar-actions.tsx | 51 ++++ .../table/accepted-quotations-table.tsx | 117 ++++++++ lib/tech-project-avl/validations.ts | 41 +++ 4 files changed, 533 insertions(+) create mode 100644 lib/tech-project-avl/table/accepted-quotations-table-columns.tsx create mode 100644 lib/tech-project-avl/table/accepted-quotations-table-toolbar-actions.tsx create mode 100644 lib/tech-project-avl/table/accepted-quotations-table.tsx create mode 100644 lib/tech-project-avl/validations.ts (limited to 'lib') diff --git a/lib/tech-project-avl/table/accepted-quotations-table-columns.tsx b/lib/tech-project-avl/table/accepted-quotations-table-columns.tsx new file mode 100644 index 00000000..68a61f0a --- /dev/null +++ b/lib/tech-project-avl/table/accepted-quotations-table-columns.tsx @@ -0,0 +1,324 @@ +"use client" + +import * as React from "react" +import { ColumnDef } from "@tanstack/react-table" +import { formatDate } from "@/lib/utils" +import { Checkbox } from "@/components/ui/checkbox" +import { Badge } from "@/components/ui/badge" +import { +} from "@/components/ui/dropdown-menu" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" + +// Accepted Quotation 타입 정의 +export interface AcceptedQuotationItem { + id: number + rfqId: number + vendorId: number + quotationCode: string | null + quotationVersion: number | null + totalPrice: string | null + currency: string | null + validUntil: Date | null + status: string + remark: string | null + submittedAt: Date | null + acceptedAt: Date | null + createdAt: Date + updatedAt: Date + + // RFQ 정보 + rfqCode: string | null + rfqType: string | null + description: string | null + dueDate: Date | null + rfqStatus: string | null + materialCode: string | null + + // Vendor 정보 + vendorName: string + vendorCode: string | null + vendorEmail: string | null + vendorCountry: string | null + + // Project 정보 + projNm: string | null + pspid: string | null + sector: string | null +} + +export function getColumns(): ColumnDef[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef = { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + // const actionsColumn: ColumnDef = { + // id: "actions", + // cell: ({ row }) => ( + // + // + // + // + // + // setRowAction({ row, type: "open" })} + // > + // 견적서 보기 + // + // + // + // ), + // size: 40, + // enableSorting: false, + // enableHiding: false, + // } + + // ---------------------------------------------------------------- + // 3) 데이터 컬럼들 정의 + // ---------------------------------------------------------------- + const dataColumns: ColumnDef[] = [ + { + accessorKey: "rfqCode", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.original.rfqCode || "-"} +
+ ), + enableSorting: true, + enableHiding: true, + meta: { + excelHeader: "RFQ 코드", + }, + }, + { + accessorKey: "description", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.original.description || "-"} +
+ ), + enableSorting: true, + enableHiding: true, + meta: { + excelHeader: "RFQ 설명", + }, + }, + { + accessorKey: "rfqType", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.original.rfqType || "-"} +
+ ), + enableSorting: true, + }, + { + accessorKey: "vendorName", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.original.vendorName} +
+ ), + enableSorting: true, + enableHiding: true, + meta: { + excelHeader: "업체명", + }, + }, + { + accessorKey: "vendorCode", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.original.vendorCode || "-"} +
+ ), + enableSorting: true, + enableHiding: true, + meta: { + excelHeader: "업체 코드", + }, + }, + { + accessorKey: "quotationCode", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.original.quotationCode || "-"} +
+ ), + enableSorting: true, + enableHiding: true, + meta: { + excelHeader: "견적서 코드", + }, + }, + { + accessorKey: "totalPrice", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const price = row.original.totalPrice; + const currency = row.original.currency || "USD"; + return ( +
+ {price ? `${Number(price).toLocaleString()} ${currency}` : "-"} +
+ ); + }, + enableSorting: true, + enableHiding: true, + meta: { + excelHeader: "총 금액", + }, + }, + { + accessorKey: "status", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.status} + + ), + enableSorting: true, + enableHiding: true, + meta: { + excelHeader: "상태", + }, + }, + { + accessorKey: "projNm", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.original.projNm || "-"} +
+ ), + enableSorting: true, + enableHiding: true, + meta: { + excelHeader: "프로젝트명", + }, + }, + { + accessorKey: "materialCode", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.original.materialCode || "-"} +
+ ), + enableSorting: true, + enableHiding: true, + meta: { + excelHeader: "자재 코드", + }, + }, + { + accessorKey: "vendorCountry", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.original.vendorCountry || "-"} +
+ ), + enableSorting: true, + enableHiding: true, + meta: { + excelHeader: "국가", + }, + }, + { + accessorKey: "dueDate", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.original.dueDate ? formatDate(row.original.dueDate) : "-"} +
+ ), + enableSorting: true, + enableHiding: true, + meta: { + excelHeader: "마감일", + }, + }, + { + accessorKey: "acceptedAt", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.original.acceptedAt ? formatDate(row.original.acceptedAt) : "-"} +
+ ), + enableSorting: true, + enableHiding: true, + meta: { + excelHeader: "승인일", + }, + }, + ] + + return [selectColumn, ...dataColumns] +} \ No newline at end of file diff --git a/lib/tech-project-avl/table/accepted-quotations-table-toolbar-actions.tsx b/lib/tech-project-avl/table/accepted-quotations-table-toolbar-actions.tsx new file mode 100644 index 00000000..ae9aea60 --- /dev/null +++ b/lib/tech-project-avl/table/accepted-quotations-table-toolbar-actions.tsx @@ -0,0 +1,51 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, RefreshCcw } from "lucide-react" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { AcceptedQuotationItem } from "./accepted-quotations-table-columns" + +interface AcceptedQuotationsTableToolbarActionsProps { + table: Table + onRefresh?: () => void +} + +export function AcceptedQuotationsTableToolbarActions({ + table, + onRefresh +}: AcceptedQuotationsTableToolbarActionsProps) { + + return ( +
+ + + {onRefresh && ( + + )} +
+ ) +} \ No newline at end of file diff --git a/lib/tech-project-avl/table/accepted-quotations-table.tsx b/lib/tech-project-avl/table/accepted-quotations-table.tsx new file mode 100644 index 00000000..da33d0d5 --- /dev/null +++ b/lib/tech-project-avl/table/accepted-quotations-table.tsx @@ -0,0 +1,117 @@ +"use client" + +import * as React from "react" +import { type DataTableAdvancedFilterField } 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, type AcceptedQuotationItem } from "./accepted-quotations-table-columns" +import { AcceptedQuotationsTableToolbarActions } from "./accepted-quotations-table-toolbar-actions" + +interface AcceptedQuotationsTableProps { + data: AcceptedQuotationItem[] + pageCount: number + onRefresh?: () => void +} + +export function AcceptedQuotationsTable({ + data, + pageCount, + onRefresh, +}: AcceptedQuotationsTableProps) { + + // 필터 필드 정의 + const filterFields: DataTableAdvancedFilterField[] = [ + { + id: "rfqCode", + label: "RFQ 코드", + type: "text", + placeholder: "RFQ 코드로 필터...", + }, + { + id: "vendorName", + label: "업체명", + type: "text", + placeholder: "업체명으로 필터...", + }, + { + id: "vendorCode", + label: "업체 코드", + type: "text", + placeholder: "업체 코드로 필터...", + }, + { + id: "projNm", + label: "프로젝트명", + type: "text", + placeholder: "프로젝트명으로 필터...", + }, + { + id: "vendorCountry", + label: "국가", + type: "text", + placeholder: "국가로 필터...", + }, + { + id: "currency", + label: "통화", + type: "select", + options: [ + { label: "USD", value: "USD" }, + { label: "EUR", value: "EUR" }, + { label: "KRW", value: "KRW" }, + { label: "JPY", value: "JPY" }, + { label: "CNY", value: "CNY" }, + ], + }, + { + id: "rfqType", + label: "RFQ 타입", + type: "select", + options: [ + { label: "TOP", value: "TOP" }, + { label: "HULL", value: "HULL" }, + ], + }, + { + id: "dueDate", + label: "마감일", + type: "date", + }, + { + id: "acceptedAt", + label: "승인일", + type: "date", + }, + ] + + const columns = React.useMemo( + () => getColumns(), + [] + ) + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + initialState: { + sorting: [{ id: "acceptedAt", desc: true }], + columnPinning: { left: ["select"] }, + }, + getRowId: (originalRow) => `${originalRow.id}`, + }) + + return ( +
+ + + + +
+ ) +} \ No newline at end of file diff --git a/lib/tech-project-avl/validations.ts b/lib/tech-project-avl/validations.ts new file mode 100644 index 00000000..3e08b641 --- /dev/null +++ b/lib/tech-project-avl/validations.ts @@ -0,0 +1,41 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { AcceptedQuotationItem } from "./table/accepted-quotations-table-columns" + +export const searchParamsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser().withDefault([ + { id: "acceptedAt", desc: true }, + ]), + + // 검색 필드 + rfqCode: parseAsString.withDefault(""), + vendorName: parseAsString.withDefault(""), + vendorCode: parseAsString.withDefault(""), + projNm: parseAsString.withDefault(""), + + // 필터 필드 + rfqType: parseAsStringEnum(["SHIP", "TOP", "HULL"]), + currency: parseAsStringEnum(["USD", "EUR", "KRW", "JPY", "CNY"]), + + // 날짜 범위 + from: parseAsString.withDefault(""), + to: parseAsString.withDefault(""), + + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), +}) \ No newline at end of file -- cgit v1.2.3 From a75541e1a1aea596bfca2a435f39133b9b72f193 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 23 Jun 2025 09:00:56 +0000 Subject: (최겸) 기술영업 벤더 후보관리 개발 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../evcp/(evcp)/tech-vendor-candidates/page.tsx | 78 ++++ lib/tech-vendor-candidates/service.ts | 395 +++++++++++++++++++ .../table/add-candidates-dialog.tsx | 394 +++++++++++++++++++ .../table/candidates-table-columns.tsx | 199 ++++++++++ .../table/candidates-table-floating-bar.tsx | 395 +++++++++++++++++++ .../table/candidates-table-toolbar-actions.tsx | 93 +++++ .../table/candidates-table.tsx | 173 ++++++++ .../table/delete-candidates-dialog.tsx | 159 ++++++++ .../table/excel-template-download.tsx | 128 ++++++ .../table/feature-flags-provider.tsx | 108 +++++ lib/tech-vendor-candidates/table/feature-flags.tsx | 96 +++++ lib/tech-vendor-candidates/table/import-button.tsx | 233 +++++++++++ .../table/invite-candidates-dialog.tsx | 230 +++++++++++ .../table/update-candidate-sheet.tsx | 437 +++++++++++++++++++++ lib/tech-vendor-candidates/utils.ts | 40 ++ lib/tech-vendor-candidates/validations.ts | 148 +++++++ 16 files changed, 3306 insertions(+) create mode 100644 app/[lng]/evcp/(evcp)/tech-vendor-candidates/page.tsx create mode 100644 lib/tech-vendor-candidates/service.ts create mode 100644 lib/tech-vendor-candidates/table/add-candidates-dialog.tsx create mode 100644 lib/tech-vendor-candidates/table/candidates-table-columns.tsx create mode 100644 lib/tech-vendor-candidates/table/candidates-table-floating-bar.tsx create mode 100644 lib/tech-vendor-candidates/table/candidates-table-toolbar-actions.tsx create mode 100644 lib/tech-vendor-candidates/table/candidates-table.tsx create mode 100644 lib/tech-vendor-candidates/table/delete-candidates-dialog.tsx create mode 100644 lib/tech-vendor-candidates/table/excel-template-download.tsx create mode 100644 lib/tech-vendor-candidates/table/feature-flags-provider.tsx create mode 100644 lib/tech-vendor-candidates/table/feature-flags.tsx create mode 100644 lib/tech-vendor-candidates/table/import-button.tsx create mode 100644 lib/tech-vendor-candidates/table/invite-candidates-dialog.tsx create mode 100644 lib/tech-vendor-candidates/table/update-candidate-sheet.tsx create mode 100644 lib/tech-vendor-candidates/utils.ts create mode 100644 lib/tech-vendor-candidates/validations.ts (limited to 'lib') diff --git a/app/[lng]/evcp/(evcp)/tech-vendor-candidates/page.tsx b/app/[lng]/evcp/(evcp)/tech-vendor-candidates/page.tsx new file mode 100644 index 00000000..3923863a --- /dev/null +++ b/app/[lng]/evcp/(evcp)/tech-vendor-candidates/page.tsx @@ -0,0 +1,78 @@ +import * as React from "react" +import { type SearchParams } from "@/types/table" + +import { getValidFilters } from "@/lib/data-table" +import { Skeleton } from "@/components/ui/skeleton" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" +import { Shell } from "@/components/shell" + +import { getVendorCandidateCounts, getVendorCandidates } from "@/lib/tech-vendor-candidates/service" +import { searchParamsTechCandidateCache } from "@/lib/tech-vendor-candidates/validations" +import { VendorCandidateTable as TechVendorCandidateTable } from "@/lib/tech-vendor-candidates/table/candidates-table" +import { DateRangePicker } from "@/components/date-range-picker" + +interface IndexPageProps { + searchParams: Promise +} + +export default async function IndexPage(props: IndexPageProps) { + const searchParams = await props.searchParams + const search = searchParamsTechCandidateCache.parse(searchParams) + + const validFilters = getValidFilters(search.filters) + + const promises = Promise.all([ + getVendorCandidates({ + ...search, + filters: validFilters, + }), + getVendorCandidateCounts() + ]) + + return ( + + +
+
+
+

+ Vendor Candidates Management +

+

+ 수집한 협력업체 후보를 등록하고 초대 메일을 송부할 수 있습니다. +

+
+
+
+ + {/* 수집일 라벨과 DateRangePicker를 함께 배치 */} +
+ {/* 수집일 기간 설정: */} + }> + + +
+ + + } + > + + +
+ ) +} \ No newline at end of file diff --git a/lib/tech-vendor-candidates/service.ts b/lib/tech-vendor-candidates/service.ts new file mode 100644 index 00000000..47832236 --- /dev/null +++ b/lib/tech-vendor-candidates/service.ts @@ -0,0 +1,395 @@ +"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) + +import { asc, desc, ilike, inArray, and, or, gte, lte, eq, count } from "drizzle-orm"; +import { revalidateTag } from "next/cache"; +import { filterColumns } from "@/lib/filter-columns"; +import { unstable_cache } from "@/lib/unstable-cache"; +import { getErrorMessage } from "@/lib/handle-error"; +import db from "@/db/db"; +import { sendEmail } from "../mail/sendEmail"; +import { CreateVendorCandidateSchema, createVendorCandidateSchema, GetTechVendorsCandidateSchema, RemoveTechCandidatesInput, removeTechCandidatesSchema, updateVendorCandidateSchema, UpdateVendorCandidateSchema } from "./validations"; +import { PgTransaction } from "drizzle-orm/pg-core"; +import { techVendorCandidates, techVendorCandidatesWithVendorInfo } from "@/db/schema/techVendors"; +import { headers } from 'next/headers'; + +export async function getVendorCandidates(input: GetTechVendorsCandidateSchema) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage + const fromDate = input.from ? new Date(input.from) : undefined; + const toDate = input.to ? new Date(input.to) : undefined; + + // 1) Advanced filters + const advancedWhere = filterColumns({ + table: techVendorCandidatesWithVendorInfo, + filters: input.filters, + joinOperator: input.joinOperator, + }) + + // 2) Global search + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or( + ilike(techVendorCandidatesWithVendorInfo.companyName, s), + ilike(techVendorCandidatesWithVendorInfo.contactEmail, s), + ilike(techVendorCandidatesWithVendorInfo.contactPhone, s), + ilike(techVendorCandidatesWithVendorInfo.country, s), + ilike(techVendorCandidatesWithVendorInfo.source, s), + ilike(techVendorCandidatesWithVendorInfo.status, s), + ilike(techVendorCandidatesWithVendorInfo.taxId, s), + ilike(techVendorCandidatesWithVendorInfo.items, s), + ilike(techVendorCandidatesWithVendorInfo.remark, s), + ilike(techVendorCandidatesWithVendorInfo.address, s), + // etc. + ) + } + + // 3) Combine finalWhere + const finalWhere = and( + advancedWhere, + globalWhere, + fromDate ? gte(techVendorCandidatesWithVendorInfo.createdAt, fromDate) : undefined, + toDate ? lte(techVendorCandidatesWithVendorInfo.createdAt, toDate) : undefined + ) + + // 5) Sorting + const orderBy = + input.sort && input.sort.length > 0 + ? input.sort.map((item) => + item.desc + ? desc(techVendorCandidatesWithVendorInfo[item.id]) + : asc(techVendorCandidatesWithVendorInfo[item.id]) + ) + : [desc(techVendorCandidatesWithVendorInfo.createdAt)] + + // 6) Query & count + const { data, total } = await db.transaction(async (tx) => { + // a) Select from the view + const candidatesData = await tx + .select() + .from(techVendorCandidatesWithVendorInfo) + .where(finalWhere) + .orderBy(...orderBy) + .offset(offset) + .limit(input.perPage) + + // b) Count total + const resCount = await tx + .select({ count: count() }) + .from(techVendorCandidatesWithVendorInfo) + .where(finalWhere) + + return { data: candidatesData, total: resCount[0]?.count } + }) + + // 7) Calculate pageCount + const pageCount = Math.ceil(total / input.perPage) + + return { data, pageCount } + } catch (err) { + console.error(err) + return { data: [], pageCount: 0 } + } + }, + // Cache key + [JSON.stringify(input)], + { + revalidate: 3600, + tags: ["tech-vendor-candidates"], + } + )() +} + +export async function createVendorCandidate(input: CreateVendorCandidateSchema) { + try { + // Validate input + const validated = createVendorCandidateSchema.parse(input); + + // 트랜잭션으로 데이터 삽입 + const result = await db.transaction(async (tx) => { + // Insert into database + const [newCandidate] = await tx + .insert(techVendorCandidates) + .values({ + companyName: validated.companyName, + contactEmail: validated.contactEmail, + contactPhone: validated.contactPhone || null, + taxId: validated.taxId || "", + address: validated.address || null, + country: validated.country || null, + source: validated.source || null, + status: validated.status || "COLLECTED", + remark: validated.remark || null, + items: validated.items || "", // items가 필수 필드이므로 빈 문자열이라도 제공 + vendorId: validated.vendorId || null, + updatedAt: new Date(), + }) + .returning(); + + return newCandidate; + }); + + // Invalidate cache + revalidateTag("tech-vendor-candidates"); + + return { success: true, data: result }; + } catch (error) { + console.error("Failed to create tech vendor candidate:", error); + return { success: false, error: getErrorMessage(error) }; + } +} + + +// Helper function to group tech vendor candidates by status +async function groupVendorCandidatesByStatus(tx: PgTransaction, Record, Record>) { + return tx + .select({ + status: techVendorCandidates.status, + count: count(), + }) + .from(techVendorCandidates) + .groupBy(techVendorCandidates.status); +} + +/** + * Get count of tech vendor candidates grouped by status + */ +export async function getVendorCandidateCounts() { + return unstable_cache( + async () => { + try { + // Initialize counts object with all possible statuses set to 0 + const initial: Record<"COLLECTED" | "INVITED" | "DISCARDED", number> = { + COLLECTED: 0, + INVITED: 0, + DISCARDED: 0, + }; + + // Execute query within transaction and transform results + const result = await db.transaction(async (tx) => { + const rows = await groupVendorCandidatesByStatus(tx); + return rows.reduce>((acc, { status, count }) => { + if (status in acc) { + acc[status] = count; + } + return acc; + }, initial); + }); + + return result; + } catch (err) { + console.error("Failed to get tech vendor candidate counts:", err); + return { + COLLECTED: 0, + INVITED: 0, + DISCARDED: 0, + }; + } + }, + ["tech-vendor-candidate-status-counts"], // Cache key + { + revalidate: 3600, // Revalidate every hour + } + )(); +} + + +/** + * Update a vendor candidate + */ +export async function updateVendorCandidate(input: UpdateVendorCandidateSchema) { + try { + // Validate input + const validated = updateVendorCandidateSchema.parse(input); + + // Prepare update data (excluding id) + const { id, ...updateData } = validated; + + const headersList = await headers(); + const host = headersList.get('host') || 'localhost:3000'; + + const baseUrl = `http://${host}` + + // Add updatedAt timestamp + const dataToUpdate = { + ...updateData, + updatedAt: new Date(), + }; + + const result = await db.transaction(async (tx) => { + // 현재 데이터 조회 (상태 변경 감지를 위해) + const [existingCandidate] = await tx + .select() + .from(techVendorCandidates) + .where(eq(techVendorCandidates.id, id)); + + if (!existingCandidate) { + throw new Error("Tech vendor candidate not found"); + } + + // Update database + const [updatedCandidate] = await tx + .update(techVendorCandidates) + .set(dataToUpdate) + .where(eq(techVendorCandidates.id, id)) + .returning(); + + // 로그 작성 + const statusChanged = + updateData.status && + existingCandidate.status !== updateData.status; + + // If status was updated to "INVITED", send email + if (statusChanged && updateData.status === "INVITED" && updatedCandidate.contactEmail) { + await sendEmail({ + to: updatedCandidate.contactEmail, + subject: "Invitation to Register as a Vendor", + template: "vendor-invitation", + context: { + companyName: updatedCandidate.companyName, + language: "en", + registrationLink: `${baseUrl}/en/partners`, + } + }); + } + + return updatedCandidate; + }); + + // Invalidate cache + revalidateTag("vendor-candidates"); + + return { success: true, data: result }; + } catch (error) { + console.error("Failed to update vendor candidate:", error); + return { success: false, error: getErrorMessage(error) }; + } +} + +export async function bulkUpdateVendorCandidateStatus({ + ids, + status, +}: { + ids: number[], + status: "COLLECTED" | "INVITED" | "DISCARDED", +}) { + try { + // Validate inputs + if (!ids.length) { + return { success: false, error: "No IDs provided" }; + } + + if (!["COLLECTED", "INVITED", "DISCARDED"].includes(status)) { + return { success: false, error: "Invalid status" }; + } + + const headersList = await headers(); + const host = headersList.get('host') || 'localhost:3000'; + + const baseUrl = `http://${host}` + + const result = await db.transaction(async (tx) => { + // Update all records + const updatedCandidates = await tx + .update(techVendorCandidates) + .set({ + status, + updatedAt: new Date(), + }) + .where(inArray(techVendorCandidates.id, ids)) + .returning(); + + // If status is "INVITED", send emails to all updated candidates + if (status === "INVITED") { + const emailPromises = updatedCandidates + .filter(candidate => candidate.contactEmail) + .map(async (candidate) => { + await sendEmail({ + to: candidate.contactEmail!, + subject: "Invitation to Register as a Vendor", + template: "vendor-invitation", + context: { + companyName: candidate.companyName, + language: "en", + registrationLink: `${baseUrl}/en/partners`, + } + }); + }); + + // Wait for all emails to be sent + await Promise.all(emailPromises); + } + + return updatedCandidates; + }); + + // Invalidate cache + revalidateTag("vendor-candidates"); + + return { + success: true, + data: result, + count: result.length + }; + } catch (error) { + console.error("Failed to bulk update vendor candidates:", error); + return { success: false, error: getErrorMessage(error) }; + } +} + +// 4. 후보자 삭제 함수 업데이트 +export async function removeCandidates(input: RemoveTechCandidatesInput) { + try { + // Validate input + const validated = removeTechCandidatesSchema.parse(input); + + const result = await db.transaction(async (tx) => { + // Get candidates before deletion (for logging purposes) + const candidatesBeforeDelete = await tx + .select() + .from(techVendorCandidates) + .where(inArray(techVendorCandidates.id, validated.ids)); + + // Delete the candidates + const deletedCandidates = await tx + .delete(techVendorCandidates) + .where(inArray(techVendorCandidates.id, validated.ids)) + .returning({ id: techVendorCandidates.id }); + + return { + deletedCandidates, + candidatesBeforeDelete + }; + }); + + // If no candidates were deleted, return an error + if (!result.deletedCandidates.length) { + return { + success: false, + error: "No candidates were found with the provided IDs", + }; + } + + // Log deletion for audit purposes + console.log( + `Deleted ${result.deletedCandidates.length} vendor candidates:`, + result.candidatesBeforeDelete.map(c => `${c.id} (${c.companyName})`) + ); + + // Invalidate cache + revalidateTag("vendor-candidates"); + revalidateTag("vendor-candidate-status-counts"); + revalidateTag("vendor-candidate-total-count"); + + return { + success: true, + count: result.deletedCandidates.length, + deletedIds: result.deletedCandidates.map(c => c.id), + }; + } catch (error) { + console.error("Failed to remove vendor candidates:", error); + return { success: false, error: getErrorMessage(error) }; + } +} \ No newline at end of file diff --git a/lib/tech-vendor-candidates/table/add-candidates-dialog.tsx b/lib/tech-vendor-candidates/table/add-candidates-dialog.tsx new file mode 100644 index 00000000..31c39137 --- /dev/null +++ b/lib/tech-vendor-candidates/table/add-candidates-dialog.tsx @@ -0,0 +1,394 @@ +"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 i18nIsoCountries from "i18n-iso-countries" +import enLocale from "i18n-iso-countries/langs/en.json" +import koLocale from "i18n-iso-countries/langs/ko.json" +import { cn } from "@/lib/utils" +import { useSession } from "next-auth/react" // next-auth 세션 훅 추가 + +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 { Textarea } from "@/components/ui/textarea" +import { useToast } from "@/hooks/use-toast" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" + +// react-hook-form + shadcn/ui Form +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" + + +import { createVendorCandidateSchema, CreateVendorCandidateSchema } from "../validations" +import { createVendorCandidate } from "../service" + +// Register locales for countries +i18nIsoCountries.registerLocale(enLocale) +i18nIsoCountries.registerLocale(koLocale) + +// Generate country array +const locale = "ko" +const countryMap = i18nIsoCountries.getNames(locale, { select: "official" }) +const countryArray = Object.entries(countryMap).map(([code, label]) => ({ + code, + label, +})) + +export function AddCandidateDialog() { + const [open, setOpen] = React.useState(false) + const [isSubmitting, setIsSubmitting] = React.useState(false) + const { toast } = useToast() + const { data: session, status } = useSession() + + // react-hook-form 세팅 + const form = useForm({ + resolver: zodResolver(createVendorCandidateSchema), + defaultValues: { + companyName: "", + contactEmail: "", // 필수 입력값 + contactPhone: "", + taxId: "", + address: "", + country: "", + source: "", + items: "", + remark: "", + status: "COLLECTED", + }, + }); + + async function onSubmit(data: CreateVendorCandidateSchema) { + setIsSubmitting(true) + try { + // 세션 유효성 검사 + if (!session || !session.user || !session.user.id) { + toast({ + title: "인증 오류", + description: "로그인 정보를 찾을 수 없습니다. 다시 로그인해주세요.", + variant: "destructive", + }) + return + } + + // userId 추출 (세션 구조에 따라 조정 필요) + const userId = session.user.id + + const result = await createVendorCandidate(data, Number(userId)) + if (result.error) { + toast({ + title: "오류 발생", + description: result.error, + variant: "destructive", + }) + return + } + // 성공 시 모달 닫고 폼 리셋 + toast({ + title: "등록 완료", + description: "협력업체 후보가 성공적으로 등록되었습니다.", + }) + form.reset() + setOpen(false) + } catch (error) { + console.error("Failed to create vendor candidate:", error) + toast({ + title: "오류 발생", + description: "예상치 못한 오류가 발생했습니다.", + variant: "destructive", + }) + } finally { + setIsSubmitting(false) + } + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset() + } + setOpen(nextOpen) + } + + return ( + + {/* 모달을 열기 위한 버튼 */} + + + + + + + Create New Vendor Candidate + + 새 Vendor Candidate 정보를 입력하고 Create 버튼을 누르세요. + + + + {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */} +
+ +
+ {/* Company Name 필드 */} + ( + + Company Name * + + + + + + )} + /> + + {/* Tax ID 필드 (새로 추가) */} + ( + + Tax ID + + + + + + )} + /> + + {/* Contact Email 필드 */} + ( + + Contact Email* + + + + + + )} + /> + + {/* Contact Phone 필드 */} + ( + + Contact Phone + + + + + + )} + /> + + {/* Address 필드 */} + ( + + Address + + + + + + )} + /> + + {/* Country 필드 */} + { + const selectedCountry = countryArray.find( + (c) => c.code === field.value + ) + return ( + + Country + + + + + + + + + + + No country found. + + {countryArray.map((country) => ( + + field.onChange(country.code) + } + > + + {country.label} + + ))} + + + + + + + + ) + }} + /> + + {/* Source 필드 */} + ( + + Source * + + + + + + )} + /> + + + {/* Items 필드 (새로 추가) */} + ( + + Items * + +