diff options
Diffstat (limited to 'lib/tech-vendors/table')
| -rw-r--r-- | lib/tech-vendors/table/add-vendor-dialog.tsx | 74 | ||||
| -rw-r--r-- | lib/tech-vendors/table/invite-tech-vendor-dialog.tsx | 184 | ||||
| -rw-r--r-- | lib/tech-vendors/table/tech-vendors-table-columns.tsx | 117 | ||||
| -rw-r--r-- | lib/tech-vendors/table/tech-vendors-table-toolbar-actions.tsx | 16 | ||||
| -rw-r--r-- | lib/tech-vendors/table/tech-vendors-table.tsx | 5 |
5 files changed, 302 insertions, 94 deletions
diff --git a/lib/tech-vendors/table/add-vendor-dialog.tsx b/lib/tech-vendors/table/add-vendor-dialog.tsx index da9880d4..22c03bcc 100644 --- a/lib/tech-vendors/table/add-vendor-dialog.tsx +++ b/lib/tech-vendors/table/add-vendor-dialog.tsx @@ -51,6 +51,7 @@ const addVendorSchema = z.object({ representativeEmail: z.string().email("올바른 이메일 주소를 입력해주세요").optional().or(z.literal("")),
representativePhone: z.string().optional(),
representativeBirth: z.string().optional(),
+ isQuoteComparison: z.boolean().default(false),
})
type AddVendorFormData = z.infer<typeof addVendorSchema>
@@ -84,6 +85,7 @@ export function AddVendorDialog({ onSuccess }: AddVendorDialogProps) { representativeEmail: "",
representativePhone: "",
representativeBirth: "",
+ isQuoteComparison: false,
},
})
@@ -108,6 +110,7 @@ export function AddVendorDialog({ onSuccess }: AddVendorDialogProps) { representativePhone: data.representativePhone || null,
representativeBirth: data.representativeBirth || null,
taxId: data.taxId || "",
+ isQuoteComparison: data.isQuoteComparison,
})
if (result.success) {
@@ -238,6 +241,31 @@ export function AddVendorDialog({ onSuccess }: AddVendorDialogProps) { </FormItem>
)}
/>
+
+ <FormField
+ control={form.control}
+ name="isQuoteComparison"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-start space-x-3 space-y-0">
+ <FormControl>
+ <input
+ type="checkbox"
+ checked={field.value}
+ onChange={field.onChange}
+ className="w-4 h-4 mt-1"
+ />
+ </FormControl>
+ <div className="space-y-1 leading-none">
+ <FormLabel className="cursor-pointer">
+ 견적비교용 벤더
+ </FormLabel>
+ <p className="text-sm text-muted-foreground">
+ 체크 시 초대 메일을 발송하고 벤더가 직접 가입할 수 있습니다.
+ </p>
+ </div>
+ </FormItem>
+ )}
+ />
</div>
{/* 연락처 정보 */}
@@ -333,52 +361,6 @@ export function AddVendorDialog({ onSuccess }: AddVendorDialogProps) { </div>
</div>
- {/* 담당자 정보 */}
- <div className="space-y-4">
- <h3 className="text-lg font-medium">담당자 정보</h3>
- <div className="grid grid-cols-2 gap-4">
- <FormField
- control={form.control}
- name="agentName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>담당자명</FormLabel>
- <FormControl>
- <Input placeholder="담당자명을 입력하세요" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <FormField
- control={form.control}
- name="agentPhone"
- render={({ field }) => (
- <FormItem>
- <FormLabel>담당자 전화번호</FormLabel>
- <FormControl>
- <Input placeholder="담당자 전화번호를 입력하세요" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
- <FormField
- control={form.control}
- name="agentEmail"
- render={({ field }) => (
- <FormItem>
- <FormLabel>담당자 이메일</FormLabel>
- <FormControl>
- <Input type="email" placeholder="담당자 이메일을 입력하세요" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
-
{/* 대표자 정보 */}
<div className="space-y-4">
<h3 className="text-lg font-medium">대표자 정보</h3>
diff --git a/lib/tech-vendors/table/invite-tech-vendor-dialog.tsx b/lib/tech-vendors/table/invite-tech-vendor-dialog.tsx new file mode 100644 index 00000000..823b2f4d --- /dev/null +++ b/lib/tech-vendors/table/invite-tech-vendor-dialog.tsx @@ -0,0 +1,184 @@ +"use client"
+
+import * as React from "react"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import { Badge } from "@/components/ui/badge"
+import { Mail, Send, Loader2, UserCheck } from "lucide-react"
+import { toast } from "sonner"
+import { TechVendor } from "@/db/schema/techVendors"
+import { inviteTechVendor } from "../service"
+
+// 더 이상 폼 스키마 불필요
+
+interface InviteTechVendorDialogProps {
+ vendors: TechVendor[]
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function InviteTechVendorDialog({
+ vendors,
+ open,
+ onOpenChange,
+ showTrigger = true,
+ onSuccess,
+}: InviteTechVendorDialogProps) {
+ const [isLoading, setIsLoading] = React.useState(false)
+
+ const onSubmit = async () => {
+ if (vendors.length === 0) {
+ toast.error("초대할 벤더를 선택해주세요.")
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ let successCount = 0
+ let errorCount = 0
+
+ for (const vendor of vendors) {
+ try {
+ const result = await inviteTechVendor({
+ vendorId: vendor.id,
+ subject: "기술영업 협력업체 등록 초대",
+ message: `안녕하세요.
+
+저희 EVCP 플랫폼에서 기술영업 협력업체로 등록하여 다양한 프로젝트에 참여하실 수 있는 기회를 제공하고 있습니다.
+
+아래 링크를 통해 업체 정보를 등록하시기 바랍니다.
+
+감사합니다.`,
+ recipientEmail: vendor.email || "",
+ })
+
+ if (result.success) {
+ successCount++
+ } else {
+ errorCount++
+ console.error(`벤더 ${vendor.vendorName} 초대 실패:`, result.error)
+ }
+ } catch (error) {
+ errorCount++
+ console.error(`벤더 ${vendor.vendorName} 초대 실패:`, error)
+ }
+ }
+
+ if (successCount > 0) {
+ toast.success(`${successCount}개 업체에 초대 메일이 성공적으로 발송되었습니다.`)
+ onOpenChange?.(false)
+ onSuccess?.()
+ }
+
+ if (errorCount > 0) {
+ toast.error(`${errorCount}개 업체 초대 메일 발송에 실패했습니다.`)
+ }
+ } catch (error) {
+ console.error("초대 메일 발송 실패:", error)
+ toast.error("초대 메일 발송 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const getStatusBadge = (status: string) => {
+ const statusConfig = {
+ ACTIVE: { variant: "default" as const, text: "활성 상태", className: "bg-emerald-100 text-emerald-800" },
+ INACTIVE: { variant: "secondary" as const, text: "비활성 상태", className: "bg-gray-100 text-gray-800" },
+ PENDING_INVITE: { variant: "outline" as const, text: "초대 대기", className: "bg-blue-100 text-blue-800" },
+ INVITED: { variant: "outline" as const, text: "초대 완료", className: "bg-green-100 text-green-800" },
+ QUOTE_COMPARISON: { variant: "outline" as const, text: "견적 비교", className: "bg-purple-100 text-purple-800" },
+ BLACKLISTED: { variant: "destructive" as const, text: "거래 금지", className: "bg-red-100 text-red-800" },
+ }
+
+ const config = statusConfig[status as keyof typeof statusConfig] || statusConfig.INACTIVE
+ return <Badge variant={config.variant} className={config.className}>{config.text}</Badge>
+ }
+
+ if (vendors.length === 0) {
+ return null
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ {showTrigger && (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Mail className="h-4 w-4" />
+ 초대 메일 발송 ({vendors.length})
+ </Button>
+ </DialogTrigger>
+ )}
+ <DialogContent className="max-w-2xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <UserCheck className="h-5 w-5" />
+ 기술영업 벤더 초대
+ </DialogTitle>
+ <DialogDescription>
+ 선택한 {vendors.length}개 벤더에게 등록 초대 메일을 발송합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-6">
+ {/* 벤더 정보 */}
+ <div className="rounded-lg border bg-muted/50 p-4">
+ <h3 className="font-semibold mb-2">초대 대상 벤더 ({vendors.length}개)</h3>
+ <div className="space-y-2 max-h-60 overflow-y-auto">
+ {vendors.map((vendor) => (
+ <div key={vendor.id} className="flex items-center justify-between p-2 border rounded bg-background">
+ <div className="flex-1">
+ <div className="font-medium">{vendor.vendorName}</div>
+ <div className="text-sm text-muted-foreground">
+ {vendor.email || "이메일 없음"}
+ </div>
+ </div>
+ <div className="flex items-center gap-2">
+ {getStatusBadge(vendor.status)}
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+
+ {/* 초대 확인 */}
+ <div className="space-y-4">
+
+ <div className="flex justify-end gap-2">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange?.(false)}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button onClick={onSubmit} disabled={isLoading}>
+ {isLoading ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ 발송 중...
+ </>
+ ) : (
+ <>
+ <Send className="mr-2 h-4 w-4" />
+ 초대 메일 발송
+ </>
+ )}
+ </Button>
+ </div>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+}
\ No newline at end of file diff --git a/lib/tech-vendors/table/tech-vendors-table-columns.tsx b/lib/tech-vendors/table/tech-vendors-table-columns.tsx index 69396c99..052794ce 100644 --- a/lib/tech-vendors/table/tech-vendors-table-columns.tsx +++ b/lib/tech-vendors/table/tech-vendors-table-columns.tsx @@ -225,6 +225,25 @@ export function getColumns({ setRowAction, router, openItemsDialog }: GetColumns className: "bg-gray-100 text-gray-800 border-gray-300", iconColor: "text-gray-600" }; + + case "PENDING_INVITE": + return { + variant: "default", + className: "bg-blue-100 text-blue-800 border-blue-300", + iconColor: "text-blue-600" + }; + case "INVITED": + return { + variant: "default", + className: "bg-green-100 text-green-800 border-green-300", + iconColor: "text-green-600" + }; + case "QUOTE_COMPARISON": + return { + variant: "default", + className: "bg-purple-100 text-purple-800 border-purple-300", + iconColor: "text-purple-600" + }; case "BLACKLISTED": return { variant: "destructive", @@ -246,7 +265,9 @@ export function getColumns({ setRowAction, router, openItemsDialog }: GetColumns "ACTIVE": "활성 상태", "INACTIVE": "비활성 상태", "BLACKLISTED": "거래 금지", - "PENDING_REVIEW": "비교 견적" + "PENDING_INVITE": "초대 대기", + "INVITED": "초대 완료", + "QUOTE_COMPARISON": "견적 비교" }; return statusMap[status] || status; @@ -269,56 +290,58 @@ export function getColumns({ setRowAction, router, openItemsDialog }: GetColumns ); } // TechVendorType 컬럼을 badge로 표시 - // if (cfg.id === "techVendorType") { - // const techVendorType = row.original.techVendorType as string; + if (cfg.id === "techVendorType") { + const techVendorType = row.original.techVendorType as string | null | undefined; - // // 벤더 타입 파싱 개선 - // let types: string[] = []; - // if (!techVendorType) { - // types = []; - // } else if (techVendorType.startsWith('[') && techVendorType.endsWith(']')) { - // // JSON 배열 형태 - // try { - // const parsed = JSON.parse(techVendorType); - // types = Array.isArray(parsed) ? parsed.filter(Boolean) : [techVendorType]; - // } catch { - // types = [techVendorType]; - // } - // } else if (techVendorType.includes(',')) { - // // 콤마로 구분된 문자열 - // types = techVendorType.split(',').map(t => t.trim()).filter(Boolean); - // } else { - // // 단일 문자열 - // types = [techVendorType.trim()].filter(Boolean); - // } - // // 벤더 타입 정렬 - 조선 > 해양top > 해양hull 순 - // const typeOrder = ["조선", "해양top", "해양hull"]; - // types.sort((a, b) => { - // const indexA = typeOrder.indexOf(a); - // const indexB = typeOrder.indexOf(b); - - // // 정의된 순서에 있는 경우 우선순위 적용 - // if (indexA !== -1 && indexB !== -1) { - // return indexA - indexB; - // } - // return a.localeCompare(b); - // }); - // return ( - // <div className="flex flex-wrap gap-1"> - // {types.length > 0 ? types.map((type, index) => ( - // <Badge key={`${type}-${index}`} variant="secondary" className="text-xs"> - // {type} - // </Badge> - // )) : ( - // <span className="text-muted-foreground">-</span> - // )} - // </div> - // ); - // } + // 벤더 타입 파싱 개선 - null/undefined 안전 처리 + let types: string[] = []; + if (!techVendorType) { + types = []; + } else if (techVendorType.startsWith('[') && techVendorType.endsWith(']')) { + // JSON 배열 형태 + try { + const parsed = JSON.parse(techVendorType); + types = Array.isArray(parsed) ? parsed.filter(Boolean) : [techVendorType]; + } catch { + types = [techVendorType]; + } + } else if (techVendorType.includes(',')) { + // 콤마로 구분된 문자열 + types = techVendorType.split(',').map(t => t.trim()).filter(Boolean); + } else { + // 단일 문자열 + types = [techVendorType.trim()].filter(Boolean); + } + + // 벤더 타입 정렬 - 조선 > 해양top > 해양hull 순 + const typeOrder = ["조선", "해양top", "해양hull"]; + types.sort((a, b) => { + const indexA = typeOrder.indexOf(a); + const indexB = typeOrder.indexOf(b); + + // 정의된 순서에 있는 경우 우선순위 적용 + if (indexA !== -1 && indexB !== -1) { + return indexA - indexB; + } + return a.localeCompare(b); + }); + + return ( + <div className="flex flex-wrap gap-1"> + {types.length > 0 ? types.map((type, index) => ( + <Badge key={`${type}-${index}`} variant="secondary" className="text-xs"> + {type} + </Badge> + )) : ( + <span className="text-muted-foreground">-</span> + )} + </div> + ); + } // 날짜 컬럼 포맷팅 if (cfg.type === "date" && cell.getValue()) { - return formatDate(cell.getValue() as Date, "KR"); + return formatDate(cell.getValue() as Date); } return cell.getValue(); diff --git a/lib/tech-vendors/table/tech-vendors-table-toolbar-actions.tsx b/lib/tech-vendors/table/tech-vendors-table-toolbar-actions.tsx index 06b2cc42..ac7ee184 100644 --- a/lib/tech-vendors/table/tech-vendors-table-toolbar-actions.tsx +++ b/lib/tech-vendors/table/tech-vendors-table-toolbar-actions.tsx @@ -20,6 +20,7 @@ import { TechVendor } from "@/db/schema/techVendors" import { ImportTechVendorButton } from "./import-button" import { exportTechVendorTemplate } from "./excel-template-download" import { AddVendorDialog } from "./add-vendor-dialog" +import { InviteTechVendorDialog } from "./invite-tech-vendor-dialog" interface TechVendorsTableToolbarActionsProps { table: Table<TechVendor> @@ -36,6 +37,13 @@ export function TechVendorsTableToolbarActions({ table, onRefresh }: TechVendors .rows .map(row => row.original); }, [table.getFilteredSelectedRowModel().rows]); + + // 초대 가능한 벤더들 (PENDING_INVITE 상태 + 이메일 있음) + const invitableVendors = React.useMemo(() => { + return selectedVendors.filter(vendor => + vendor.status === "PENDING_INVITE" && vendor.email + ); + }, [selectedVendors]); // 테이블의 모든 벤더 가져오기 (필터링된 결과) const allFilteredVendors = React.useMemo(() => { @@ -97,6 +105,14 @@ export function TechVendorsTableToolbarActions({ table, onRefresh }: TechVendors return ( <div className="flex items-center gap-2"> + {/* 초대 버튼 - 선택된 PENDING_REVIEW 벤더들이 있을 때만 표시 */} + {invitableVendors.length > 0 && ( + <InviteTechVendorDialog + vendors={invitableVendors} + onSuccess={handleVendorAddSuccess} + /> + )} + {/* 벤더 추가 다이얼로그 추가 */} <AddVendorDialog onSuccess={handleVendorAddSuccess} /> diff --git a/lib/tech-vendors/table/tech-vendors-table.tsx b/lib/tech-vendors/table/tech-vendors-table.tsx index 125e39dc..a8e18501 100644 --- a/lib/tech-vendors/table/tech-vendors-table.tsx +++ b/lib/tech-vendors/table/tech-vendors-table.tsx @@ -59,7 +59,9 @@ export function TechVendorsTable({ promises }: TechVendorsTableProps) { "ACTIVE": "활성 상태", "INACTIVE": "비활성 상태", "BLACKLISTED": "거래 금지", - "PENDING_REVIEW": "비교 견적", + "PENDING_INVITE": "초대 대기", + "INVITED": "초대 완료", + "QUOTE_COMPARISON": "견적 비교", }; return statusMap[status] || status; @@ -187,6 +189,7 @@ export function TechVendorsTable({ promises }: TechVendorsTableProps) { onOpenChange={setItemsDialogOpen} vendor={selectedVendorForItems} /> + </> ) }
\ No newline at end of file |
