diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-04-28 02:13:30 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-04-28 02:13:30 +0000 |
| commit | ef4c533ebacc2cdc97e518f30e9a9350004fcdfb (patch) | |
| tree | 345251a3ed0f4429716fa5edaa31024d8f4cb560 /lib/vendor-candidates | |
| parent | 9ceed79cf32c896f8a998399bf1b296506b2cd4a (diff) | |
~20250428 작업사항
Diffstat (limited to 'lib/vendor-candidates')
| -rw-r--r-- | lib/vendor-candidates/service.ts | 421 | ||||
| -rw-r--r-- | lib/vendor-candidates/table/add-candidates-dialog.tsx | 171 | ||||
| -rw-r--r-- | lib/vendor-candidates/table/candidates-table-columns.tsx | 104 | ||||
| -rw-r--r-- | lib/vendor-candidates/table/candidates-table-floating-bar.tsx | 416 | ||||
| -rw-r--r-- | lib/vendor-candidates/table/candidates-table-toolbar-actions.tsx | 4 | ||||
| -rw-r--r-- | lib/vendor-candidates/table/candidates-table.tsx | 19 | ||||
| -rw-r--r-- | lib/vendor-candidates/table/delete-candidates-dialog.tsx | 16 | ||||
| -rw-r--r-- | lib/vendor-candidates/table/excel-template-download.tsx | 44 | ||||
| -rw-r--r-- | lib/vendor-candidates/table/import-button.tsx | 78 | ||||
| -rw-r--r-- | lib/vendor-candidates/table/invite-candidates-dialog.tsx | 112 | ||||
| -rw-r--r-- | lib/vendor-candidates/table/update-candidate-sheet.tsx | 112 | ||||
| -rw-r--r-- | lib/vendor-candidates/table/view-candidate_logs-dialog.tsx | 246 | ||||
| -rw-r--r-- | lib/vendor-candidates/validations.ts | 50 |
13 files changed, 1300 insertions, 493 deletions
diff --git a/lib/vendor-candidates/service.ts b/lib/vendor-candidates/service.ts index 68971f18..bfeb3090 100644 --- a/lib/vendor-candidates/service.ts +++ b/lib/vendor-candidates/service.ts @@ -1,6 +1,5 @@ "use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) -import { vendorCandidates} from "@/db/schema/vendors" import { asc, desc, ilike, inArray, and, or, gte, lte, eq, isNull, count } from "drizzle-orm"; import { revalidateTag, unstable_noStore } from "next/cache"; import { filterColumns } from "@/lib/filter-columns"; @@ -10,16 +9,20 @@ import db from "@/db/db"; import { sendEmail } from "../mail/sendEmail"; import { CreateVendorCandidateSchema, createVendorCandidateSchema, GetVendorsCandidateSchema, RemoveCandidatesInput, removeCandidatesSchema, updateVendorCandidateSchema, UpdateVendorCandidateSchema } from "./validations"; import { PgTransaction } from "drizzle-orm/pg-core"; +import { users, vendorCandidateLogs, vendorCandidates, vendorCandidatesWithVendorInfo } from "@/db/schema"; +import { headers } from 'next/headers'; export async function getVendorCandidates(input: GetVendorsCandidateSchema) { 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: vendorCandidates, + table: vendorCandidatesWithVendorInfo, filters: input.filters, joinOperator: input.joinOperator, }) @@ -29,12 +32,16 @@ export async function getVendorCandidates(input: GetVendorsCandidateSchema) { if (input.search) { const s = `%${input.search}%` globalWhere = or( - ilike(vendorCandidates.companyName, s), - ilike(vendorCandidates.contactEmail, s), - ilike(vendorCandidates.contactPhone, s), - ilike(vendorCandidates.country, s), - ilike(vendorCandidates.source, s), - ilike(vendorCandidates.status, s), + ilike(vendorCandidatesWithVendorInfo.companyName, s), + ilike(vendorCandidatesWithVendorInfo.contactEmail, s), + ilike(vendorCandidatesWithVendorInfo.contactPhone, s), + ilike(vendorCandidatesWithVendorInfo.country, s), + ilike(vendorCandidatesWithVendorInfo.source, s), + ilike(vendorCandidatesWithVendorInfo.status, s), + ilike(vendorCandidatesWithVendorInfo.taxId, s), + ilike(vendorCandidatesWithVendorInfo.items, s), + ilike(vendorCandidatesWithVendorInfo.remark, s), + ilike(vendorCandidatesWithVendorInfo.address, s), // etc. ) } @@ -44,6 +51,8 @@ export async function getVendorCandidates(input: GetVendorsCandidateSchema) { const finalWhere = and( advancedWhere, globalWhere, + fromDate ? gte(vendorCandidatesWithVendorInfo.createdAt, fromDate) : undefined, + toDate ? lte(vendorCandidatesWithVendorInfo.createdAt, toDate) : undefined ) @@ -53,17 +62,17 @@ export async function getVendorCandidates(input: GetVendorsCandidateSchema) { input.sort && input.sort.length > 0 ? input.sort.map((item) => item.desc - ? desc(vendorCandidates[item.id]) - : asc(vendorCandidates[item.id]) + ? desc(vendorCandidatesWithVendorInfo[item.id]) + : asc(vendorCandidatesWithVendorInfo[item.id]) ) - : [desc(vendorCandidates.createdAt)] + : [desc(vendorCandidatesWithVendorInfo.createdAt)] // 6) Query & count const { data, total } = await db.transaction(async (tx) => { // a) Select from the view const candidatesData = await tx .select() - .from(vendorCandidates) + .from(vendorCandidatesWithVendorInfo) .where(finalWhere) .orderBy(...orderBy) .offset(offset) @@ -72,7 +81,7 @@ export async function getVendorCandidates(input: GetVendorsCandidateSchema) { // b) Count total const resCount = await tx .select({ count: count() }) - .from(vendorCandidates) + .from(vendorCandidatesWithVendorInfo) .where(finalWhere) return { data: candidatesData, total: resCount[0]?.count } @@ -98,30 +107,48 @@ export async function getVendorCandidates(input: GetVendorsCandidateSchema) { )() } -export async function createVendorCandidate(input: CreateVendorCandidateSchema) { +export async function createVendorCandidate(input: CreateVendorCandidateSchema, userId: number) { try { // Validate input const validated = createVendorCandidateSchema.parse(input); - // Insert into database - const [newCandidate] = await db - .insert(vendorCandidates) - .values({ - companyName: validated.companyName, - contactEmail: validated.contactEmail, - contactPhone: validated.contactPhone || null, - country: validated.country || null, - source: validated.source || null, - status: validated.status, - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning(); + // 트랜잭션으로 데이터 삽입과 로그 기록을 원자적으로 처리 + const result = await db.transaction(async (tx) => { + // Insert into database + const [newCandidate] = await tx + .insert(vendorCandidates) + .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(); + + // 로그에 기록 + await tx.insert(vendorCandidateLogs).values({ + vendorCandidateId: newCandidate.id, + userId: userId, + action: "create", + newStatus: newCandidate.status, + comment: `Created new vendor candidate: ${newCandidate.companyName}` + }); + + return newCandidate; + }); // Invalidate cache revalidateTag("vendor-candidates"); - return { success: true, data: newCandidate }; + return { success: true, data: result }; } catch (error) { console.error("Failed to create vendor candidate:", error); return { success: false, error: getErrorMessage(error) }; @@ -187,60 +214,107 @@ export async function getVendorCandidateCounts() { /** * Update a vendor candidate */ -export async function updateVendorCandidate(input: UpdateVendorCandidateSchema) { +export async function updateVendorCandidate(input: UpdateVendorCandidateSchema, userId: number) { 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(), }; - - // Update database - const [updatedCandidate] = await db - .update(vendorCandidates) - .set(dataToUpdate) - .where(eq(vendorCandidates.id, id)) - .returning(); - - // If status was updated to "INVITED", send email - if (validated.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: `${process.env.NEXT_PUBLIC_APP_URL}/en/partners`, - } + + const result = await db.transaction(async (tx) => { + // 현재 데이터 조회 (상태 변경 감지를 위해) + const [existingCandidate] = await tx + .select() + .from(vendorCandidates) + .where(eq(vendorCandidates.id, id)); + + if (!existingCandidate) { + throw new Error("Vendor candidate not found"); + } + + // Update database + const [updatedCandidate] = await tx + .update(vendorCandidates) + .set(dataToUpdate) + .where(eq(vendorCandidates.id, id)) + .returning(); + + // 로그 작성 + const statusChanged = + updateData.status && + existingCandidate.status !== updateData.status; + + await tx.insert(vendorCandidateLogs).values({ + vendorCandidateId: id, + userId: userId, + action: statusChanged ? "status_change" : "update", + oldStatus: statusChanged ? existingCandidate.status : undefined, + newStatus: statusChanged ? updateData.status : undefined, + comment: statusChanged + ? `Status changed from ${existingCandidate.status} to ${updateData.status}` + : `Updated vendor candidate: ${existingCandidate.companyName}` }); - } + + + + // 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`, + } + }); + + // 이메일 전송 로그 + await tx.insert(vendorCandidateLogs).values({ + vendorCandidateId: id, + userId: userId, + action: "invite_sent", + comment: `Invitation email sent to ${updatedCandidate.contactEmail}` + }); + } + + return updatedCandidate; + }); + // Invalidate cache revalidateTag("vendor-candidates"); - - return { success: true, data: updatedCandidate }; + + return { success: true, data: result }; } catch (error) { console.error("Failed to update vendor candidate:", error); return { success: false, error: getErrorMessage(error) }; } } -/** - * Update status of multiple vendor candidates at once - */ export async function bulkUpdateVendorCandidateStatus({ ids, - status + status, + userId, + comment }: { ids: number[], - status: "COLLECTED" | "INVITED" | "DISCARDED" + status: "COLLECTED" | "INVITED" | "DISCARDED", + userId: number, + comment?: string }) { try { // Validate inputs @@ -252,50 +326,86 @@ export async function bulkUpdateVendorCandidateStatus({ return { success: false, error: "Invalid status" }; } - // Get current data of candidates (needed for email sending) - const candidatesBeforeUpdate = await db - .select() - .from(vendorCandidates) - .where(inArray(vendorCandidates.id, ids)); - - // Update all records - const updatedCandidates = await db - .update(vendorCandidates) - .set({ - status, - updatedAt: new Date(), - }) - .where(inArray(vendorCandidates.id, ids)) - .returning(); - - // If status is "INVITED", send emails to all updated candidates - if (status === "INVITED") { - const emailPromises = updatedCandidates - .filter(candidate => candidate.contactEmail) - .map(candidate => - sendEmail({ - to: candidate.contactEmail!, - subject: "Invitation to Register as a Vendor", - template: "vendor-invitation", - context: { - companyName: candidate.companyName, - language: "en", - registrationLink: `${process.env.NEXT_PUBLIC_APP_URL}/en/partners`, - } - }) - ); - - // Wait for all emails to be sent - await Promise.all(emailPromises); - } + const headersList = await headers(); + const host = headersList.get('host') || 'localhost:3000'; + const baseUrl = `http://${host}` + + const result = await db.transaction(async (tx) => { + // Get current data of candidates (needed for email sending and logging) + const candidatesBeforeUpdate = await tx + .select() + .from(vendorCandidates) + .where(inArray(vendorCandidates.id, ids)); + + // Update all records + const updatedCandidates = await tx + .update(vendorCandidates) + .set({ + status, + updatedAt: new Date(), + }) + .where(inArray(vendorCandidates.id, ids)) + .returning(); + + // 각 후보자에 대한 로그 생성 + const logPromises = candidatesBeforeUpdate.map(candidate => { + if (candidate.status === status) { + // 상태가 변경되지 않은 경우 로그 생성하지 않음 + return Promise.resolve(); + } + + return tx.insert(vendorCandidateLogs).values({ + vendorCandidateId: candidate.id, + userId: userId, + action: "status_change", + oldStatus: candidate.status, + newStatus: status, + comment: comment || `Bulk status update to ${status}` + }); + }); + + await Promise.all(logPromises); + + // 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`, + } + }); + + // 이메일 발송 로그 + await tx.insert(vendorCandidateLogs).values({ + vendorCandidateId: candidate.id, + userId: userId, + action: "invite_sent", + comment: `Invitation email sent to ${candidate.contactEmail}` + }); + }); + + // Wait for all emails to be sent + await Promise.all(emailPromises); + } + + return updatedCandidates; + }); + // Invalidate cache revalidateTag("vendor-candidates"); - - return { - success: true, - data: updatedCandidates, - count: updatedCandidates.length + + return { + success: true, + data: result, + count: result.length }; } catch (error) { console.error("Failed to bulk update vendor candidates:", error); @@ -303,58 +413,111 @@ export async function bulkUpdateVendorCandidateStatus({ } } - - - -/** - * Remove multiple vendor candidates by their IDs - */ -export async function removeCandidates(input: RemoveCandidatesInput) { +// 4. 후보자 삭제 함수 업데이트 +export async function removeCandidates(input: RemoveCandidatesInput, userId: number) { try { // Validate input const validated = removeCandidatesSchema.parse(input); - - // Get candidates before deletion (for logging purposes) - const candidatesBeforeDelete = await db - .select({ - id: vendorCandidates.id, - companyName: vendorCandidates.companyName, - }) - .from(vendorCandidates) - .where(inArray(vendorCandidates.id, validated.ids)); - - // Delete the candidates - const deletedCandidates = await db - .delete(vendorCandidates) - .where(inArray(vendorCandidates.id, validated.ids)) - .returning({ id: vendorCandidates.id }); - + + const result = await db.transaction(async (tx) => { + // Get candidates before deletion (for logging purposes) + const candidatesBeforeDelete = await tx + .select() + .from(vendorCandidates) + .where(inArray(vendorCandidates.id, validated.ids)); + + // 각 삭제될 후보자에 대한 로그 생성 + for (const candidate of candidatesBeforeDelete) { + await tx.insert(vendorCandidateLogs).values({ + vendorCandidateId: candidate.id, + userId: userId, + action: "delete", + oldStatus: candidate.status, + comment: `Deleted vendor candidate: ${candidate.companyName}` + }); + } + + // Delete the candidates + const deletedCandidates = await tx + .delete(vendorCandidates) + .where(inArray(vendorCandidates.id, validated.ids)) + .returning({ id: vendorCandidates.id }); + + return { + deletedCandidates, + candidatesBeforeDelete + }; + }); + // If no candidates were deleted, return an error - if (!deletedCandidates.length) { + if (!result.deletedCandidates.length) { return { success: false, error: "No candidates were found with the provided IDs", }; } - + // Log deletion for audit purposes console.log( - `Deleted ${deletedCandidates.length} vendor candidates:`, - candidatesBeforeDelete.map(c => `${c.id} (${c.companyName})`) + `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: deletedCandidates.length, - deletedIds: deletedCandidates.map(c => c.id), + 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) }; } +} + +export interface CandidateLogWithUser { + id: number + vendorCandidateId: number + userId: number + userName: string | null + userEmail: string | null + action: string + oldStatus: string | null + newStatus: string | null + comment: string | null + createdAt: Date +} + +export async function getCandidateLogs(candidateId: number): Promise<CandidateLogWithUser[]> { + try { + const logs = await db + .select({ + // vendor_candidate_logs 필드 + id: vendorCandidateLogs.id, + vendorCandidateId: vendorCandidateLogs.vendorCandidateId, + userId: vendorCandidateLogs.userId, + action: vendorCandidateLogs.action, + oldStatus: vendorCandidateLogs.oldStatus, + newStatus: vendorCandidateLogs.newStatus, + comment: vendorCandidateLogs.comment, + createdAt: vendorCandidateLogs.createdAt, + + // 조인한 users 테이블 필드 + userName: users.name, + userEmail: users.email, + }) + .from(vendorCandidateLogs) + .leftJoin(users, eq(vendorCandidateLogs.userId, users.id)) + .where(eq(vendorCandidateLogs.vendorCandidateId, candidateId)) + .orderBy(desc(vendorCandidateLogs.createdAt)) + + return logs + } catch (error) { + console.error("Failed to fetch candidate logs with user info:", error) + throw error + } }
\ No newline at end of file diff --git a/lib/vendor-candidates/table/add-candidates-dialog.tsx b/lib/vendor-candidates/table/add-candidates-dialog.tsx index db475064..733d3716 100644 --- a/lib/vendor-candidates/table/add-candidates-dialog.tsx +++ b/lib/vendor-candidates/table/add-candidates-dialog.tsx @@ -8,10 +8,13 @@ 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, @@ -36,19 +39,9 @@ import { FormMessage, } from "@/components/ui/form" -// shadcn/ui Select -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" import { createVendorCandidateSchema, CreateVendorCandidateSchema } from "../validations" import { createVendorCandidate } from "../service" -import { vendorCandidates } from "@/db/schema/vendors" // Register locales for countries i18nIsoCountries.registerLocale(enLocale) @@ -65,34 +58,65 @@ const countryArray = Object.entries(countryMap).map(([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: "", + contactEmail: "", // 이제 빈 문자열이 허용됨 contactPhone: "", + taxId: "", + address: "", country: "", source: "", - status: "COLLECTED", // Default status set to COLLECTED + items: "", + remark: "", + status: "COLLECTED", }, - }) + }); async function onSubmit(data: CreateVendorCandidateSchema) { setIsSubmitting(true) try { - const result = await createVendorCandidate(data) + // 세션 유효성 검사 + 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) { - alert(`에러: ${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) - alert("An unexpected error occurred") + toast({ + title: "오류 발생", + description: "예상치 못한 오류가 발생했습니다.", + variant: "destructive", + }) } finally { setIsSubmitting(false) } @@ -114,7 +138,7 @@ export function AddCandidateDialog() { </Button> </DialogTrigger> - <DialogContent className="sm:max-w-[425px]"> + <DialogContent className="sm:max-w-[525px]"> <DialogHeader> <DialogTitle>Create New Vendor Candidate</DialogTitle> <DialogDescription> @@ -124,17 +148,15 @@ export function AddCandidateDialog() { {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */} <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)}> - <div className="space-y-4 py-4"> + <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 className="after:content-['*'] after:ml-0.5 after:text-red-500"> - Company Name - </FormLabel> + <FormLabel>Company Name <span className="text-red-500">*</span></FormLabel> <FormControl> <Input placeholder="Enter company name" @@ -147,15 +169,32 @@ export function AddCandidateDialog() { )} /> + {/* 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 className="after:content-['*'] after:ml-0.5 after:text-red-500"> - Contact Email - </FormLabel> + <FormLabel>Contact Email</FormLabel> <FormControl> <Input placeholder="email@example.com" @@ -188,6 +227,25 @@ export function AddCandidateDialog() { )} /> + {/* 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} @@ -260,7 +318,7 @@ export function AddCandidateDialog() { name="source" render={({ field }) => ( <FormItem> - <FormLabel>Source</FormLabel> + <FormLabel>Source <span className="text-red-500">*</span></FormLabel> <FormControl> <Input placeholder="Where this candidate was found" @@ -273,37 +331,46 @@ export function AddCandidateDialog() { )} /> - {/* Status 필드 */} - {/* <FormField + + {/* Items 필드 (새로 추가) */} + <FormField control={form.control} - name="status" + name="items" render={({ field }) => ( - <FormItem> - <FormLabel>Status</FormLabel> - <Select - onValueChange={field.onChange} - defaultValue={field.value} - disabled={isSubmitting} - > - <FormControl> - <SelectTrigger> - <SelectValue placeholder="Select status" /> - </SelectTrigger> - </FormControl> - <SelectContent> - <SelectGroup> - {vendorCandidates.status.enumValues.map((status) => ( - <SelectItem key={status} value={status}> - {status} - </SelectItem> - ))} - </SelectGroup> - </SelectContent> - </Select> + <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> diff --git a/lib/vendor-candidates/table/candidates-table-columns.tsx b/lib/vendor-candidates/table/candidates-table-columns.tsx index dc014d4e..113927cf 100644 --- a/lib/vendor-candidates/table/candidates-table-columns.tsx +++ b/lib/vendor-candidates/table/candidates-table-columns.tsx @@ -7,7 +7,7 @@ import { Ellipsis } from "lucide-react" import { toast } from "sonner" import { getErrorMessage } from "@/lib/handle-error" -import { formatDate } from "@/lib/utils" +import { formatDate, formatDateTime } from "@/lib/utils" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" @@ -24,24 +24,24 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" -import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header" -import { VendorCandidates, vendorCandidates } from "@/db/schema/vendors" 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<VendorCandidates> | null>> + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorCandidatesWithVendorInfo> | null>> } /** * tanstack table 컬럼 정의 (중첩 헤더 버전) */ -export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorCandidates>[] { +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorCandidatesWithVendorInfo>[] { // ---------------------------------------------------------------- // 1) select 컬럼 (체크박스) // ---------------------------------------------------------------- - const selectColumn: ColumnDef<VendorCandidates> = { + const selectColumn: ColumnDef<VendorCandidatesWithVendorInfo> = { id: "select", header: ({ table }) => ( <Checkbox @@ -70,48 +70,54 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorC // ---------------------------------------------------------------- // 2) actions 컬럼 (Dropdown 메뉴) // ---------------------------------------------------------------- - const actionsColumn: ColumnDef<VendorCandidates> = { - id: "actions", - enableHiding: false, - cell: function Cell({ row }) { - const [isUpdatePending, startUpdateTransition] = React.useTransition() - - return ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - aria-label="Open menu" - variant="ghost" - className="flex size-8 p-0 data-[state=open]:bg-muted" - > - <Ellipsis className="size-4" aria-hidden="true" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="w-40"> - <DropdownMenuItem - onSelect={() => setRowAction({ row, type: "update" })} - > - Edit - </DropdownMenuItem> - - <DropdownMenuItem - onSelect={() => setRowAction({ row, type: "delete" })} - > - Delete - <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - ) - }, - size: 40, - } +// "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<VendorCandidates>[] } - const groupMap: Record<string, ColumnDef<VendorCandidates>[]> = {} + // 3-1) groupMap: { [groupName]: ColumnDef<VendorCandidatesWithVendorInfo>[] } + const groupMap: Record<string, ColumnDef<VendorCandidatesWithVendorInfo>[]> = {} candidateColumnsConfig.forEach((cfg) => { // 만약 group가 없으면 "_noGroup" 처리 @@ -122,11 +128,11 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorC } // child column 정의 - const childCol: ColumnDef<VendorCandidates> = { + const childCol: ColumnDef<VendorCandidatesWithVendorInfo> = { accessorKey: cfg.id, enableResizing: true, header: ({ column }) => ( - <DataTableColumnHeader column={column} title={cfg.label} /> + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> ), meta: { excelHeader: cfg.excelHeader, @@ -148,9 +154,9 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorC } - if (cfg.id === "createdAt") { + if (cfg.id === "createdAt" ||cfg.id === "updatedAt" ) { const dateVal = cell.getValue() as Date - return formatDate(dateVal) + return formatDateTime(dateVal) } // code etc... @@ -164,7 +170,7 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorC // ---------------------------------------------------------------- // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 // ---------------------------------------------------------------- - const nestedColumns: ColumnDef<VendorCandidates>[] = [] + const nestedColumns: ColumnDef<VendorCandidatesWithVendorInfo>[] = [] // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 // 여기서는 그냥 Object.entries 순서 diff --git a/lib/vendor-candidates/table/candidates-table-floating-bar.tsx b/lib/vendor-candidates/table/candidates-table-floating-bar.tsx index 2696292d..baf4a583 100644 --- a/lib/vendor-candidates/table/candidates-table-floating-bar.tsx +++ b/lib/vendor-candidates/table/candidates-table-floating-bar.tsx @@ -30,37 +30,48 @@ import { 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 { vendorCandidates, VendorCandidates } from "@/db/schema/vendors" -import { bulkUpdateVendorCandidateStatus, removeCandidates, updateVendorCandidate } from "../service" +import { VendorCandidatesWithVendorInfo, vendorCandidates } from "@/db/schema/vendors" +import { + bulkUpdateVendorCandidateStatus, + removeCandidates, +} from "../service" +/** + * 테이블 상단/하단에 고정되는 Floating Bar + * 상태 일괄 변경, 초대, 삭제, Export 등을 수행 + */ interface CandidatesTableFloatingBarProps { - table: Table<VendorCandidates> + table: Table<VendorCandidatesWithVendorInfo> } -export function VendorCandidateTableFloatingBar({ table }: CandidatesTableFloatingBarProps) { +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) - // Clear selection on Escape key press + // 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 state + // 공용 Confirm Dialog (ActionConfirmDialog) 제어 const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false) const [confirmProps, setConfirmProps] = React.useState<{ title: string @@ -69,25 +80,41 @@ export function VendorCandidateTableFloatingBar({ table }: CandidatesTableFloati }>({ title: "", description: "", - onConfirm: () => { }, + onConfirm: () => {}, }) - // 1) "삭제" Confirm 열기 + /** + * 1) 삭제 버튼 클릭 시 Confirm Dialog 열기 + */ function handleDeleteConfirm() { setAction("delete") + setConfirmProps({ - title: `Delete ${rows.length} user${rows.length > 1 ? "s" : ""}?`, + title: `Delete ${rows.length} candidate${ + rows.length > 1 ? "s" : "" + }?`, description: "This action cannot be undone.", onConfirm: async () => { startTransition(async () => { - const { error } = await removeCandidates({ - ids: rows.map((row) => row.original.id), - }) + 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("Users deleted") + toast.success("Candidates deleted successfully") table.toggleAllRowsSelected(false) setConfirmDialogOpen(false) }) @@ -96,43 +123,71 @@ export function VendorCandidateTableFloatingBar({ table }: CandidatesTableFloati setConfirmDialogOpen(true) } - // 2) 상태 업데이트 - function handleSelectStatus(newStatus: VendorCandidates["status"]) { + /** + * 2) 선택된 후보들의 상태 일괄 업데이트 + */ + function handleSelectStatus(newStatus: VendorCandidatesWithVendorInfo["status"]) { setAction("update-status") setConfirmProps({ - title: `Update ${rows.length} candidate${rows.length > 1 ? "s" : ""} with status: ${newStatus}?`, + 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) 초대하기 (INVITED 상태로 바꾸고 이메일 전송) + /** + * 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.", + 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 @@ -147,166 +202,168 @@ export function VendorCandidateTableFloatingBar({ table }: CandidatesTableFloati } return ( - <Portal > - <div className="fixed inset-x-0 bottom-10 z-50 mx-auto w-fit px-2.5" style={{ bottom: '1.5rem' }}> - <div className="w-full overflow-x-auto"> - <div className="mx-auto flex w-fit items-center gap-2 rounded-md border bg-background p-2 text-foreground shadow"> - <div className="flex h-7 items-center rounded-md border border-dashed pl-2.5 pr-1"> - <span className="whitespace-nowrap text-xs"> - {rows.length} selected - </span> - <Separator orientation="vertical" className="ml-2 mr-1" /> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="ghost" - size="icon" - className="size-5 hover:border" - onClick={() => table.toggleAllRowsSelected(false)} - > - <X className="size-3.5 shrink-0" aria-hidden="true" /> - </Button> - </TooltipTrigger> - <TooltipContent className="flex items-center border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900"> - <p className="mr-2">Clear selection</p> - <Kbd abbrTitle="Escape" variant="outline"> - Esc - </Kbd> - </TooltipContent> - </Tooltip> - </div> - <Separator orientation="vertical" className="hidden h-5 sm:block" /> - <div className="flex items-center gap-1.5"> - {/* 초대하기 버튼 (새로 추가) */} - <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> + <> + {/* 선택된 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> - <Select - onValueChange={(value: VendorCandidates["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> + <Separator orientation="vertical" className="hidden h-5 sm:block" /> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="secondary" - size="icon" - className="size-7 border" - onClick={() => { - setAction("export") + {/* 우측 액션들: 초대, 상태변경, 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> - 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> - + {/* 상태 업데이트 (Select) */} + <Select + onValueChange={(value: VendorCandidatesWithVendorInfo["status"]) => { + handleSelectStatus(value) + }} + > <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> + <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>Delete candidates</p> + <p>Update status</p> </TooltipContent> </Tooltip> - </div> + <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> @@ -318,7 +375,10 @@ export function VendorCandidateTableFloatingBar({ table }: CandidatesTableFloati title={confirmProps.title} description={confirmProps.description} onConfirm={confirmProps.onConfirm} - isLoading={isPending && (action === "delete" || action === "update-status" || action === "invite")} + isLoading={ + isPending && + (action === "delete" || action === "update-status" || action === "invite") + } confirmLabel={ action === "delete" ? "Delete" @@ -328,10 +388,8 @@ export function VendorCandidateTableFloatingBar({ table }: CandidatesTableFloati ? "Invite" : "Confirm" } - confirmVariant={ - action === "delete" ? "destructive" : "default" - } + confirmVariant={action === "delete" ? "destructive" : "default"} /> - </Portal> + </> ) -}
\ No newline at end of file +} diff --git a/lib/vendor-candidates/table/candidates-table-toolbar-actions.tsx b/lib/vendor-candidates/table/candidates-table-toolbar-actions.tsx index a2229a54..17462841 100644 --- a/lib/vendor-candidates/table/candidates-table-toolbar-actions.tsx +++ b/lib/vendor-candidates/table/candidates-table-toolbar-actions.tsx @@ -14,15 +14,15 @@ import { } from "@/components/ui/dropdown-menu" import { AddCandidateDialog } from "./add-candidates-dialog" -import { VendorCandidates } from "@/db/schema/vendors" 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<VendorCandidates> + table: Table<VendorCandidatesWithVendorInfo> } export function CandidatesTableToolbarActions({ table }: CandidatesTableToolbarActionsProps) { diff --git a/lib/vendor-candidates/table/candidates-table.tsx b/lib/vendor-candidates/table/candidates-table.tsx index 2c01733c..e36649b5 100644 --- a/lib/vendor-candidates/table/candidates-table.tsx +++ b/lib/vendor-candidates/table/candidates-table.tsx @@ -11,17 +11,17 @@ 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 { DataTableToolbar } from "@/components/data-table/data-table-toolbar" import { useFeatureFlags } from "./feature-flags-provider" import { getVendorCandidateCounts, getVendorCandidates } from "../service" -import { VendorCandidates, vendorCandidates } from "@/db/schema/vendors" +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" +import { ViewCandidateLogsDialog } from "./view-candidate_logs-dialog" interface VendorCandidatesTableProps { promises: Promise< @@ -41,7 +41,7 @@ export function VendorCandidateTable({ promises }: VendorCandidatesTableProps) { const [rowAction, setRowAction] = - React.useState<DataTableRowAction<VendorCandidates> | null>(null) + React.useState<DataTableRowAction<VendorCandidatesWithVendorInfo> | null>(null) const columns = React.useMemo( () => getColumns({ setRowAction }), @@ -59,7 +59,7 @@ export function VendorCandidateTable({ promises }: VendorCandidatesTableProps) { * @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<VendorCandidates>[] = [ + const filterFields: DataTableFilterField<VendorCandidatesWithVendorInfo>[] = [ { id: "status", @@ -83,7 +83,7 @@ export function VendorCandidateTable({ promises }: VendorCandidatesTableProps) { * 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<VendorCandidates>[] = [ + const advancedFilterFields: DataTableAdvancedFilterField<VendorCandidatesWithVendorInfo>[] = [ { id: "companyName", label: "Company Name", @@ -109,7 +109,7 @@ export function VendorCandidateTable({ promises }: VendorCandidatesTableProps) { label: "Status", type: "multi-select", options: vendorCandidates.status.enumValues.map((status) => ({ - label: toSentenceCase(status), + label: (status), value: status, icon: getCandidateStatusIcon(status), count: statusCounts[status], @@ -118,7 +118,7 @@ export function VendorCandidateTable({ promises }: VendorCandidatesTableProps) { { id: "createdAt", - label: "Created at", + label: "수집일", type: "date", }, ] @@ -168,6 +168,11 @@ export function VendorCandidateTable({ promises }: VendorCandidatesTableProps) { showTrigger={false} onSuccess={() => rowAction?.row.toggleSelected(false)} /> + <ViewCandidateLogsDialog + open={rowAction?.type === "log"} + onOpenChange={() => setRowAction(null)} + candidateId={rowAction?.row.original?.id ?? null} + /> </> ) } diff --git a/lib/vendor-candidates/table/delete-candidates-dialog.tsx b/lib/vendor-candidates/table/delete-candidates-dialog.tsx index e9fabf76..bc231109 100644 --- a/lib/vendor-candidates/table/delete-candidates-dialog.tsx +++ b/lib/vendor-candidates/table/delete-candidates-dialog.tsx @@ -28,12 +28,13 @@ import { DrawerTrigger, } from "@/components/ui/drawer" -import { VendorCandidates } from "@/db/schema/vendors" 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<VendorCandidates>["original"][] + candidates: Row<VendorCandidatesWithVendorInfo>["original"][] showTrigger?: boolean onSuccess?: () => void } @@ -46,12 +47,21 @@ export function DeleteCandidatesDialog({ }: 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) diff --git a/lib/vendor-candidates/table/excel-template-download.tsx b/lib/vendor-candidates/table/excel-template-download.tsx index b69ab821..673680db 100644 --- a/lib/vendor-candidates/table/excel-template-download.tsx +++ b/lib/vendor-candidates/table/excel-template-download.tsx @@ -16,10 +16,14 @@ export async function exportVendorCandidateTemplate() { // 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 }, ] @@ -27,7 +31,7 @@ export async function exportVendorCandidateTemplate() { worksheet.columns = columns // Style the header row - const headerRow = worksheet.getRow(1) + const headerRow = worksheet.getRow(2) headerRow.font = { bold: true } headerRow.alignment = { horizontal: "center" } headerRow.eachCell((cell) => { @@ -36,24 +40,39 @@ export async function exportVendorCandidateTemplate() { 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", }, ] @@ -65,8 +84,11 @@ export async function exportVendorCandidateTemplate() { // Add data validation for Status column const statusValues = ["COLLECTED", "INVITED", "DISCARDED"] - for (let i = 2; i <= 100; i++) { // Apply to rows 2-100 - worksheet.getCell(`F${i}`).dataValidation = { + 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(',')}"`] @@ -74,11 +96,23 @@ export async function exportVendorCandidateTemplate() { } // Add instructions row - worksheet.insertRow(1, ["Please fill in the data below. Required fields: Company Name, Contact Email"]) - worksheet.mergeCells("A1:F1") + 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() diff --git a/lib/vendor-candidates/table/import-button.tsx b/lib/vendor-candidates/table/import-button.tsx index 1a2a4f7c..b1dd43a9 100644 --- a/lib/vendor-candidates/table/import-button.tsx +++ b/lib/vendor-candidates/table/import-button.tsx @@ -7,6 +7,7 @@ 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 세션 훅 추가 interface ImportExcelProps { onSuccess?: () => void @@ -15,24 +16,25 @@ interface ImportExcelProps { export function ImportVendorCandidatesButton({ onSuccess }: ImportExcelProps) { const fileInputRef = useRef<HTMLInputElement>(null) const [isImporting, setIsImporting] = React.useState(false) + const { data: session, status } = useSession() // 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: any) => rt.text).join(''); } - + // Handle dates if (cell.value instanceof Date) { return cell.value.toISOString().split('T')[0]; } - + // Fallback return String(cell.value); } @@ -42,55 +44,55 @@ export function ImportVendorCandidatesButton({ onSuccess }: ImportExcelProps) { if (!file) return setIsImporting(true) - + try { // Read the Excel file using ExcelJS const data = await file.arrayBuffer() 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; - + 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: any[] = []; 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; + const isExample = i === startRow && worksheet.getRow(i + 1).values?.length === 0; if (isExample) continue; - + const rowData: Record<string, any> = {}; let hasData = false; - + // Map the data using header indices Object.entries(columnIndices).forEach(([header, colIndex]) => { const value = getCellValueAsString(row.getCell(colIndex)); @@ -99,22 +101,22 @@ export function ImportVendorCandidatesButton({ onSuccess }: ImportExcelProps) { 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 { @@ -123,28 +125,40 @@ export function ImportVendorCandidatesButton({ onSuccess }: ImportExcelProps) { 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.contactEmail) { + 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 + } + + const userId = session.user.id + // Create the vendor candidate - const result = await createVendorCandidate(candidateData); - + const result = await createVendorCandidate(candidateData, Number(userId)) + if (result.error) { console.error(`Failed to import row: ${result.error}`, candidateData); errorCount++; return null; } - + successCount++; return result.data; } catch (error) { @@ -153,10 +167,10 @@ export function ImportVendorCandidatesButton({ onSuccess }: ImportExcelProps) { return null; } }); - + // Wait for all operations to complete await Promise.all(promises); - + // Show results if (successCount > 0) { toast.success(`Successfully imported ${successCount} vendor candidates`); @@ -168,7 +182,7 @@ export function ImportVendorCandidatesButton({ onSuccess }: ImportExcelProps) { } 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."); diff --git a/lib/vendor-candidates/table/invite-candidates-dialog.tsx b/lib/vendor-candidates/table/invite-candidates-dialog.tsx index 366b6f45..45cf13c3 100644 --- a/lib/vendor-candidates/table/invite-candidates-dialog.tsx +++ b/lib/vendor-candidates/table/invite-candidates-dialog.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { type Row } from "@tanstack/react-table" -import { Loader, Mail } from "lucide-react" +import { Loader, Mail, AlertCircle, XCircle } from "lucide-react" import { toast } from "sonner" import { useMediaQuery } from "@/hooks/use-media-query" @@ -27,9 +27,15 @@ import { 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> { @@ -46,12 +52,35 @@ export function InviteCandidatesDialog({ }: 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 userId = Number(session.user.id) const { error } = await bulkUpdateVendorCandidateStatus({ - ids: candidates.map((candidate) => candidate.id), + ids: candidatesWithEmail.map((candidate) => candidate.id), status: "INVITED", + userId, + comment: "Bulk invite action", }) if (error) { @@ -60,11 +89,72 @@ export function InviteCandidatesDialog({ } props.onOpenChange?.(false) - toast.success("Invitation emails sent") + + 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}> @@ -79,12 +169,8 @@ export function InviteCandidatesDialog({ <DialogContent> <DialogHeader> <DialogTitle>Send invitations?</DialogTitle> - <DialogDescription> - This will send invitation emails to{" "} - <span className="font-medium">{candidates.length}</span> - {candidates.length === 1 ? " candidate" : " candidates"} and change their status to INVITED. - </DialogDescription> </DialogHeader> + {DialogComponent} <DialogFooter className="gap-2 sm:space-x-0"> <DialogClose asChild> <Button variant="outline">Cancel</Button> @@ -93,7 +179,7 @@ export function InviteCandidatesDialog({ aria-label="Invite selected vendors" variant="default" onClick={onInvite} - disabled={isInvitePending} + disabled={disableInviteButton} > {isInvitePending && ( <Loader @@ -122,12 +208,8 @@ export function InviteCandidatesDialog({ <DrawerContent> <DrawerHeader> <DrawerTitle>Send invitations?</DrawerTitle> - <DrawerDescription> - This will send invitation emails to{" "} - <span className="font-medium">{candidates.length}</span> - {candidates.length === 1 ? " candidate" : " candidates"} and change their status to INVITED. - </DrawerDescription> </DrawerHeader> + {DialogComponent} <DrawerFooter className="gap-2 sm:space-x-0"> <DrawerClose asChild> <Button variant="outline">Cancel</Button> @@ -136,7 +218,7 @@ export function InviteCandidatesDialog({ aria-label="Invite selected vendors" variant="default" onClick={onInvite} - disabled={isInvitePending} + disabled={disableInviteButton} > {isInvitePending && ( <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> diff --git a/lib/vendor-candidates/table/update-candidate-sheet.tsx b/lib/vendor-candidates/table/update-candidate-sheet.tsx index c475210b..3d278126 100644 --- a/lib/vendor-candidates/table/update-candidate-sheet.tsx +++ b/lib/vendor-candidates/table/update-candidate-sheet.tsx @@ -1,7 +1,6 @@ "use client" import * as React from "react" -import { vendorCandidates, VendorCandidates } from "@/db/schema/vendors" import { zodResolver } from "@hookform/resolvers/zod" import { Check, ChevronsUpDown, Loader } from "lucide-react" import { useForm } from "react-hook-form" @@ -38,6 +37,7 @@ import { SheetTitle, } from "@/components/ui/sheet" import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" import { Popover, PopoverContent, @@ -51,9 +51,11 @@ import { 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) @@ -69,47 +71,65 @@ const countryArray = Object.entries(countryMap).map(([code, label]) => ({ interface UpdateCandidateSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { - candidate: VendorCandidates | null + 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, - contactEmail: candidate.contactEmail, + 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) @@ -124,7 +144,7 @@ export function UpdateCandidateSheet({ candidate, ...props }: UpdateCandidateShe return ( <Sheet {...props}> - <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetContent className="flex flex-col gap-6 sm:max-w-md overflow-y-auto"> <SheetHeader className="text-left"> <SheetTitle>Update Vendor Candidate</SheetTitle> <SheetDescription> @@ -142,7 +162,7 @@ export function UpdateCandidateSheet({ candidate, ...props }: UpdateCandidateShe name="companyName" render={({ field }) => ( <FormItem> - <FormLabel>Company Name</FormLabel> + <FormLabel>Company Name <span className="text-red-500">*</span></FormLabel> <FormControl> <Input placeholder="Enter company name" @@ -155,6 +175,25 @@ export function UpdateCandidateSheet({ candidate, ...props }: UpdateCandidateShe )} /> + {/* 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} @@ -194,6 +233,25 @@ export function UpdateCandidateSheet({ candidate, ...props }: UpdateCandidateShe )} /> + {/* 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} @@ -266,7 +324,7 @@ export function UpdateCandidateSheet({ candidate, ...props }: UpdateCandidateShe name="source" render={({ field }) => ( <FormItem> - <FormLabel>Source</FormLabel> + <FormLabel>Source <span className="text-red-500">*</span></FormLabel> <FormControl> <Input placeholder="Where this candidate was found" @@ -279,6 +337,46 @@ export function UpdateCandidateSheet({ candidate, ...props }: UpdateCandidateShe )} /> + {/* 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} diff --git a/lib/vendor-candidates/table/view-candidate_logs-dialog.tsx b/lib/vendor-candidates/table/view-candidate_logs-dialog.tsx new file mode 100644 index 00000000..6d119bf3 --- /dev/null +++ b/lib/vendor-candidates/table/view-candidate_logs-dialog.tsx @@ -0,0 +1,246 @@ +"use client" + +import * as React from "react" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { ScrollArea } from "@/components/ui/scroll-area" +import { formatDateTime } from "@/lib/utils" +import { CandidateLogWithUser, getCandidateLogs } from "../service" +import { useToast } from "@/hooks/use-toast" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Badge } from "@/components/ui/badge" +import { Download, Search, User } from "lucide-react" + +interface ViewCandidateLogsDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + candidateId: number | null +} + +export function ViewCandidateLogsDialog({ + open, + onOpenChange, + candidateId, +}: ViewCandidateLogsDialogProps) { + const [logs, setLogs] = React.useState<CandidateLogWithUser[]>([]) + const [filteredLogs, setFilteredLogs] = React.useState<CandidateLogWithUser[]>([]) + const [loading, setLoading] = React.useState(false) + const [error, setError] = React.useState<string | null>(null) + const [searchQuery, setSearchQuery] = React.useState("") + const [actionFilter, setActionFilter] = React.useState<string>("all") + const { toast } = useToast() + + // Get unique action types for filter dropdown + const actionTypes = React.useMemo(() => { + if (!logs.length) return [] + return Array.from(new Set(logs.map(log => log.action))) + }, [logs]) + + // Fetch logs when dialog opens + React.useEffect(() => { + if (open && candidateId) { + setLoading(true) + setError(null) + getCandidateLogs(candidateId) + .then((res) => { + setLogs(res) + setFilteredLogs(res) + }) + .catch((err) => { + console.error(err) + setError("Failed to load logs. Please try again.") + toast({ + variant: "destructive", + title: "Error", + description: "Failed to load candidate logs", + }) + }) + .finally(() => setLoading(false)) + } else { + // Reset state when dialog closes + setSearchQuery("") + setActionFilter("all") + } + }, [open, candidateId, toast]) + + // Filter logs based on search query and action filter + React.useEffect(() => { + if (!logs.length) return + + let result = [...logs] + + // Apply action filter + if (actionFilter !== "all") { + result = result.filter(log => log.action === actionFilter) + } + + // Apply search filter (case insensitive) + if (searchQuery) { + const query = searchQuery.toLowerCase() + result = result.filter(log => + log.action.toLowerCase().includes(query) || + (log.comment && log.comment.toLowerCase().includes(query)) || + (log.oldStatus && log.oldStatus.toLowerCase().includes(query)) || + (log.newStatus && log.newStatus.toLowerCase().includes(query)) || + (log.userName && log.userName.toLowerCase().includes(query)) || + (log.userEmail && log.userEmail.toLowerCase().includes(query)) + ) + } + + setFilteredLogs(result) + }, [logs, searchQuery, actionFilter]) + + // Export logs as CSV + const exportLogs = () => { + if (!filteredLogs.length) return + + const headers = ["Action", "Old Status", "New Status", "Comment", "User", "Email", "Date"] + const csvContent = [ + headers.join(","), + ...filteredLogs.map(log => [ + `"${log.action}"`, + `"${log.oldStatus || ''}"`, + `"${log.newStatus || ''}"`, + `"${log.comment?.replace(/"/g, '""') || ''}"`, + `"${log.userName || ''}"`, + `"${log.userEmail || ''}"`, + `"${formatDateTime(log.createdAt)}"` + ].join(",")) + ].join("\n") + + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.setAttribute("href", url) + link.setAttribute("download", `candidate-logs-${candidateId}-${new Date().toISOString().split('T')[0]}.csv`) + link.style.visibility = "hidden" + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + } + + // Render status change with appropriate badge + const renderStatusChange = (oldStatus: string, newStatus: string) => { + return ( + <div className="text-sm flex flex-wrap gap-2 items-center"> + <strong>Status:</strong> + <Badge variant="outline" className="text-xs">{oldStatus}</Badge> + <span>→</span> + <Badge variant="outline" className="bg-primary/10 text-xs">{newStatus}</Badge> + </div> + ) + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[700px]"> + <DialogHeader> + <DialogTitle>Audit Logs</DialogTitle> + </DialogHeader> + + {/* Filters and search */} + {/* Filters and search */} + <div className="flex items-center gap-2 mb-4"> + <div className="relative flex-1"> + <div className="absolute inset-y-0 left-0 flex items-center pl-2 pointer-events-none"> + <Search className="h-4 w-4 text-muted-foreground" /> + </div> + <Input + placeholder="Search logs..." + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + className="pl-8" + /> + </div> + + <Select + value={actionFilter} + onValueChange={setActionFilter} + > + <SelectTrigger className="w-[180px]"> + <SelectValue placeholder="Filter by action" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="all">All actions</SelectItem> + {actionTypes.map(action => ( + <SelectItem key={action} value={action}>{action}</SelectItem> + ))} + </SelectContent> + </Select> + + <Button + size="icon" + variant="outline" + onClick={exportLogs} + disabled={filteredLogs.length === 0} + title="Export to CSV" + > + <Download className="h-4 w-4" /> + </Button> + </div> + + <div className="space-y-2"> + {loading && ( + <div className="flex justify-center py-8"> + <p className="text-muted-foreground">Loading logs...</p> + </div> + )} + + {error && !loading && ( + <div className="bg-destructive/10 text-destructive p-3 rounded-md"> + {error} + </div> + )} + + {!loading && !error && filteredLogs.length === 0 && ( + <p className="text-muted-foreground text-center py-8"> + {logs.length > 0 ? "No logs match your search criteria." : "No logs found for this candidate."} + </p> + )} + + {!loading && !error && filteredLogs.length > 0 && ( + <> + <div className="text-xs text-muted-foreground mb-2"> + Showing {filteredLogs.length} {filteredLogs.length === 1 ? 'log' : 'logs'} + {filteredLogs.length !== logs.length && ` (filtered from ${logs.length})`} + </div> + <ScrollArea className="max-h-96 space-y-4 pr-4"> + {filteredLogs.map((log) => ( + <div key={log.id} className="rounded-md border p-4 mb-3 hover:bg-muted/50 transition-colors"> + <div className="flex justify-between items-start mb-2"> + <Badge className="text-xs">{log.action}</Badge> + <div className="text-xs text-muted-foreground"> + {formatDateTime(log.createdAt)} + </div> + </div> + + {log.oldStatus && log.newStatus && ( + <div className="my-2"> + {renderStatusChange(log.oldStatus, log.newStatus)} + </div> + )} + + {log.comment && ( + <div className="my-2 text-sm bg-muted/50 p-2 rounded-md"> + <strong>Comment:</strong> {log.comment} + </div> + )} + + {(log.userName || log.userEmail) && ( + <div className="mt-3 pt-2 border-t flex items-center text-xs text-muted-foreground"> + <User className="h-3 w-3 mr-1" /> + {log.userName || "Unknown"} + {log.userEmail && <span className="ml-1">({log.userEmail})</span>} + </div> + )} + </div> + ))} + </ScrollArea> + </> + )} + </div> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/vendor-candidates/validations.ts b/lib/vendor-candidates/validations.ts index 0abb568e..f42d4d3f 100644 --- a/lib/vendor-candidates/validations.ts +++ b/lib/vendor-candidates/validations.ts @@ -1,4 +1,4 @@ -import { vendorCandidates } from "@/db/schema/vendors" +import { vendorCandidates, vendorCandidatesWithVendorInfo } from "@/db/schema/vendors" import { createSearchParamsCache, parseAsArrayOf, @@ -12,13 +12,14 @@ import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" export const searchParamsCandidateCache = 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 vendorCandidates.$inferSelect>().withDefault([ + sort: getSortingStateParser<typeof vendorCandidatesWithVendorInfo.$inferSelect>().withDefault([ { id: "createdAt", desc: true }, ]), @@ -53,22 +54,45 @@ export type GetVendorsCandidateSchema = Awaited<ReturnType<typeof searchParamsCa // Updated version of the updateVendorCandidateSchema export const updateVendorCandidateSchema = z.object({ id: z.number(), - companyName: z.string().min(1).max(255).optional(), - contactEmail: z.string().email().max(255).optional(), - contactPhone: z.string().max(50).optional(), - country: z.string().max(100).optional(), - source: z.string().max(100).optional(), - status: z.enum(["COLLECTED", "INVITED", "DISCARDED"]).optional(), + // 필수 필드 + 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: z.string().email().max(255), + companyName: z.string().min(1, "회사명은 필수입니다").max(255), + // 빈 문자열을 undefined로 변환하여 optional 처리 + contactEmail: z.string().email("유효한 이메일 형식이 아닙니다").or(z.literal('')).optional(), contactPhone: z.string().max(50).optional(), country: z.string().max(100).optional(), - source: 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"), }); |
