summaryrefslogtreecommitdiff
path: root/lib/tech-vendors/table
diff options
context:
space:
mode:
Diffstat (limited to 'lib/tech-vendors/table')
-rw-r--r--lib/tech-vendors/table/add-vendor-dialog.tsx74
-rw-r--r--lib/tech-vendors/table/invite-tech-vendor-dialog.tsx184
-rw-r--r--lib/tech-vendors/table/tech-vendors-table-columns.tsx117
-rw-r--r--lib/tech-vendors/table/tech-vendors-table-toolbar-actions.tsx16
-rw-r--r--lib/tech-vendors/table/tech-vendors-table.tsx5
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