diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-23 09:00:56 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-23 09:00:56 +0000 |
| commit | a75541e1a1aea596bfca2a435f39133b9b72f193 (patch) | |
| tree | 1d78b9ea8f91416d8fc21602b245c7050c4e895e | |
| parent | 96ba777cda69af8caf3a6e0e8bfc1aca5016fe58 (diff) | |
(최겸) 기술영업 벤더 후보관리 개발
16 files changed, 3306 insertions, 0 deletions
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<SearchParams> +} + +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 ( + <Shell className="gap-2"> + + <div className="flex items-center justify-between space-y-2"> + <div className="flex items-center justify-between space-y-2"> + <div> + <h2 className="text-2xl font-bold tracking-tight"> + Vendor Candidates Management + </h2> + <p className="text-muted-foreground"> + 수집한 협력업체 후보를 등록하고 초대 메일을 송부할 수 있습니다. + </p> + </div> + </div> + </div> + + {/* 수집일 라벨과 DateRangePicker를 함께 배치 */} + <div className="flex items-center justify-start gap-2"> + {/* <span className="text-sm font-medium">수집일 기간 설정: </span> */} + <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> + <DateRangePicker + triggerSize="sm" + triggerClassName="w-56 sm:w-60" + align="end" + shallow={false} + showClearButton={true} + placeholder="수집일 날짜 범위를 고르세요" + /> + </React.Suspense> + </div> + + <React.Suspense + fallback={ + <DataTableSkeleton + columnCount={6} + searchableColumnCount={1} + filterableColumnCount={2} + cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} + shrinkZero + /> + } + > + <TechVendorCandidateTable promises={promises}/> + </React.Suspense> + </Shell> + ) +}
\ 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<string, never>, Record<string, never>, Record<string, never>>) { + 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<Record<string, number>>((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<CreateVendorCandidateSchema>({ + 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 ( + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + {/* 모달을 열기 위한 버튼 */} + <DialogTrigger asChild> + <Button variant="default" size="sm"> + Add Vendor Candidate + </Button> + </DialogTrigger> + + <DialogContent className="sm:max-w-[525px]"> + <DialogHeader> + <DialogTitle>Create New Vendor Candidate</DialogTitle> + <DialogDescription> + 새 Vendor Candidate 정보를 입력하고 <b>Create</b> 버튼을 누르세요. + </DialogDescription> + </DialogHeader> + + {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */} + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + {/* Company Name 필드 */} + <FormField + control={form.control} + name="companyName" + render={({ field }) => ( + <FormItem> + <FormLabel>Company Name <span className="text-red-500">*</span></FormLabel> + <FormControl> + <Input + placeholder="Enter company name" + {...field} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Tax ID 필드 (새로 추가) */} + <FormField + control={form.control} + name="taxId" + render={({ field }) => ( + <FormItem> + <FormLabel>Tax ID</FormLabel> + <FormControl> + <Input + placeholder="Tax identification number" + {...field} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Contact Email 필드 */} + <FormField + control={form.control} + name="contactEmail" + render={({ field }) => ( + <FormItem> + <FormLabel>Contact Email<span className="text-red-500">*</span></FormLabel> + <FormControl> + <Input + placeholder="email@example.com" + type="email" + {...field} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Contact Phone 필드 */} + <FormField + control={form.control} + name="contactPhone" + render={({ field }) => ( + <FormItem> + <FormLabel>Contact Phone</FormLabel> + <FormControl> + <Input + placeholder="+82-10-1234-5678" + {...field} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Address 필드 */} + <FormField + control={form.control} + name="address" + render={({ field }) => ( + <FormItem className="col-span-full"> + <FormLabel>Address</FormLabel> + <FormControl> + <Input + placeholder="Company address" + {...field} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Country 필드 */} + <FormField + control={form.control} + name="country" + render={({ field }) => { + const selectedCountry = countryArray.find( + (c) => c.code === field.value + ) + return ( + <FormItem> + <FormLabel>Country</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + role="combobox" + className={cn( + "w-full justify-between", + !field.value && "text-muted-foreground" + )} + disabled={isSubmitting} + > + {selectedCountry + ? selectedCountry.label + : "Select a country"} + <ChevronsUpDown className="ml-2 h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-[300px] p-0"> + <Command> + <CommandInput placeholder="Search country..." /> + <CommandList> + <CommandEmpty>No country found.</CommandEmpty> + <CommandGroup className="max-h-[300px] overflow-y-auto"> + {countryArray.map((country) => ( + <CommandItem + key={country.code} + value={country.label} + onSelect={() => + field.onChange(country.code) + } + > + <Check + className={cn( + "mr-2 h-4 w-4", + country.code === field.value + ? "opacity-100" + : "opacity-0" + )} + /> + {country.label} + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + ) + }} + /> + + {/* Source 필드 */} + <FormField + control={form.control} + name="source" + render={({ field }) => ( + <FormItem> + <FormLabel>Source <span className="text-red-500">*</span></FormLabel> + <FormControl> + <Input + placeholder="Where this candidate was found" + {...field} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + + {/* Items 필드 (새로 추가) */} + <FormField + control={form.control} + name="items" + render={({ field }) => ( + <FormItem className="col-span-full"> + <FormLabel>Items <span className="text-red-500">*</span></FormLabel> + <FormControl> + <Textarea + placeholder="List of items or products this vendor provides" + className="min-h-[80px]" + {...field} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Remark 필드 (새로 추가) */} + <FormField + control={form.control} + name="remark" + render={({ field }) => ( + <FormItem className="col-span-full"> + <FormLabel>Remarks</FormLabel> + <FormControl> + <Textarea + placeholder="Additional notes or comments" + className="min-h-[80px]" + {...field} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => setOpen(false)} + disabled={isSubmitting} + > + Cancel + </Button> + <Button type="submit" disabled={isSubmitting}> + {isSubmitting ? "Creating..." : "Create"} + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/tech-vendor-candidates/table/candidates-table-columns.tsx b/lib/tech-vendor-candidates/table/candidates-table-columns.tsx new file mode 100644 index 00000000..113927cf --- /dev/null +++ b/lib/tech-vendor-candidates/table/candidates-table-columns.tsx @@ -0,0 +1,199 @@ +"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, formatDateTime } 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 { getCandidateStatusIcon } from "@/lib/vendor-candidates/utils" +import { candidateColumnsConfig } from "@/config/candidatesColumnsConfig" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { VendorCandidatesWithVendorInfo } from "@/db/schema" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorCandidatesWithVendorInfo> | null>> +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorCandidatesWithVendorInfo>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<VendorCandidatesWithVendorInfo> = { + 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 메뉴) + // ---------------------------------------------------------------- +// "actions" 컬럼 예시 +const actionsColumn: ColumnDef<VendorCandidatesWithVendorInfo> = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "update" })} + > + 편집 + </DropdownMenuItem> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "delete" })} + > + 삭제 + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> + + {/* 여기서 Log 보기 액션 추가 */} + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "log" })} + > + 감사 로그 보기 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, +} + + + // ---------------------------------------------------------------- + // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 3-1) groupMap: { [groupName]: ColumnDef<VendorCandidatesWithVendorInfo>[] } + const groupMap: Record<string, ColumnDef<VendorCandidatesWithVendorInfo>[]> = {} + + candidateColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef<VendorCandidatesWithVendorInfo> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + + if (cfg.id === "status") { + const statusVal = row.original.status + if (!statusVal) return null + const Icon = getCandidateStatusIcon(statusVal) + return ( + <div className="flex w-[6.25rem] items-center"> + <Icon className="mr-2 size-4 text-muted-foreground" aria-hidden="true" /> + <span className="capitalize">{statusVal}</span> + </div> + ) + } + + + if (cfg.id === "createdAt" ||cfg.id === "updatedAt" ) { + const dateVal = cell.getValue() as Date + return formatDateTime(dateVal) + } + + // code etc... + return row.getValue(cfg.id) ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // ---------------------------------------------------------------- + // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef<VendorCandidatesWithVendorInfo>[] = [] + + // 순서를 고정하고 싶다면 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-vendor-candidates/table/candidates-table-floating-bar.tsx b/lib/tech-vendor-candidates/table/candidates-table-floating-bar.tsx new file mode 100644 index 00000000..baf4a583 --- /dev/null +++ b/lib/tech-vendor-candidates/table/candidates-table-floating-bar.tsx @@ -0,0 +1,395 @@ +"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, + Mail, +} 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 { useSession } from "next-auth/react" // next-auth 세션 훅 + +import { ActionConfirmDialog } from "@/components/ui/action-dialog" +import { VendorCandidatesWithVendorInfo, vendorCandidates } from "@/db/schema/vendors" +import { + bulkUpdateVendorCandidateStatus, + removeCandidates, +} from "../service" + +/** + * 테이블 상단/하단에 고정되는 Floating Bar + * 상태 일괄 변경, 초대, 삭제, Export 등을 수행 + */ +interface CandidatesTableFloatingBarProps { + table: Table<VendorCandidatesWithVendorInfo> +} + +export function VendorCandidateTableFloatingBar({ + table, +}: CandidatesTableFloatingBarProps) { + const rows = table.getFilteredSelectedRowModel().rows + const { data: session, status } = useSession() + + // React 18의 startTransition 사용 (isPending으로 트랜지션 상태 확인) + const [isPending, startTransition] = React.useTransition() + const [action, setAction] = React.useState< + "update-status" | "export" | "delete" | "invite" + >() + const [popoverOpen, setPopoverOpen] = React.useState(false) + + // ESC 키로 selection 해제 + 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 (ActionConfirmDialog) 제어 + const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false) + const [confirmProps, setConfirmProps] = React.useState<{ + title: string + description?: string + onConfirm: () => Promise<void> | void + }>({ + title: "", + description: "", + onConfirm: () => {}, + }) + + /** + * 1) 삭제 버튼 클릭 시 Confirm Dialog 열기 + */ + function handleDeleteConfirm() { + setAction("delete") + + setConfirmProps({ + title: `Delete ${rows.length} candidate${ + rows.length > 1 ? "s" : "" + }?`, + description: "This action cannot be undone.", + onConfirm: async () => { + startTransition(async () => { + if (!session?.user?.id) { + toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.") + return + } + const userId = Number(session.user.id) + + // removeCandidates 호출 시 userId를 넘긴다고 가정 + const { error } = await removeCandidates( + { + ids: rows.map((row) => row.original.id), + }, + userId + ) + + if (error) { + toast.error(error) + return + } + toast.success("Candidates deleted successfully") + table.toggleAllRowsSelected(false) + setConfirmDialogOpen(false) + }) + }, + }) + setConfirmDialogOpen(true) + } + + /** + * 2) 선택된 후보들의 상태 일괄 업데이트 + */ + function handleSelectStatus(newStatus: VendorCandidatesWithVendorInfo["status"]) { + setAction("update-status") + + setConfirmProps({ + title: `Update ${rows.length} candidate${ + rows.length > 1 ? "s" : "" + } with status: ${newStatus}?`, + description: "This action will override their current status.", + onConfirm: async () => { + startTransition(async () => { + if (!session?.user?.id) { + toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.") + return + } + const userId = Number(session.user.id) + + const { error } = await bulkUpdateVendorCandidateStatus({ + ids: rows.map((row) => row.original.id), + status: newStatus, + userId, + comment: `Bulk status update to ${newStatus}`, + }) + + if (error) { + toast.error(error) + return + } + toast.success("Candidates updated") + setConfirmDialogOpen(false) + table.toggleAllRowsSelected(false) + }) + }, + }) + setConfirmDialogOpen(true) + } + + /** + * 3) 초대하기 (status = "INVITED" + 이메일 발송) + */ + function handleInvite() { + setAction("invite") + setConfirmProps({ + title: `Invite ${rows.length} candidate${ + rows.length > 1 ? "s" : "" + }?`, + description: + "This will change their status to INVITED and send invitation emails.", + onConfirm: async () => { + startTransition(async () => { + if (!session?.user?.id) { + toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.") + return + } + const userId = Number(session.user.id) + + const { error } = await bulkUpdateVendorCandidateStatus({ + ids: rows.map((row) => row.original.id), + status: "INVITED", + userId, + comment: "Bulk invite action", + }) + + if (error) { + toast.error(error) + return + } + toast.success("Invitation emails sent successfully") + table.toggleAllRowsSelected(false) + setConfirmDialogOpen(false) + }) + }, + }) + setConfirmDialogOpen(true) + } + + return ( + <> + {/* 선택된 row가 있을 때 표시되는 Floating Bar */} + <div className="flex justify-center w-full my-4"> + <div className="flex items-center gap-2 rounded-md border bg-background p-2 text-foreground shadow"> + {/* 선택된 갯수 표시 + Clear selection 버튼 */} + <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" /> + + {/* 우측 액션들: 초대, 상태변경, Export, 삭제 */} + <div className="flex items-center gap-1.5"> + {/* 초대하기 */} + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="sm" + className="h-7 border" + onClick={handleInvite} + disabled={isPending} + > + {isPending && action === "invite" ? ( + <Loader + className="mr-1 size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <Mail className="mr-1 size-3.5" aria-hidden="true" /> + )} + <span>Invite</span> + </Button> + </TooltipTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Send invitation emails</p> + </TooltipContent> + </Tooltip> + + {/* 상태 업데이트 (Select) */} + <Select + onValueChange={(value: VendorCandidatesWithVendorInfo["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> + {vendorCandidates.status.enumValues.map((status) => ( + <SelectItem + key={status} + value={status} + className="capitalize" + > + {status} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + + {/* Export 버튼 */} + <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 candidates</p> + </TooltipContent> + </Tooltip> + + {/* 삭제 버튼 */} + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border" + onClick={handleDeleteConfirm} + disabled={isPending} + > + {isPending && action === "delete" ? ( + <Loader + className="size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <Trash2 className="size-3.5" aria-hidden="true" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Delete candidates</p> + </TooltipContent> + </Tooltip> + </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" || action === "invite") + } + confirmLabel={ + action === "delete" + ? "Delete" + : action === "update-status" + ? "Update" + : action === "invite" + ? "Invite" + : "Confirm" + } + confirmVariant={action === "delete" ? "destructive" : "default"} + /> + </> + ) +} diff --git a/lib/tech-vendor-candidates/table/candidates-table-toolbar-actions.tsx b/lib/tech-vendor-candidates/table/candidates-table-toolbar-actions.tsx new file mode 100644 index 00000000..17462841 --- /dev/null +++ b/lib/tech-vendor-candidates/table/candidates-table-toolbar-actions.tsx @@ -0,0 +1,93 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, FileDown, Upload } from "lucide-react" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +import { AddCandidateDialog } from "./add-candidates-dialog" +import { DeleteCandidatesDialog } from "./delete-candidates-dialog" +import { InviteCandidatesDialog } from "./invite-candidates-dialog" +import { ImportVendorCandidatesButton } from "./import-button" +import { exportVendorCandidateTemplate } from "./excel-template-download" +import { VendorCandidatesWithVendorInfo } from "@/db/schema/vendors" + + +interface CandidatesTableToolbarActionsProps { + table: Table<VendorCandidatesWithVendorInfo> +} + +export function CandidatesTableToolbarActions({ table }: CandidatesTableToolbarActionsProps) { + const selectedRows = table.getFilteredSelectedRowModel().rows + const hasSelection = selectedRows.length > 0 + const [refreshKey, setRefreshKey] = React.useState(0) + + // Handler to refresh the table after import + const handleImportSuccess = () => { + // Trigger a refresh of the table data + setRefreshKey(prev => prev + 1) + } + + return ( + <div className="flex items-center gap-2"> + {/* Show actions only when rows are selected */} + {hasSelection ? ( + <> + {/* Invite dialog - new addition */} + <InviteCandidatesDialog + candidates={selectedRows.map((row) => row.original)} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + + {/* Delete dialog */} + <DeleteCandidatesDialog + candidates={selectedRows.map((row) => row.original)} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + </> + ) : null} + + {/* Add new candidate dialog */} + <AddCandidateDialog /> + + {/* Import Excel button */} + <ImportVendorCandidatesButton onSuccess={handleImportSuccess} /> + + {/* Export dropdown menu */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem + onClick={() => { + exportTableToExcel(table, { + filename: "vendor-candidates", + excludeColumns: ["select", "actions"], + useGroupHeader: false, + }) + }} + > + <FileDown className="mr-2 h-4 w-4" /> + <span>Export Current Data</span> + </DropdownMenuItem> + <DropdownMenuItem onClick={exportVendorCandidateTemplate}> + <FileDown className="mr-2 h-4 w-4" /> + <span>Download Template</span> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </div> + ) +}
\ No newline at end of file diff --git a/lib/tech-vendor-candidates/table/candidates-table.tsx b/lib/tech-vendor-candidates/table/candidates-table.tsx new file mode 100644 index 00000000..9dab8f6d --- /dev/null +++ b/lib/tech-vendor-candidates/table/candidates-table.tsx @@ -0,0 +1,173 @@ +"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 { getVendorCandidateCounts, getVendorCandidates } from "../service" +import { vendorCandidates ,VendorCandidatesWithVendorInfo} from "@/db/schema/vendors" +import { VendorCandidateTableFloatingBar } from "./candidates-table-floating-bar" +import { getColumns } from "./candidates-table-columns" +import { CandidatesTableToolbarActions } from "./candidates-table-toolbar-actions" +import { DeleteCandidatesDialog } from "./delete-candidates-dialog" +import { UpdateCandidateSheet } from "./update-candidate-sheet" +import { getCandidateStatusIcon } from "@/lib/vendor-candidates/utils" + +interface VendorCandidatesTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getVendorCandidates>>, + Awaited<ReturnType<typeof getVendorCandidateCounts>>, + ] + > +} + +export function VendorCandidateTable({ promises }: VendorCandidatesTableProps) { + const { featureFlags } = useFeatureFlags() + + const [{ data, pageCount }, statusCounts] = + React.use(promises) + + + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<VendorCandidatesWithVendorInfo> | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + /** + * This component can render either a faceted filter or a search filter based on the `options` prop. + * + * @prop options - An array of objects, each representing a filter option. If provided, a faceted filter is rendered. If not, a search filter is rendered. + * + * Each `option` object has the following properties: + * @prop {string} label - The label for the filter option. + * @prop {string} value - The value for the filter option. + * @prop {React.ReactNode} [icon] - An optional icon to display next to the label. + * @prop {boolean} [withCount] - An optional boolean to display the count of the filter option. + */ + const filterFields: DataTableFilterField<VendorCandidatesWithVendorInfo>[] = [ + + { + id: "status", + label: "Status", + options: vendorCandidates.status.enumValues.map((status) => ({ + label: toSentenceCase(status), + value: status, + count: statusCounts[status], + })), + }, + + ] + + /** + * Advanced filter fields for the data table. + * These fields provide more complex filtering options compared to the regular filterFields. + * + * Key differences from regular filterFields: + * 1. More field types: Includes 'text', 'multi-select', 'date', and 'boolean'. + * 2. Enhanced flexibility: Allows for more precise and varied filtering options. + * 3. Used with DataTableAdvancedToolbar: Enables a more sophisticated filtering UI. + * 4. Date and boolean types: Adds support for filtering by date ranges and boolean values. + */ + const advancedFilterFields: DataTableAdvancedFilterField<VendorCandidatesWithVendorInfo>[] = [ + { + id: "companyName", + label: "Company Name", + type: "text", + }, + { + id: "contactEmail", + label: "Contact Email", + type: "text", + }, + { + id: "contactPhone", + label: "Contact Phone", + type: "text", + }, + { + id: "source", + label: "source", + type: "text", + }, + { + id: "status", + label: "Status", + type: "multi-select", + options: vendorCandidates.status.enumValues.map((status) => ({ + label: (status), + value: status, + icon: getCandidateStatusIcon(status), + count: statusCounts[status], + })), + }, + + { + id: "createdAt", + 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, + }) + + return ( + <> + <DataTable + table={table} + floatingBar={<VendorCandidateTableFloatingBar table={table} />} + > + + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <CandidatesTableToolbarActions table={table} /> + </DataTableAdvancedToolbar> + + </DataTable> + <UpdateCandidateSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + candidate={rowAction?.row.original ?? null} + /> + <DeleteCandidatesDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + candidates={rowAction?.row.original ? [rowAction?.row.original] : []} + showTrigger={false} + onSuccess={() => rowAction?.row.toggleSelected(false)} + /> + + </> + ) +} diff --git a/lib/tech-vendor-candidates/table/delete-candidates-dialog.tsx b/lib/tech-vendor-candidates/table/delete-candidates-dialog.tsx new file mode 100644 index 00000000..bc231109 --- /dev/null +++ b/lib/tech-vendor-candidates/table/delete-candidates-dialog.tsx @@ -0,0 +1,159 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Trash } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" + +import { removeCandidates } from "../service" +import { VendorCandidatesWithVendorInfo } from "@/db/schema" +import { useSession } from "next-auth/react" // next-auth 세션 훅 + +interface DeleteCandidatesDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + candidates: Row<VendorCandidatesWithVendorInfo>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteCandidatesDialog({ + candidates, + showTrigger = true, + onSuccess, + ...props +}: DeleteCandidatesDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + const { data: session, status } = useSession() + + function onDelete() { + startDeleteTransition(async () => { + + if (!session?.user?.id) { + toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.") + return + } + + const userId = Number(session.user.id) + + const { error } = await removeCandidates({ + ids: candidates.map((candidate) => candidate.id), + }, userId) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + toast.success("Candidates deleted") + onSuccess?.() + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({candidates.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>Are you absolutely sure?</DialogTitle> + <DialogDescription> + This action cannot be undone. This will permanently delete your{" "} + <span className="font-medium">{candidates.length}</span> + {candidates.length === 1 ? " candidate" : " candidates"} from our servers. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">Cancel</Button> + </DialogClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Delete + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({candidates.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>Are you absolutely sure?</DrawerTitle> + <DrawerDescription> + This action cannot be undone. This will permanently delete your{" "} + <span className="font-medium">{candidates.length}</span> + {candidates.length === 1 ? " candidate" : " candidates"} from our servers. + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">Cancel</Button> + </DrawerClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + Delete + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +} diff --git a/lib/tech-vendor-candidates/table/excel-template-download.tsx b/lib/tech-vendor-candidates/table/excel-template-download.tsx new file mode 100644 index 00000000..673680db --- /dev/null +++ b/lib/tech-vendor-candidates/table/excel-template-download.tsx @@ -0,0 +1,128 @@ +"use client" + +import { type Table } from "@tanstack/react-table" +import ExcelJS from "exceljs" +import { VendorCandidates } from "@/db/schema/vendors" + +/** + * Export an empty template for vendor candidates with column headers + * matching the expected import format + */ +export async function exportVendorCandidateTemplate() { + // Create a new workbook and worksheet + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("Vendor Candidates") + + // Define the columns with expected headers + const columns = [ + { header: "Company Name", key: "companyName", width: 30 }, + { header: "Tax ID", key: "taxId", width: 20 }, + { header: "Contact Email", key: "contactEmail", width: 30 }, + { header: "Contact Phone", key: "contactPhone", width: 20 }, + { header: "Address", key: "address", width: 40 }, + { header: "Country", key: "country", width: 20 }, + { header: "Source", key: "source", width: 20 }, + { header: "Items", key: "items", width: 40 }, + { header: "Remark", key: "remark", width: 40 }, + { header: "Status", key: "status", width: 15 }, + ] + + // Add columns to the worksheet + worksheet.columns = columns + + // Style the header row + const headerRow = worksheet.getRow(2) + headerRow.font = { bold: true } + headerRow.alignment = { horizontal: "center" } + headerRow.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, + } + + // Mark required fields with a red asterisk + const requiredFields = ["Company Name", "Source", "Items"] + if (requiredFields.includes(cell.value as string)) { + cell.value = `${cell.value} *` + cell.font = { bold: true, color: { argb: "FFFF0000" } } + } + }) + + // Add example data rows + const exampleData = [ + { + companyName: "ABC Corporation", + taxId: "123-45-6789", + contactEmail: "contact@abc.com", + contactPhone: "+1-123-456-7890", + address: "123 Business Ave, Suite 100, New York, NY 10001", + country: "US", + source: "Website", + items: "Electronic components, Circuit boards, Sensors", + remark: "Potential supplier for Project X", + status: "COLLECTED", + }, + { + companyName: "XYZ Ltd.", + taxId: "GB987654321", + contactEmail: "info@xyz.com", + contactPhone: "+44-987-654-3210", + address: "45 Industrial Park, London, EC2A 4PX", + country: "GB", + source: "Referral", + items: "Steel components, Metal frames, Industrial hardware", + remark: "Met at trade show in March", + status: "COLLECTED", + }, + ] + + // Add the example rows to the worksheet + exampleData.forEach((data) => { + worksheet.addRow(data) + }) + + // Add data validation for Status column + const statusValues = ["COLLECTED", "INVITED", "DISCARDED"] + const statusColumn = columns.findIndex(col => col.key === "status") + 1 + const statusColLetter = String.fromCharCode(64 + statusColumn) + + for (let i = 4; i <= 100; i++) { // Apply to rows 4-100 (after example data) + worksheet.getCell(`${statusColLetter}${i}`).dataValidation = { + type: 'list', + allowBlank: true, + formulae: [`"${statusValues.join(',')}"`] + } + } + + // Add instructions row + worksheet.insertRow(1, ["Please fill in the data below. Required fields are marked with an asterisk (*): Company Name, Source, Items"]) + worksheet.mergeCells(`A1:${String.fromCharCode(64 + columns.length)}1`) + const instructionRow = worksheet.getRow(1) + instructionRow.font = { bold: true, color: { argb: "FF0000FF" } } + instructionRow.alignment = { horizontal: "center" } + instructionRow.height = 30 + + // Auto-width columns based on content + worksheet.columns.forEach(column => { + if (column.key) { // Check that column.key is defined + const dataMax = Math.max(...worksheet.getColumn(column.key).values + .filter(value => value !== null && value !== undefined) + .map(value => String(value).length) + ) + column.width = Math.max(column.width || 10, dataMax + 2) + } + }) + + // Download the workbook + 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 = "vendor-candidates-template.xlsx" + link.click() + URL.revokeObjectURL(url) +}
\ No newline at end of file diff --git a/lib/tech-vendor-candidates/table/feature-flags-provider.tsx b/lib/tech-vendor-candidates/table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/tech-vendor-candidates/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-vendor-candidates/table/feature-flags.tsx b/lib/tech-vendor-candidates/table/feature-flags.tsx new file mode 100644 index 00000000..aaae6af2 --- /dev/null +++ b/lib/tech-vendor-candidates/table/feature-flags.tsx @@ -0,0 +1,96 @@ +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" + +import { dataTableConfig, type DataTableConfig } from "@/config/data-table" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] + +interface TasksTableContextProps { + featureFlags: FeatureFlagValue[] + setFeatureFlags: (value: FeatureFlagValue[]) => void +} + +const TasksTableContext = React.createContext<TasksTableContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useTasksTable() { + const context = React.useContext(TasksTableContext) + if (!context) { + throw new Error("useTasksTable must be used within a TasksTableProvider") + } + return context +} + +export function TasksTableProvider({ children }: React.PropsWithChildren) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "featureFlags", + { + 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, + } + ) + + return ( + <TasksTableContext.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" + > + {dataTableConfig.featureFlags.map((flag) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className="whitespace-nowrap px-3 text-xs" + asChild + > + <TooltipTrigger> + <flag.icon + className="mr-2 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} + </TasksTableContext.Provider> + ) +} diff --git a/lib/tech-vendor-candidates/table/import-button.tsx b/lib/tech-vendor-candidates/table/import-button.tsx new file mode 100644 index 00000000..ad1e6862 --- /dev/null +++ b/lib/tech-vendor-candidates/table/import-button.tsx @@ -0,0 +1,233 @@ +"use client" + +import React, { useRef } from 'react' +import ExcelJS from 'exceljs' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Upload, Loader } from 'lucide-react' +import { createVendorCandidate } from '../service' +import { Input } from '@/components/ui/input' +import { useSession } from "next-auth/react" // next-auth 세션 훅 추가 +import { decryptWithServerAction } from '@/components/drm/drmUtils' // DRM 복호화 함수 import + +interface ImportExcelProps { + onSuccess?: () => void +} + +export function ImportVendorCandidatesButton({ onSuccess }: ImportExcelProps) { + const fileInputRef = useRef<HTMLInputElement>(null) + const [isImporting, setIsImporting] = React.useState(false) + const { data: session } = useSession() // status 제거 (사용하지 않음) + + // Helper function to get cell value as string + const getCellValueAsString = (cell: ExcelJS.Cell): string => { + if (!cell || cell.value === undefined || cell.value === null) return ''; + + if (typeof cell.value === 'string') return cell.value.trim(); + if (typeof cell.value === 'number') return cell.value.toString(); + + // Handle rich text + if (typeof cell.value === 'object' && 'richText' in cell.value) { + return cell.value.richText.map((rt: { text: string }) => rt.text).join(''); + } + + // Handle dates + if (cell.value instanceof Date) { + return cell.value.toISOString().split('T')[0]; + } + + // Fallback + return String(cell.value); + } + + const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => { + const file = event.target.files?.[0] + if (!file) return + + setIsImporting(true) + + try { + // DRM 복호화 시도 (실패시 원본 파일 사용) + let data: ArrayBuffer; + try { + data = await decryptWithServerAction(file); + console.log('[Import] DRM 복호화 성공'); + } catch (decryptError) { + console.warn('[Import] DRM 복호화 실패, 원본 파일 사용:', decryptError); + data = await file.arrayBuffer(); + } + + // Read the Excel file using ExcelJS + const workbook = new ExcelJS.Workbook() + await workbook.xlsx.load(data) + + // Get the first worksheet + const worksheet = workbook.getWorksheet(1) + if (!worksheet) { + toast.error("No worksheet found in the spreadsheet") + return + } + + // Check if there's an instruction row + const hasInstructionRow = worksheet.getRow(1).getCell(1).value !== null && + worksheet.getRow(1).getCell(2).value === null; + + // Get header row index (row 2 if there's an instruction row, otherwise row 1) + const headerRowIndex = hasInstructionRow ? 2 : 1; + + // Get column headers and their indices + const headerRow = worksheet.getRow(headerRowIndex); + const headers: Record<number, string> = {}; + const columnIndices: Record<string, number> = {}; + + headerRow.eachCell((cell, colNumber) => { + const header = getCellValueAsString(cell); + headers[colNumber] = header; + columnIndices[header] = colNumber; + }); + + // Process data rows + const rows: Record<string, string>[] = []; + const startRow = headerRowIndex + 1; + + for (let i = startRow; i <= worksheet.rowCount; i++) { + const row = worksheet.getRow(i); + + // Skip empty rows + if (row.cellCount === 0) continue; + + // Check if this is likely an example row + const isExample = i === startRow && worksheet.getRow(i + 1).values?.length === 0; + if (isExample) continue; + + const rowData: Record<string, string> = {}; + let hasData = false; + + // Map the data using header indices + Object.entries(columnIndices).forEach(([header, colIndex]) => { + const value = getCellValueAsString(row.getCell(colIndex)); + if (value) { + rowData[header] = value; + hasData = true; + } + }); + + if (hasData) { + rows.push(rowData); + } + } + + if (rows.length === 0) { + toast.error("No data found in the spreadsheet") + setIsImporting(false) + return + } + + // Process each row + let successCount = 0; + let errorCount = 0; + + // Create promises for all vendor candidate creation operations + const promises = rows.map(async (row) => { + try { + // Map Excel columns to our data model + const candidateData = { + companyName: String(row['Company Name'] || ''), + contactEmail: String(row['Contact Email'] || ''), + contactPhone: String(row['Contact Phone'] || ''), + taxId: String(row['Tax ID'] || ''), + address: String(row['Address'] || ''), + country: String(row['Country'] || ''), + source: String(row['Source'] || ''), + items: String(row['Items'] || ''), + remark: String(row['Remark'] || row['Remarks'] || ''), + // Default to COLLECTED if not specified + status: (row['Status'] || 'COLLECTED') as "COLLECTED" | "INVITED" | "DISCARDED" + }; + + // Validate required fields + if (!candidateData.companyName || !candidateData.source || + !candidateData.items) { + console.error("Missing required fields", candidateData); + errorCount++; + return null; + } + + if (!session || !session.user || !session.user.id) { + toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.") + return + } + + // Create the vendor candidate (userId는 이미 number 타입이므로 변환 불필요) + const result = await createVendorCandidate(candidateData) + + if (result.error) { + console.error(`Failed to import row: ${result.error}`, candidateData); + errorCount++; + return null; + } + + successCount++; + return result.data; + } catch (error) { + console.error("Error processing row:", error, row); + errorCount++; + return null; + } + }); + + // Wait for all operations to complete + await Promise.all(promises); + + // Show results + if (successCount > 0) { + toast.success(`Successfully imported ${successCount} vendor candidates`); + if (errorCount > 0) { + toast.warning(`Failed to import ${errorCount} rows due to errors`); + } + // Call the success callback to refresh data + onSuccess?.(); + } else if (errorCount > 0) { + toast.error(`Failed to import all ${errorCount} rows due to errors`); + } + + } catch (error) { + console.error("Import error:", error); + toast.error("Error importing data. Please check file format."); + } finally { + setIsImporting(false); + // Reset the file input + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + } + + return ( + <> + <Input + type="file" + ref={fileInputRef} + onChange={handleImport} + accept=".xlsx,.xls" + className="hidden" + /> + <Button + variant="outline" + size="sm" + onClick={() => fileInputRef.current?.click()} + disabled={isImporting} + className="gap-2" + > + {isImporting ? ( + <Loader className="size-4 animate-spin" aria-hidden="true" /> + ) : ( + <Upload className="size-4" aria-hidden="true" /> + )} + <span className="hidden sm:inline"> + {isImporting ? "Importing..." : "Import"} + </span> + </Button> + </> + ) +}
\ No newline at end of file diff --git a/lib/tech-vendor-candidates/table/invite-candidates-dialog.tsx b/lib/tech-vendor-candidates/table/invite-candidates-dialog.tsx new file mode 100644 index 00000000..570cf96a --- /dev/null +++ b/lib/tech-vendor-candidates/table/invite-candidates-dialog.tsx @@ -0,0 +1,230 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Mail, AlertCircle, XCircle } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" +import { + Alert, + AlertTitle, + AlertDescription +} from "@/components/ui/alert" + +import { VendorCandidates } from "@/db/schema/vendors" +import { bulkUpdateVendorCandidateStatus } from "../service" +import { useSession } from "next-auth/react" // next-auth 세션 훅 + +interface InviteCandidatesDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + candidates: Row<VendorCandidates>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function InviteCandidatesDialog({ + candidates, + showTrigger = true, + onSuccess, + ...props +}: InviteCandidatesDialogProps) { + const [isInvitePending, startInviteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + const { data: session, status } = useSession() + + // 후보자를 상태별로 분류 + const discardedCandidates = candidates.filter(candidate => candidate.status === "DISCARDED") + const nonDiscardedCandidates = candidates.filter(candidate => candidate.status !== "DISCARDED") + + // 이메일 유무에 따라 초대 가능한 후보자 분류 (DISCARDED가 아닌 후보자 중에서) + const candidatesWithEmail = nonDiscardedCandidates.filter(candidate => candidate.contactEmail) + const candidatesWithoutEmail = nonDiscardedCandidates.filter(candidate => !candidate.contactEmail) + + // 각 카테고리 수 + const invitableCount = candidatesWithEmail.length + const hasUninvitableCandidates = candidatesWithoutEmail.length > 0 + const hasDiscardedCandidates = discardedCandidates.length > 0 + + function onInvite() { + startInviteTransition(async () => { + // 이메일이 있고 DISCARDED가 아닌 후보자만 상태 업데이트 + + if (!session?.user?.id) { + toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.") + return + } + + const { error } = await bulkUpdateVendorCandidateStatus({ + ids: candidatesWithEmail.map((candidate) => candidate.id), + status: "INVITED", + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + + if (invitableCount === 0) { + toast.warning("No invitation sent - no eligible candidates with email addresses") + } else { + let skipMessage = "" + + if (hasUninvitableCandidates && hasDiscardedCandidates) { + skipMessage = ` ${candidatesWithoutEmail.length} candidates without email and ${discardedCandidates.length} discarded candidates were skipped.` + } else if (hasUninvitableCandidates) { + skipMessage = ` ${candidatesWithoutEmail.length} candidates without email were skipped.` + } else if (hasDiscardedCandidates) { + skipMessage = ` ${discardedCandidates.length} discarded candidates were skipped.` + } + + toast.success(`Invitation emails sent to ${invitableCount} candidates.${skipMessage}`) + } + + onSuccess?.() + }) + } + + // 초대 버튼 비활성화 조건 + const disableInviteButton = isInvitePending || invitableCount === 0 + + const DialogComponent = ( + <> + <div className="space-y-4"> + {/* 이메일 없는 후보자 알림 */} + {hasUninvitableCandidates && ( + <Alert> + <AlertCircle className="h-4 w-4" /> + <AlertTitle>Missing Email Addresses</AlertTitle> + <AlertDescription> + {candidatesWithoutEmail.length} candidate{candidatesWithoutEmail.length > 1 ? 's' : ''} {candidatesWithoutEmail.length > 1 ? 'don't' : 'doesn't'} have email addresses and won't receive invitations. + </AlertDescription> + </Alert> + )} + + {/* 폐기된 후보자 알림 */} + {hasDiscardedCandidates && ( + <Alert variant="destructive"> + <XCircle className="h-4 w-4" /> + <AlertTitle>Discarded Candidates</AlertTitle> + <AlertDescription> + {discardedCandidates.length} candidate{discardedCandidates.length > 1 ? 's have' : ' has'} been discarded and won't receive invitations. + </AlertDescription> + </Alert> + )} + + <DialogDescription> + {invitableCount > 0 ? ( + <> + This will send invitation emails to{" "} + <span className="font-medium">{invitableCount}</span> + {invitableCount === 1 ? " candidate" : " candidates"} and change their status to INVITED. + </> + ) : ( + <> + No candidates can be invited because none of the selected candidates have valid email addresses or they have been discarded. + </> + )} + </DialogDescription> + </div> + </> + ) + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <Mail className="size-4" aria-hidden="true" /> + Invite ({candidates.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>Send invitations?</DialogTitle> + </DialogHeader> + {DialogComponent} + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">Cancel</Button> + </DialogClose> + <Button + aria-label="Invite selected vendors" + variant="default" + onClick={onInvite} + disabled={disableInviteButton} + > + {isInvitePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Send Invitations + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <Mail className="size-4" aria-hidden="true" /> + Invite ({candidates.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>Send invitations?</DrawerTitle> + </DrawerHeader> + {DialogComponent} + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">Cancel</Button> + </DrawerClose> + <Button + aria-label="Invite selected vendors" + variant="default" + onClick={onInvite} + disabled={disableInviteButton} + > + {isInvitePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + Send Invitations + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/lib/tech-vendor-candidates/table/update-candidate-sheet.tsx b/lib/tech-vendor-candidates/table/update-candidate-sheet.tsx new file mode 100644 index 00000000..3d278126 --- /dev/null +++ b/lib/tech-vendor-candidates/table/update-candidate-sheet.tsx @@ -0,0 +1,437 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { Check, ChevronsUpDown, Loader } from "lucide-react" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +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 { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { useSession } from "next-auth/react" // next-auth 세션 훅 + +import { updateVendorCandidateSchema, UpdateVendorCandidateSchema } from "../validations" +import { updateVendorCandidate } from "../service" +import { vendorCandidates,VendorCandidatesWithVendorInfo} from "@/db/schema" + +// 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, +})) + +interface UpdateCandidateSheetProps + extends React.ComponentPropsWithRef<typeof Sheet> { + candidate: VendorCandidatesWithVendorInfo | null +} + +export function UpdateCandidateSheet({ candidate, ...props }: UpdateCandidateSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + const { data: session, status } = useSession() + + // Set default values from candidate data when the component receives a new candidate + + React.useEffect(() => { + if (candidate) { + form.reset({ + id: candidate.id, + companyName: candidate.companyName, + taxId: candidate.taxId, + contactEmail: candidate.contactEmail || "", // null을 빈 문자열로 변환 + contactPhone: candidate.contactPhone || "", + address: candidate.address || "", + country: candidate.country || "", + source: candidate.source || "", + items: candidate.items, + remark: candidate.remark || "", + status: candidate.status, + }) + } + }, [candidate]) + + + const form = useForm<UpdateVendorCandidateSchema>({ + resolver: zodResolver(updateVendorCandidateSchema), + defaultValues: { + id: candidate?.id || 0, + companyName: candidate?.companyName || "", + taxId: candidate?.taxId || "", + contactEmail: candidate?.contactEmail || "", + contactPhone: candidate?.contactPhone || "", + address: candidate?.address || "", + country: candidate?.country || "", + source: candidate?.source || "", + items: candidate?.items || "", + remark: candidate?.remark || "", + status: candidate?.status || "COLLECTED", + }, + }) + + function onSubmit(input: UpdateVendorCandidateSchema) { + startUpdateTransition(async () => { + + if (!session?.user?.id) { + toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.") + return + } + const userId = Number(session.user.id) + + if (!candidate) return + + const { error } = await updateVendorCandidate({ + ...input, + }, userId) + + if (error) { + toast.error(error) + return + } + + form.reset() + props.onOpenChange?.(false) + toast.success("Vendor candidate updated") + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-md overflow-y-auto"> + <SheetHeader className="text-left"> + <SheetTitle>Update Vendor Candidate</SheetTitle> + <SheetDescription> + Update the vendor candidate details and save the changes + </SheetDescription> + </SheetHeader> + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col gap-4" + > + {/* Company Name Field */} + <FormField + control={form.control} + name="companyName" + render={({ field }) => ( + <FormItem> + <FormLabel>Company Name <span className="text-red-500">*</span></FormLabel> + <FormControl> + <Input + placeholder="Enter company name" + {...field} + disabled={isUpdatePending} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Tax ID Field */} + <FormField + control={form.control} + name="taxId" + render={({ field }) => ( + <FormItem> + <FormLabel>Tax ID</FormLabel> + <FormControl> + <Input + placeholder="Enter tax ID" + {...field} + disabled={isUpdatePending} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Contact Email Field */} + <FormField + control={form.control} + name="contactEmail" + render={({ field }) => ( + <FormItem> + <FormLabel>Contact Email</FormLabel> + <FormControl> + <Input + placeholder="email@example.com" + type="email" + {...field} + disabled={isUpdatePending} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Contact Phone Field */} + <FormField + control={form.control} + name="contactPhone" + render={({ field }) => ( + <FormItem> + <FormLabel>Contact Phone</FormLabel> + <FormControl> + <Input + placeholder="+82-10-1234-5678" + {...field} + disabled={isUpdatePending} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Address Field */} + <FormField + control={form.control} + name="address" + render={({ field }) => ( + <FormItem> + <FormLabel>Address</FormLabel> + <FormControl> + <Input + placeholder="Enter company address" + {...field} + disabled={isUpdatePending} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Country Field */} + <FormField + control={form.control} + name="country" + render={({ field }) => { + const selectedCountry = countryArray.find( + (c) => c.code === field.value + ) + return ( + <FormItem> + <FormLabel>Country</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + role="combobox" + className={cn( + "w-full justify-between", + !field.value && "text-muted-foreground" + )} + disabled={isUpdatePending} + > + {selectedCountry + ? selectedCountry.label + : "Select a country"} + <ChevronsUpDown className="ml-2 h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-[300px] p-0"> + <Command> + <CommandInput placeholder="Search country..." /> + <CommandList> + <CommandEmpty>No country found.</CommandEmpty> + <CommandGroup className="max-h-[300px] overflow-y-auto"> + {countryArray.map((country) => ( + <CommandItem + key={country.code} + value={country.label} + onSelect={() => + field.onChange(country.code) + } + > + <Check + className={cn( + "mr-2 h-4 w-4", + country.code === field.value + ? "opacity-100" + : "opacity-0" + )} + /> + {country.label} + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + ) + }} + /> + + {/* Source Field */} + <FormField + control={form.control} + name="source" + render={({ field }) => ( + <FormItem> + <FormLabel>Source <span className="text-red-500">*</span></FormLabel> + <FormControl> + <Input + placeholder="Where this candidate was found" + {...field} + disabled={isUpdatePending} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Items Field */} + <FormField + control={form.control} + name="items" + render={({ field }) => ( + <FormItem> + <FormLabel>Items <span className="text-red-500">*</span></FormLabel> + <FormControl> + <Textarea + placeholder="List of items or products this vendor provides" + className="min-h-[80px]" + {...field} + disabled={isUpdatePending} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Remark Field */} + <FormField + control={form.control} + name="remark" + render={({ field }) => ( + <FormItem> + <FormLabel>Remark</FormLabel> + <FormControl> + <Textarea + placeholder="Additional notes or comments" + className="min-h-[80px]" + {...field} + disabled={isUpdatePending} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Status Field */} + <FormField + control={form.control} + name="status" + render={({ field }) => ( + <FormItem> + <FormLabel>Status</FormLabel> + <Select + onValueChange={field.onChange} + defaultValue={field.value} + disabled={isUpdatePending} + > + <FormControl> + <SelectTrigger className="capitalize"> + <SelectValue placeholder="Select a status" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectGroup> + {vendorCandidates.status.enumValues.map((item) => ( + <SelectItem + key={item} + value={item} + className="capitalize" + > + {item} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <SheetClose asChild> + <Button type="button" variant="outline" disabled={isUpdatePending}> + Cancel + </Button> + </SheetClose> + <Button disabled={isUpdatePending}> + {isUpdatePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Save + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/tech-vendor-candidates/utils.ts b/lib/tech-vendor-candidates/utils.ts new file mode 100644 index 00000000..4c21b873 --- /dev/null +++ b/lib/tech-vendor-candidates/utils.ts @@ -0,0 +1,40 @@ +import { + Activity, + AlertCircle, + AlertTriangle, + ArrowDownIcon, + ArrowRightIcon, + ArrowUpIcon, + AwardIcon, + BadgeCheck, + CheckCircle2, + CircleHelp, + CircleIcon, + CircleX, + ClipboardCheck, + ClipboardList, + FileCheck2, + FilePenLine, + FileX2, + MailCheck, + PencilIcon, + SearchIcon, + SendIcon, + Timer, + Trash2, + XCircle, +} from "lucide-react" + +import { TechVendorCandidate } from "@/db/schema/techVendors" + + +export function getCandidateStatusIcon(status: TechVendorCandidate["status"]) { + const statusIcons = { + COLLECTED: ClipboardList, // Data collection icon + INVITED: MailCheck, // Email sent and checked icon + DISCARDED: Trash2, // Trashed/discarded icon + } + + return statusIcons[status] || CircleIcon +} + diff --git a/lib/tech-vendor-candidates/validations.ts b/lib/tech-vendor-candidates/validations.ts new file mode 100644 index 00000000..d2ef4d53 --- /dev/null +++ b/lib/tech-vendor-candidates/validations.ts @@ -0,0 +1,148 @@ +import { techVendorCandidatesWithVendorInfo } from "@/db/schema/techVendors" +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" + +export const searchParamsTechCandidateCache = createSearchParamsCache({ + // Common flags + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + from: parseAsString.withDefault(""), + to: parseAsString.withDefault(""), + // Paging + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // Sorting - adjusting for vendorInvestigationsView + sort: getSortingStateParser<typeof techVendorCandidatesWithVendorInfo.$inferSelect>().withDefault([ + { id: "createdAt", desc: true }, + ]), + + // Advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // Global search + search: parseAsString.withDefault(""), + + // ----------------------------------------------------------------- + // Fields specific to vendor investigations + // ----------------------------------------------------------------- + + // investigationStatus: PLANNED, IN_PROGRESS, COMPLETED, CANCELED + status: parseAsStringEnum(["COLLECTED", "INVITED", "DISCARDED"]), + + // In case you also want to filter by vendorName, vendorCode, etc. + companyName: parseAsString.withDefault(""), + contactEmail: parseAsString.withDefault(""), + contactPhone: parseAsString.withDefault(""), + country: parseAsString.withDefault(""), + source: parseAsString.withDefault(""), + + +}) + +// Finally, export the type you can use in your server action: +export type GetTechVendorsCandidateSchema = Awaited<ReturnType<typeof searchParamsTechCandidateCache.parse>> + + +// Updated version of the updateVendorCandidateSchema +export const updateVendorCandidateSchema = z.object({ + id: z.number(), + // 필수 필드 + companyName: z.string().min(1, "회사명은 필수입니다").max(255), + // null을 명시적으로 처리 + contactEmail: z.union([ + z.string().email("유효한 이메일 형식이 아닙니다").max(255), + z.literal(''), + z.null() + ]).optional().transform(val => val === null ? '' : val), + contactPhone: z.union([z.string().max(50), z.literal(''), z.null()]).optional() + .transform(val => val === null ? '' : val), + country: z.union([z.string().max(100), z.literal(''), z.null()]).optional() + .transform(val => val === null ? '' : val), + // 필수 필드 + source: z.string().min(1, "출처는 필수입니다").max(100), + address: z.union([z.string(), z.literal(''), z.null()]).optional() + .transform(val => val === null ? '' : val), + taxId: z.union([z.string(), z.literal(''), z.null()]).optional() + .transform(val => val === null ? '' : val), + // 필수 필드 + items: z.string().min(1, "항목은 필수입니다"), + remark: z.union([z.string(), z.literal(''), z.null()]).optional() + .transform(val => val === null ? '' : val), + status: z.enum(["COLLECTED", "INVITED", "DISCARDED"]), + updatedAt: z.date().optional().default(() => new Date()), +});; + +// Create schema for vendor candidates +export const createVendorCandidateSchema = z.object({ + companyName: z.string().min(1, "회사명은 필수입니다").max(255), + // contactEmail을 필수값으로 변경 + contactEmail: z.string().min(1, "이메일은 필수입니다").email("유효한 이메일 형식이 아닙니다").max(255), + contactPhone: z.string().max(50).optional(), + country: z.string().max(100).optional(), + source: z.string().min(1, "출처는 필수입니다").max(100), + address: z.string().optional(), + taxId: z.string().optional(), + items: z.string().min(1, "항목은 필수입니다"), + remark: z.string().optional(), + vendorId: z.number().optional(), + status: z.enum(["COLLECTED", "INVITED", "DISCARDED"]).default("COLLECTED"), +}); + +// Export types for both schemas +export type UpdateVendorCandidateSchema = z.infer<typeof updateVendorCandidateSchema>; +export type CreateVendorCandidateSchema = z.infer<typeof createVendorCandidateSchema>; + + +export const removeCandidatesSchema = z.object({ + ids: z.array(z.number()).min(1, "At least one candidate ID must be provided"), +}); + +export type RemoveCandidatesInput = z.infer<typeof removeCandidatesSchema>; + +// 기술영업 벤더 후보 생성 스키마 +export const createTechVendorCandidateSchema = z.object({ + companyName: z.string().min(1, "Company name is required"), + contactEmail: z.string().email("Invalid email").optional(), + contactPhone: z.string().optional(), + taxId: z.string().optional(), + address: z.string().optional(), + country: z.string().optional(), + source: z.string().optional(), + status: z.enum(["COLLECTED", "INVITED", "DISCARDED"]).default("COLLECTED"), + remark: z.string().optional(), + items: z.string().min(1, "Items are required"), + vendorId: z.number().optional(), +}); + +// 기술영업 벤더 후보 업데이트 스키마 +export const updateTechVendorCandidateSchema = z.object({ + id: z.number(), + companyName: z.string().min(1, "Company name is required").optional(), + contactEmail: z.string().email("Invalid email").optional(), + contactPhone: z.string().optional(), + taxId: z.string().optional(), + address: z.string().optional(), + country: z.string().optional(), + source: z.string().optional(), + status: z.enum(["COLLECTED", "INVITED", "DISCARDED"]).optional(), + remark: z.string().optional(), + items: z.string().optional(), + vendorId: z.number().optional(), +}); + +// 기술영업 벤더 후보 삭제 스키마 +export const removeTechCandidatesSchema = z.object({ + ids: z.array(z.number()).min(1, "At least one ID is required"), +}); + +export type CreateTechVendorCandidateSchema = z.infer<typeof createTechVendorCandidateSchema> +export type UpdateTechVendorCandidateSchema = z.infer<typeof updateTechVendorCandidateSchema> +export type RemoveTechCandidatesInput = z.infer<typeof removeTechCandidatesSchema>
\ No newline at end of file |
