summaryrefslogtreecommitdiff
path: root/lib/rfq-last/vendor
diff options
context:
space:
mode:
Diffstat (limited to 'lib/rfq-last/vendor')
-rw-r--r--lib/rfq-last/vendor/rfq-vendor-table.tsx205
-rw-r--r--lib/rfq-last/vendor/send-rfq-dialog.tsx803
2 files changed, 716 insertions, 292 deletions
diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx
index 7f7afe14..b2ea7588 100644
--- a/lib/rfq-last/vendor/rfq-vendor-table.tsx
+++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx
@@ -24,7 +24,8 @@ import {
Globe,
Package,
MapPin,
- Info
+ Info,
+ Loader2
} from "lucide-react";
import { format } from "date-fns";
import { ko } from "date-fns/locale";
@@ -53,6 +54,12 @@ import { BatchUpdateConditionsDialog } from "./batch-update-conditions-dialog";
import { SendRfqDialog } from "./send-rfq-dialog";
// import { VendorDetailDialog } from "./vendor-detail-dialog";
// import { sendRfqToVendors } from "@/app/actions/rfq/send-rfq.action";
+import {
+ getRfqSendData,
+ getSelectedVendorsWithEmails,
+ type RfqSendData,
+ type VendorEmailInfo
+} from "../service"
// 타입 정의
interface RfqDetail {
@@ -100,13 +107,12 @@ interface VendorResponse {
attachmentCount?: number;
}
-// Props 타입 정의 (중복 제거하고 하나로 통합)
+// Props 타입 정의
interface RfqVendorTableProps {
rfqId: number;
rfqCode?: string;
rfqDetails: RfqDetail[];
vendorResponses: VendorResponse[];
- // 추가 props
rfqInfo?: {
rfqTitle: string;
rfqType: string;
@@ -201,6 +207,17 @@ export function RfqVendorTable({
const [isBatchUpdateOpen, setIsBatchUpdateOpen] = React.useState(false);
const [selectedVendor, setSelectedVendor] = React.useState<any | null>(null);
const [isSendDialogOpen, setIsSendDialogOpen] = React.useState(false);
+ const [isLoadingSendData, setIsLoadingSendData] = React.useState(false);
+
+ const [sendDialogData, setSendDialogData] = React.useState<{
+ rfqInfo: RfqSendData['rfqInfo'] | null;
+ attachments: RfqSendData['attachments'];
+ selectedVendors: VendorEmailInfo[];
+ }>({
+ rfqInfo: null,
+ attachments: [],
+ selectedVendors: [],
+ });
// 데이터 병합
const mergedData = React.useMemo(
@@ -215,9 +232,63 @@ export function RfqVendorTable({
return;
}
- // 다이얼로그 열기
- setIsSendDialogOpen(true);
- }, [selectedRows]);
+ try {
+ setIsLoadingSendData(true);
+
+ // 선택된 벤더 ID들 추출
+ const selectedVendorIds = selectedRows
+ .map(row => row.vendorId)
+ .filter(id => id != null);
+
+ if (selectedVendorIds.length === 0) {
+ toast.error("유효한 벤더가 선택되지 않았습니다.");
+ return;
+ }
+
+ // 병렬로 데이터 가져오기 (에러 처리 포함)
+ const [rfqSendData, vendorEmailInfos] = await Promise.all([
+ getRfqSendData(rfqId),
+ getSelectedVendorsWithEmails(rfqId, selectedVendorIds)
+ ]);
+
+ // 데이터 검증
+ if (!rfqSendData?.rfqInfo) {
+ toast.error("RFQ 정보를 불러올 수 없습니다.");
+ return;
+ }
+
+ if (!vendorEmailInfos || vendorEmailInfos.length === 0) {
+ toast.error("선택된 벤더의 이메일 정보를 찾을 수 없습니다.");
+ return;
+ }
+
+ // 다이얼로그 데이터 설정
+ setSendDialogData({
+ rfqInfo: rfqSendData.rfqInfo,
+ attachments: rfqSendData.attachments || [],
+ selectedVendors: vendorEmailInfos.map(v => ({
+ vendorId: v.vendorId,
+ vendorName: v.vendorName,
+ vendorCode: v.vendorCode,
+ vendorCountry: v.vendorCountry,
+ vendorEmail: v.vendorEmail,
+ representativeEmail: v.representativeEmail,
+ contacts: v.contacts || [],
+ contactsByPosition: v.contactsByPosition || {},
+ primaryEmail: v.primaryEmail,
+ currency: v.currency,
+ })),
+ });
+
+ // 다이얼로그 열기
+ setIsSendDialogOpen(true);
+ } catch (error) {
+ console.error("RFQ 발송 데이터 로드 실패:", error);
+ toast.error("데이터를 불러오는데 실패했습니다. 다시 시도해주세요.");
+ } finally {
+ setIsLoadingSendData(false);
+ }
+ }, [selectedRows, rfqId]);
// RFQ 발송 핸들러
const handleSendRfq = React.useCallback(async (data: {
@@ -248,6 +319,12 @@ export function RfqVendorTable({
// 성공 후 처리
setSelectedRows([]);
+ setSendDialogData({
+ rfqInfo: null,
+ attachments: [],
+ selectedVendors: [],
+ });
+
toast.success(`${data.vendors.length}개 업체에 RFQ를 발송했습니다.`);
} catch (error) {
console.error("RFQ 발송 실패:", error);
@@ -264,30 +341,63 @@ export function RfqVendorTable({
break;
case "send":
- // RFQ 발송 로직
- toast.info(`${vendor.vendorName}에게 RFQ를 발송합니다.`);
+ // 개별 RFQ 발송
+ try {
+ setIsLoadingSendData(true);
+
+ const [rfqSendData, vendorEmailInfos] = await Promise.all([
+ getRfqSendData(rfqId),
+ getSelectedVendorsWithEmails(rfqId, [vendor.vendorId])
+ ]);
+
+ if (!rfqSendData?.rfqInfo || !vendorEmailInfos || vendorEmailInfos.length === 0) {
+ toast.error("벤더 정보를 불러올 수 없습니다.");
+ return;
+ }
+
+ setSendDialogData({
+ rfqInfo: rfqSendData.rfqInfo,
+ attachments: rfqSendData.attachments || [],
+ selectedVendors: vendorEmailInfos.map(v => ({
+ vendorId: v.vendorId,
+ vendorName: v.vendorName,
+ vendorCode: v.vendorCode,
+ vendorCountry: v.vendorCountry,
+ vendorEmail: v.vendorEmail,
+ representativeEmail: v.representativeEmail,
+ contacts: v.contacts || [],
+ contactsByPosition: v.contactsByPosition || {},
+ primaryEmail: v.primaryEmail,
+ currency: v.currency,
+ })),
+ });
+
+ setIsSendDialogOpen(true);
+ } catch (error) {
+ console.error("개별 발송 데이터 로드 실패:", error);
+ toast.error("데이터를 불러오는데 실패했습니다.");
+ } finally {
+ setIsLoadingSendData(false);
+ }
break;
case "edit":
- // 수정 로직
toast.info("수정 기능은 준비중입니다.");
break;
case "delete":
- // 삭제 로직
if (confirm(`${vendor.vendorName}을(를) 삭제하시겠습니까?`)) {
toast.success(`${vendor.vendorName}이(가) 삭제되었습니다.`);
}
break;
case "response-detail":
- // 회신 상세 보기
toast.info(`${vendor.vendorName}의 회신 상세를 확인합니다.`);
break;
}
- }, []);
+ }, [rfqId]);
- // 컬럼 정의 (확장된 버전)
+ // 컬럼 정의
const columns: ColumnDef<any>[] = React.useMemo(() => [
{
id: "select",
@@ -535,7 +645,6 @@ export function RfqVendorTable({
header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="참여여부 (회신일)" />,
cell: ({ row }) => {
const submittedAt = row.original.response?.submittedAt;
- const status = row.original.response?.status;
if (!submittedAt) {
return <Badge variant="outline">미참여</Badge>;
@@ -639,7 +748,10 @@ export function RfqVendorTable({
상세보기
</DropdownMenuItem>
{!hasResponse && (
- <DropdownMenuItem onClick={() => handleAction("send", vendor)}>
+ <DropdownMenuItem
+ onClick={() => handleAction("send", vendor)}
+ disabled={isLoadingSendData}
+ >
<Send className="mr-2 h-4 w-4" />
RFQ 발송
</DropdownMenuItem>
@@ -662,7 +774,7 @@ export function RfqVendorTable({
},
size: 60,
},
- ], [handleAction, rfqCode]);
+ ], [handleAction, rfqCode, isLoadingSendData]);
const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [
{ id: "vendorName", label: "벤더명", type: "text" },
@@ -701,41 +813,6 @@ export function RfqVendorTable({
}));
}, [selectedRows]);
- // 선택된 벤더 정보 (Send용)
- const selectedVendorsForSend = React.useMemo(() => {
- return selectedRows.map(row => ({
- vendorId: row.vendorId,
- vendorName: row.vendorName,
- vendorCode: row.vendorCode,
- vendorCountry: row.vendorCountry,
- vendorEmail: row.vendorEmail || `vendor${row.vendorId}@example.com`,
- currency: row.currency,
- }));
- }, [selectedRows]);
-
- // RFQ 정보 준비 (다이얼로그용)
- const rfqInfoForDialog = React.useMemo(() => {
- // props로 받은 rfqInfo 사용, 없으면 기본값
- return rfqInfo || {
- rfqCode: rfqCode || '',
- rfqTitle: '테스트 RFQ',
- rfqType: '정기견적',
- projectCode: 'PN003',
- projectName: 'PETRONAS ZLNG nearshore project',
- picName: '김*종',
- picCode: '86D',
- picTeam: '해양구매팀(해양구매1)',
- packageNo: 'MM03',
- packageName: 'Deck Machinery',
- designPicName: '이*진',
- designTeam: '전장설계팀 (전장기기시스템)',
- materialGroup: 'BE2101',
- materialGroupDesc: 'Combined Windlass & Mooring Wi',
- dueDate: new Date('2025-07-05'),
- evaluationApply: true,
- };
- }, [rfqInfo, rfqCode]);
-
// 추가 액션 버튼들
const additionalActions = React.useMemo(() => (
<div className="flex items-center gap-2">
@@ -743,6 +820,7 @@ export function RfqVendorTable({
variant="outline"
size="sm"
onClick={() => setIsAddDialogOpen(true)}
+ disabled={isLoadingSendData}
>
<Plus className="h-4 w-4 mr-2" />
벤더 추가
@@ -753,6 +831,7 @@ export function RfqVendorTable({
variant="outline"
size="sm"
onClick={() => setIsBatchUpdateOpen(true)}
+ disabled={isLoadingSendData}
>
<Settings2 className="h-4 w-4 mr-2" />
정보 일괄 입력 ({selectedRows.length})
@@ -761,9 +840,19 @@ export function RfqVendorTable({
variant="outline"
size="sm"
onClick={handleBulkSend}
+ disabled={isLoadingSendData || selectedRows.length === 0}
>
- <Send className="h-4 w-4 mr-2" />
- 선택 발송 ({selectedRows.length})
+ {isLoadingSendData ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ 데이터 준비중...
+ </>
+ ) : (
+ <>
+ <Send className="h-4 w-4 mr-2" />
+ 선택 발송 ({selectedRows.length})
+ </>
+ )}
</Button>
</>
)}
@@ -777,13 +866,13 @@ export function RfqVendorTable({
toast.success("데이터를 새로고침했습니다.");
}, 1000);
}}
- disabled={isRefreshing}
+ disabled={isRefreshing || isLoadingSendData}
>
<RefreshCw className={cn("h-4 w-4 mr-2", isRefreshing && "animate-spin")} />
새로고침
</Button>
</div>
- ), [selectedRows, isRefreshing, handleBulkSend]);
+ ), [selectedRows, isRefreshing, isLoadingSendData, handleBulkSend]);
return (
<>
@@ -828,9 +917,9 @@ export function RfqVendorTable({
<SendRfqDialog
open={isSendDialogOpen}
onOpenChange={setIsSendDialogOpen}
- selectedVendors={selectedVendorsForSend}
- rfqInfo={rfqInfoForDialog}
- attachments={attachments || []}
+ selectedVendors={sendDialogData.selectedVendors}
+ rfqInfo={sendDialogData.rfqInfo}
+ attachments={sendDialogData.attachments || []}
onSend={handleSendRfq}
/>
diff --git a/lib/rfq-last/vendor/send-rfq-dialog.tsx b/lib/rfq-last/vendor/send-rfq-dialog.tsx
index dc420cad..9d88bdc9 100644
--- a/lib/rfq-last/vendor/send-rfq-dialog.tsx
+++ b/lib/rfq-last/vendor/send-rfq-dialog.tsx
@@ -14,8 +14,8 @@ import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
-import { ScrollArea } from "@/components/ui/scroll-area";
import { Checkbox } from "@/components/ui/checkbox";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Send,
Building2,
@@ -33,12 +33,18 @@ import {
Info,
File,
CheckCircle,
- RefreshCw
+ RefreshCw,
+ Phone,
+ Briefcase,
+ Building,
+ ChevronDown,
+ ChevronRight,
+ UserPlus
} from "lucide-react";
import { format } from "date-fns";
import { ko } from "date-fns/locale";
import { toast } from "sonner";
-import { cn } from "@/lib/utils";
+import { cn, formatDate } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
@@ -47,16 +53,54 @@ import {
} from "@/components/ui/tooltip";
import {
Alert,
- AlertDescription,
+ AlertDescription, AlertTitle
} from "@/components/ui/alert";
-
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Popover,
+ PopoverTrigger,
+ PopoverContent,
+} from "@/components/ui/popover"
// 타입 정의
+interface ContactDetail {
+ id: number;
+ name: string;
+ position?: string | null;
+ department?: string | null;
+ email: string;
+ phone?: string | null;
+ isPrimary: boolean;
+}
+
+interface CustomEmail {
+ id: string;
+ email: string;
+ name?: string;
+}
+
interface Vendor {
vendorId: number;
vendorName: string;
vendorCode?: string | null;
vendorCountry?: string | null;
vendorEmail?: string | null;
+ representativeEmail?: string | null;
+ contacts?: ContactDetail[];
+ contactsByPosition?: Record<string, ContactDetail[]>;
+ primaryEmail?: string | null;
currency?: string | null;
}
@@ -93,7 +137,9 @@ interface RfqInfo {
}
interface VendorWithRecipients extends Vendor {
- additionalRecipients: string[];
+ selectedMainEmail: string;
+ additionalEmails: string[];
+ customEmails: CustomEmail[];
}
interface SendRfqDialogProps {
@@ -109,6 +155,12 @@ interface SendRfqDialogProps {
}) => Promise<void>;
}
+// 이메일 유효성 검사 함수
+const validateEmail = (email: string): boolean => {
+ const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ return re.test(email);
+};
+
// 첨부파일 타입별 아이콘
const getAttachmentIcon = (type: string) => {
switch (type.toLowerCase()) {
@@ -132,6 +184,40 @@ const formatFileSize = (bytes?: number) => {
return `${kb.toFixed(2)} KB`;
};
+// 포지션별 아이콘
+const getPositionIcon = (position?: string | null) => {
+ if (!position) return <User className="h-3 w-3" />;
+
+ const lowerPosition = position.toLowerCase();
+ if (lowerPosition.includes('대표') || lowerPosition.includes('ceo')) {
+ return <Building2 className="h-3 w-3" />;
+ }
+ if (lowerPosition.includes('영업') || lowerPosition.includes('sales')) {
+ return <Briefcase className="h-3 w-3" />;
+ }
+ if (lowerPosition.includes('기술') || lowerPosition.includes('tech')) {
+ return <Package className="h-3 w-3" />;
+ }
+ return <User className="h-3 w-3" />;
+};
+
+// 포지션별 색상
+const getPositionColor = (position?: string | null) => {
+ if (!position) return 'default';
+
+ const lowerPosition = position.toLowerCase();
+ if (lowerPosition.includes('대표') || lowerPosition.includes('ceo')) {
+ return 'destructive';
+ }
+ if (lowerPosition.includes('영업') || lowerPosition.includes('sales')) {
+ return 'success';
+ }
+ if (lowerPosition.includes('기술') || lowerPosition.includes('tech')) {
+ return 'secondary';
+ }
+ return 'default';
+};
+
export function SendRfqDialog({
open,
onOpenChange,
@@ -144,6 +230,9 @@ export function SendRfqDialog({
const [vendorsWithRecipients, setVendorsWithRecipients] = React.useState<VendorWithRecipients[]>([]);
const [selectedAttachments, setSelectedAttachments] = React.useState<number[]>([]);
const [additionalMessage, setAdditionalMessage] = React.useState("");
+ const [expandedVendors, setExpandedVendors] = React.useState<number[]>([]);
+ const [customEmailInputs, setCustomEmailInputs] = React.useState<Record<number, { email: string; name: string }>>({});
+ const [showCustomEmailForm, setShowCustomEmailForm] = React.useState<Record<number, boolean>>({});
// 초기화
React.useEffect(() => {
@@ -151,48 +240,135 @@ export function SendRfqDialog({
setVendorsWithRecipients(
selectedVendors.map(v => ({
...v,
- additionalRecipients: []
+ selectedMainEmail: v.primaryEmail || v.vendorEmail || '',
+ additionalEmails: [],
+ customEmails: []
}))
);
// 모든 첨부파일 선택
setSelectedAttachments(attachments.map(a => a.id));
+ // 첫 번째 벤더를 자동으로 확장
+ if (selectedVendors.length > 0) {
+ setExpandedVendors([selectedVendors[0].vendorId]);
+ }
+ // 초기화
+ setCustomEmailInputs({});
+ setShowCustomEmailForm({});
}
}, [open, selectedVendors, attachments]);
- // 추가 수신처 이메일 추가
- const handleAddRecipient = (vendorId: number, email: string) => {
- if (!email) return;
-
- // 이메일 유효성 검사
- const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
- if (!emailRegex.test(email)) {
+ // 커스텀 이메일 추가
+ const addCustomEmail = (vendorId: number) => {
+ const input = customEmailInputs[vendorId];
+ if (!input || !input.email) {
+ toast.error("이메일 주소를 입력해주세요.");
+ return;
+ }
+
+ if (!validateEmail(input.email)) {
toast.error("올바른 이메일 형식이 아닙니다.");
return;
}
setVendorsWithRecipients(prev =>
- prev.map(v =>
- v.vendorId === vendorId
- ? { ...v, additionalRecipients: [...v.additionalRecipients, email] }
- : v
- )
+ prev.map(v => {
+ if (v.vendorId !== vendorId) return v;
+
+ // 중복 체크
+ const allEmails = [
+ v.vendorEmail,
+ v.representativeEmail,
+ ...(v.contacts?.map(c => c.email) || []),
+ ...v.customEmails.map(c => c.email)
+ ].filter(Boolean);
+
+ if (allEmails.includes(input.email)) {
+ toast.error("이미 등록된 이메일 주소입니다.");
+ return v;
+ }
+
+ const newCustomEmail: CustomEmail = {
+ id: `custom-${Date.now()}`,
+ email: input.email,
+ name: input.name || input.email.split('@')[0]
+ };
+
+ return {
+ ...v,
+ customEmails: [...v.customEmails, newCustomEmail]
+ };
+ })
);
+
+ // 입력 필드 초기화
+ setCustomEmailInputs(prev => ({
+ ...prev,
+ [vendorId]: { email: '', name: '' }
+ }));
+ setShowCustomEmailForm(prev => ({
+ ...prev,
+ [vendorId]: false
+ }));
+
+ toast.success("수신자가 추가되었습니다.");
};
- // 추가 수신처 이메일 제거
- const handleRemoveRecipient = (vendorId: number, index: number) => {
+ // 커스텀 이메일 삭제
+ const removeCustomEmail = (vendorId: number, emailId: string) => {
+ setVendorsWithRecipients(prev =>
+ prev.map(v => {
+ if (v.vendorId !== vendorId) return v;
+
+ const emailToRemove = v.customEmails.find(e => e.id === emailId);
+ if (!emailToRemove) return v;
+
+ return {
+ ...v,
+ customEmails: v.customEmails.filter(e => e.id !== emailId),
+ // 만약 삭제하는 이메일이 선택된 주 수신자라면 초기화
+ selectedMainEmail: v.selectedMainEmail === emailToRemove.email ? '' : v.selectedMainEmail,
+ // 추가 수신자에서도 제거
+ additionalEmails: v.additionalEmails.filter(e => e !== emailToRemove.email)
+ };
+ })
+ );
+ };
+
+ // 주 수신자 이메일 변경
+ const handleMainEmailChange = (vendorId: number, email: string) => {
setVendorsWithRecipients(prev =>
prev.map(v =>
v.vendorId === vendorId
- ? {
- ...v,
- additionalRecipients: v.additionalRecipients.filter((_, i) => i !== index)
- }
+ ? { ...v, selectedMainEmail: email }
: v
)
);
};
+ // 추가 수신자 토글
+ const toggleAdditionalEmail = (vendorId: number, email: string) => {
+ setVendorsWithRecipients(prev =>
+ prev.map(v => {
+ if (v.vendorId !== vendorId) return v;
+
+ const additionalEmails = v.additionalEmails.includes(email)
+ ? v.additionalEmails.filter(e => e !== email)
+ : [...v.additionalEmails, email];
+
+ return { ...v, additionalEmails };
+ })
+ );
+ };
+
+ // 벤더 확장/축소 토글
+ const toggleVendorExpand = (vendorId: number) => {
+ setExpandedVendors(prev =>
+ prev.includes(vendorId)
+ ? prev.filter(id => id !== vendorId)
+ : [...prev, vendorId]
+ );
+ };
+
// 첨부파일 선택 토글
const toggleAttachment = (attachmentId: number) => {
setSelectedAttachments(prev =>
@@ -206,15 +382,24 @@ export function SendRfqDialog({
const handleSend = async () => {
try {
setIsSending(true);
-
+
// 유효성 검사
+ const vendorsWithoutEmail = vendorsWithRecipients.filter(v => !v.selectedMainEmail);
+ if (vendorsWithoutEmail.length > 0) {
+ toast.error(`${vendorsWithoutEmail.map(v => v.vendorName).join(', ')}의 주 수신자를 선택해주세요.`);
+ return;
+ }
+
if (selectedAttachments.length === 0) {
toast.warning("최소 하나 이상의 첨부파일을 선택해주세요.");
return;
}
await onSend({
- vendors: vendorsWithRecipients,
+ vendors: vendorsWithRecipients.map(v => ({
+ ...v,
+ additionalRecipients: v.additionalEmails,
+ })),
attachments: selectedAttachments,
message: additionalMessage,
});
@@ -231,14 +416,14 @@ export function SendRfqDialog({
// 총 수신자 수 계산
const totalRecipientCount = React.useMemo(() => {
- return vendorsWithRecipients.reduce((acc, v) =>
- acc + 1 + v.additionalRecipients.length, 0
+ return vendorsWithRecipients.reduce((acc, v) =>
+ acc + 1 + v.additionalEmails.length, 0
);
}, [vendorsWithRecipients]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
+ <DialogContent className="max-w-5xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Send className="h-5 w-5" />
@@ -249,7 +434,8 @@ export function SendRfqDialog({
</DialogDescription>
</DialogHeader>
- <ScrollArea className="flex-1 max-h-[calc(90vh-200px)]">
+ {/* ScrollArea 대신 div 사용 */}
+ <div className="flex-1 overflow-y-auto px-1" style={{ maxHeight: 'calc(90vh - 200px)' }}>
<div className="space-y-6 pr-4">
{/* RFQ 정보 섹션 */}
<div className="space-y-4">
@@ -257,88 +443,367 @@ export function SendRfqDialog({
<Info className="h-4 w-4" />
RFQ 정보
</div>
-
+
<div className="bg-muted/50 rounded-lg p-4 space-y-3">
- {/* 프로젝트 정보 */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="flex items-start gap-2">
- <span className="text-muted-foreground min-w-[80px]">프로젝트:</span>
- <span className="font-medium">
- {rfqInfo.projectCode || "PN003"} ({rfqInfo.projectName || "PETRONAS ZLNG nearshore project"})
- </span>
+ <span className="text-muted-foreground min-w-[80px]">RFQ 코드:</span>
+ <span className="font-medium">{rfqInfo?.rfqCode}</span>
</div>
<div className="flex items-start gap-2">
- <span className="text-muted-foreground min-w-[80px]">견적번호:</span>
- <span className="font-medium font-mono">{rfqInfo.rfqCode}</span>
- </div>
- </div>
-
- {/* 담당자 정보 */}
- <div className="grid grid-cols-2 gap-4 text-sm">
- <div className="flex items-start gap-2">
- <span className="text-muted-foreground min-w-[80px]">구매담당:</span>
- <span>
- {rfqInfo.picName || "김*종"} ({rfqInfo.picCode || "86D"}) {rfqInfo.picTeam || "해양구매팀(해양구매1)"}
+ <span className="text-muted-foreground min-w-[80px]">견적마감일:</span>
+ <span className="font-medium text-red-600">
+ {formatDate(rfqInfo?.dueDate, "KR")}
</span>
</div>
<div className="flex items-start gap-2">
- <span className="text-muted-foreground min-w-[80px]">설계담당:</span>
- <span>
- {rfqInfo.designPicName || "이*진"} {rfqInfo.designTeam || "전장설계팀 (전장기기시스템)"}
+ <span className="text-muted-foreground min-w-[80px]">프로젝트:</span>
+ <span className="font-medium">
+ {rfqInfo?.projectCode} ({rfqInfo?.projectName})
</span>
</div>
- </div>
-
- {/* PKG 및 자재 정보 */}
- <div className="grid grid-cols-2 gap-4 text-sm">
<div className="flex items-start gap-2">
- <span className="text-muted-foreground min-w-[80px]">PKG 정보:</span>
- <span>
- {rfqInfo.packageNo || "MM03"} ({rfqInfo.packageName || "Deck Machinery"})
+ <span className="text-muted-foreground min-w-[80px]"> 자재그룹:</span>
+ <span className="font-medium">
+ {rfqInfo?.packageNo} - {rfqInfo?.packageName}
</span>
</div>
<div className="flex items-start gap-2">
- <span className="text-muted-foreground min-w-[80px]">자재그룹:</span>
- <span>
- {rfqInfo.materialGroup || "BE2101"} ({rfqInfo.materialGroupDesc || "Combined Windlass & Mooring Wi"})
+ <span className="text-muted-foreground min-w-[80px]">구매담당자:</span>
+ <span className="font-medium">
+ {rfqInfo?.picName} ({rfqInfo?.picCode}) {rfqInfo?.picTeam}
</span>
</div>
- </div>
-
- {/* 견적 정보 */}
- <div className="grid grid-cols-2 gap-4 text-sm">
<div className="flex items-start gap-2">
- <span className="text-muted-foreground min-w-[80px]">견적마감일:</span>
- <span className="font-medium text-red-600">
- {format(rfqInfo.dueDate, "yyyy.MM.dd", { locale: ko })}
+ <span className="text-muted-foreground min-w-[80px]"> 설계담당자:</span>
+ <span className="font-medium">
+ {rfqInfo?.designPicName}
</span>
</div>
- <div className="flex items-start gap-2">
- <span className="text-muted-foreground min-w-[80px]">평가적용:</span>
- <Badge variant={rfqInfo.evaluationApply ? "default" : "outline"}>
- {rfqInfo.evaluationApply ? "Y" : "N"}
- </Badge>
- </div>
+ {rfqInfo?.rfqCode.startsWith("F") &&
+ <>
+ <div className="flex items-start gap-2">
+ <span className="text-muted-foreground min-w-[80px]">견적명:</span>
+ <span className="font-medium">
+ {rfqInfo?.rfqTitle}
+ </span>
+ </div>
+ <div className="flex items-start gap-2">
+ <span className="text-muted-foreground min-w-[80px]"> 견적종류:</span>
+ <span className="font-medium">
+ {rfqInfo?.rfqType}
+ </span>
+ </div>
+ </>
+ }
</div>
+ </div>
+ </div>
- {/* 견적명 */}
- <div className="flex items-start gap-2 text-sm">
- <span className="text-muted-foreground min-w-[80px]">견적명:</span>
- <span className="font-medium">{rfqInfo.rfqTitle}</span>
+ <Separator />
+
+ {/* 수신 업체 섹션 - 테이블 버전 with 인라인 추가 폼 */}
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2 text-sm font-medium">
+ <Building2 className="h-4 w-4" />
+ 수신 업체 ({selectedVendors.length})
</div>
+ <Badge variant="outline" className="flex items-center gap-1">
+ <Users className="h-3 w-3" />
+ 총 {totalRecipientCount}명
+ </Badge>
+ </div>
- {/* 계약구분 (일반견적일 때만) */}
- {rfqInfo.rfqType === "일반견적" && (
- <div className="flex items-start gap-2 text-sm">
- <span className="text-muted-foreground min-w-[80px]">계약구분:</span>
- <span>{rfqInfo.contractType || "-"}</span>
- </div>
- )}
+ <div className="border rounded-lg overflow-hidden">
+ <table className="w-full">
+ <thead className="bg-muted/50 border-b">
+ <tr>
+ <th className="text-left p-2 text-xs font-medium">No.</th>
+ <th className="text-left p-2 text-xs font-medium">업체명</th>
+ <th className="text-left p-2 text-xs font-medium">주 수신자</th>
+ <th className="text-left p-2 text-xs font-medium">CC</th>
+ <th className="text-left p-2 text-xs font-medium">작업</th>
+ </tr>
+ </thead>
+ <tbody>
+ {vendorsWithRecipients.map((vendor, index) => {
+ const allContacts = vendor.contacts || [];
+ const allEmails = [
+ ...(vendor.representativeEmail ? [{
+ value: vendor.representativeEmail,
+ label: '대표자',
+ email: vendor.representativeEmail,
+ type: 'representative'
+ }] : []),
+ ...allContacts.map(c => ({
+ value: c.email,
+ label: `${c.name} ${c.position ? `(${c.position})` : ''}`,
+ email: c.email,
+ type: 'contact'
+ })),
+ ...vendor.customEmails.map(c => ({
+ value: c.email,
+ label: c.name || c.email,
+ email: c.email,
+ type: 'custom'
+ })),
+ ...(vendor.vendorEmail && vendor.vendorEmail !== vendor.representativeEmail ? [{
+ value: vendor.vendorEmail,
+ label: '업체 기본',
+ email: vendor.vendorEmail,
+ type: 'default'
+ }] : [])
+ ];
+
+ const ccEmails = allEmails.filter(e => e.value !== vendor.selectedMainEmail);
+ const selectedMainEmailInfo = allEmails.find(e => e.value === vendor.selectedMainEmail);
+ const isFormOpen = showCustomEmailForm[vendor.vendorId];
+
+ return (
+ <React.Fragment key={vendor.vendorId}>
+ <tr className="border-b hover:bg-muted/20">
+ <td className="p-2">
+ <div className="flex items-center justify-center w-5 h-5 rounded-full bg-primary/10 text-primary text-xs font-medium">
+ {index + 1}
+ </div>
+ </td>
+ <td className="p-2">
+ <div className="space-y-1">
+ <div className="font-medium text-sm">{vendor.vendorName}</div>
+ <div className="flex items-center gap-1">
+ <Badge variant="outline" className="text-xs">
+ {vendor.vendorCountry}
+ </Badge>
+ <span className="text-xs text-muted-foreground">
+ {vendor.vendorCode}
+ </span>
+ </div>
+ </div>
+ </td>
+ <td className="p-2">
+ <Select
+ value={vendor.selectedMainEmail}
+ onValueChange={(value) => handleMainEmailChange(vendor.vendorId, value)}
+ >
+ <SelectTrigger className="h-7 text-xs w-[200px]">
+ <SelectValue placeholder="선택하세요">
+ {selectedMainEmailInfo && (
+ <div className="flex items-center gap-1">
+ {selectedMainEmailInfo.type === 'representative' && <Building2 className="h-3 w-3" />}
+ {selectedMainEmailInfo.type === 'custom' && <UserPlus className="h-3 w-3 text-green-500" />}
+ <span className="truncate">{selectedMainEmailInfo.label}</span>
+ </div>
+ )}
+ </SelectValue>
+ </SelectTrigger>
+ <SelectContent>
+ {allEmails.map((email) => (
+ <SelectItem key={email.value} value={email.value} className="text-xs">
+ <div className="flex items-center gap-1">
+ {email.type === 'representative' && <Building2 className="h-3 w-3" />}
+ {email.type === 'custom' && <UserPlus className="h-3 w-3 text-green-500" />}
+ <span>{email.label}</span>
+ </div>
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ {!vendor.selectedMainEmail && (
+ <span className="text-xs text-red-500">필수</span>
+ )}
+ </td>
+ <td className="p-2">
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button variant="outline" className="h-7 text-xs">
+ {vendor.additionalEmails.length > 0
+ ? `${vendor.additionalEmails.length}명`
+ : "선택"
+ }
+ <ChevronDown className="ml-1 h-3 w-3" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-48 p-2">
+ <div className="max-h-48 overflow-y-auto space-y-1">
+ {ccEmails.map((email) => (
+ <div key={email.value} className="flex items-center space-x-1 p-1">
+ <Checkbox
+ checked={vendor.additionalEmails.includes(email.value)}
+ onCheckedChange={() => toggleAdditionalEmail(vendor.vendorId, email.value)}
+ className="h-3 w-3"
+ />
+ <label className="text-xs cursor-pointer flex-1 truncate">
+ {email.label}
+ </label>
+ </div>
+ ))}
+ </div>
+ </PopoverContent>
+ </Popover>
+ </td>
+ <td className="p-2">
+ <div className="flex items-center gap-1">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant={isFormOpen ? "default" : "ghost"}
+ size="sm"
+ className="h-6 w-6 p-0"
+ onClick={() => {
+ setShowCustomEmailForm(prev => ({
+ ...prev,
+ [vendor.vendorId]: !prev[vendor.vendorId]
+ }));
+ }}
+ >
+ {isFormOpen ? <X className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>{isFormOpen ? "닫기" : "수신자 추가"}</TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ {vendor.customEmails.length > 0 && (
+ <Badge variant="success" className="text-xs">
+ +{vendor.customEmails.length}
+ </Badge>
+ )}
+ </div>
+ </td>
+ </tr>
+
+ {/* 인라인 수신자 추가 폼 - 한 줄 레이아웃 */}
+ {isFormOpen && (
+ <tr className="bg-muted/10 border-b">
+ <td colSpan={5} className="p-4">
+ <div className="space-y-3">
+ <div className="flex items-center justify-between mb-2">
+ <div className="flex items-center gap-2 text-sm font-medium">
+ <UserPlus className="h-4 w-4" />
+ 수신자 추가 - {vendor.vendorName}
+ </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-6 w-6 p-0"
+ onClick={() => setShowCustomEmailForm(prev => ({
+ ...prev,
+ [vendor.vendorId]: false
+ }))}
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </div>
+
+ {/* 한 줄에 모든 요소 배치 - 명확한 너비 지정 */}
+ <div className="flex gap-2 items-end">
+ <div className="w-[150px]">
+ <Label className="text-xs mb-1 block">이름 (선택)</Label>
+ <Input
+ placeholder="홍길동"
+ className="h-8 text-sm"
+ value={customEmailInputs[vendor.vendorId]?.name || ''}
+ onChange={(e) => setCustomEmailInputs(prev => ({
+ ...prev,
+ [vendor.vendorId]: {
+ ...prev[vendor.vendorId],
+ name: e.target.value
+ }
+ }))}
+ />
+ </div>
+ <div className="flex-1">
+ <Label className="text-xs mb-1 block">이메일 <span className="text-red-500">*</span></Label>
+ <Input
+ type="email"
+ placeholder="example@company.com"
+ className="h-8 text-sm"
+ value={customEmailInputs[vendor.vendorId]?.email || ''}
+ onChange={(e) => setCustomEmailInputs(prev => ({
+ ...prev,
+ [vendor.vendorId]: {
+ ...prev[vendor.vendorId],
+ email: e.target.value
+ }
+ }))}
+ onKeyPress={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ addCustomEmail(vendor.vendorId);
+ }
+ }}
+ />
+ </div>
+ <Button
+ size="sm"
+ className="h-8 px-4"
+ onClick={() => addCustomEmail(vendor.vendorId)}
+ disabled={!customEmailInputs[vendor.vendorId]?.email}
+ >
+ <Plus className="h-3 w-3 mr-1" />
+ 추가
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ className="h-8 px-4"
+ onClick={() => {
+ setCustomEmailInputs(prev => ({
+ ...prev,
+ [vendor.vendorId]: { email: '', name: '' }
+ }));
+ setShowCustomEmailForm(prev => ({
+ ...prev,
+ [vendor.vendorId]: false
+ }));
+ }}
+ >
+ 취소
+ </Button>
+ </div>
+
+ {/* 추가된 커스텀 이메일 목록 */}
+ {vendor.customEmails.length > 0 && (
+ <div className="mt-3 pt-3 border-t">
+ <div className="text-xs text-muted-foreground mb-2">추가된 수신자 목록</div>
+ <div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
+ {vendor.customEmails.map((custom) => (
+ <div key={custom.id} className="flex items-center justify-between bg-background rounded-md p-2">
+ <div className="flex items-center gap-2 min-w-0">
+ <UserPlus className="h-3 w-3 text-green-500 flex-shrink-0" />
+ <div className="min-w-0">
+ <div className="text-sm font-medium truncate">{custom.name}</div>
+ <div className="text-xs text-muted-foreground truncate">{custom.email}</div>
+ </div>
+ </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-6 w-6 p-0 flex-shrink-0"
+ onClick={() => removeCustomEmail(vendor.vendorId, custom.id)}
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+ </td>
+ </tr>
+ )}
+ </React.Fragment>
+ );
+ })}
+ </tbody>
+ </table>
</div>
</div>
- <Separator />
+ <Separator />
{/* 첨부파일 섹션 */}
<div className="space-y-4">
<div className="flex items-center justify-between">
@@ -361,45 +826,30 @@ export function SendRfqDialog({
</Button>
</div>
- <div className="border rounded-lg divide-y">
+ <div className="border rounded-lg divide-y max-h-40 overflow-y-auto">
{attachments.length > 0 ? (
attachments.map((attachment) => (
<div
key={attachment.id}
- className="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
+ className="flex items-center justify-between p-2 hover:bg-muted/50 transition-colors"
>
- <div className="flex items-center gap-3">
+ <div className="flex items-center gap-2">
<Checkbox
checked={selectedAttachments.includes(attachment.id)}
onCheckedChange={() => toggleAttachment(attachment.id)}
/>
{getAttachmentIcon(attachment.attachmentType)}
- <div>
- <div className="flex items-center gap-2">
- <span className="text-sm font-medium">
- {attachment.fileName || `${attachment.attachmentType}_${attachment.serialNo}`}
- </span>
- <Badge variant="outline" className="text-xs">
- {attachment.currentRevision}
- </Badge>
- </div>
- {attachment.description && (
- <p className="text-xs text-muted-foreground mt-0.5">
- {attachment.description}
- </p>
- )}
- </div>
- </div>
- <div className="flex items-center gap-2">
- <span className="text-xs text-muted-foreground">
- {formatFileSize(attachment.fileSize)}
+ <span className="text-sm">
+ {attachment.fileName || `${attachment.attachmentType}_${attachment.serialNo}`}
</span>
+ <Badge variant="outline" className="text-xs">
+ {attachment.currentRevision}
+ </Badge>
</div>
</div>
))
) : (
- <div className="p-8 text-center text-muted-foreground">
- <Paperclip className="h-8 w-8 mx-auto mb-2 opacity-50" />
+ <div className="p-4 text-center text-muted-foreground">
<p className="text-sm">첨부파일이 없습니다.</p>
</div>
)}
@@ -408,124 +858,7 @@ export function SendRfqDialog({
<Separator />
- {/* 수신 업체 섹션 */}
- <div className="space-y-4">
- <div className="flex items-center justify-between">
- <div className="flex items-center gap-2 text-sm font-medium">
- <Building2 className="h-4 w-4" />
- 수신 업체 ({selectedVendors.length})
- </div>
- <Badge variant="outline" className="flex items-center gap-1">
- <Users className="h-3 w-3" />
- 총 {totalRecipientCount}명
- </Badge>
- </div>
-
- <div className="space-y-3">
- {vendorsWithRecipients.map((vendor, index) => (
- <div
- key={vendor.vendorId}
- className="border rounded-lg p-4 space-y-3"
- >
- {/* 업체 정보 */}
- <div className="flex items-start justify-between">
- <div className="flex items-center gap-3">
- <div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary/10 text-primary text-sm font-medium">
- {index + 1}
- </div>
- <div>
- <div className="flex items-center gap-2">
- <span className="font-medium">{vendor.vendorName}</span>
- <Badge variant="outline" className="text-xs">
- {vendor.vendorCountry}
- </Badge>
- </div>
- {vendor.vendorCode && (
- <span className="text-xs text-muted-foreground">
- {vendor.vendorCode}
- </span>
- )}
- </div>
- </div>
- <Badge variant="secondary">
- 주 수신: {vendor.vendorEmail || "vendor@example.com"}
- </Badge>
- </div>
-
- {/* 추가 수신처 */}
- <div className="pl-11 space-y-2">
- <div className="flex items-center gap-2">
- <Label className="text-xs text-muted-foreground">추가 수신처:</Label>
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger>
- <AlertCircle className="h-3 w-3 text-muted-foreground" />
- </TooltipTrigger>
- <TooltipContent>
- <p>참조로 RFQ를 받을 추가 이메일 주소를 입력하세요.</p>
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- </div>
-
- {/* 추가된 이메일 목록 */}
- <div className="flex flex-wrap gap-2">
- {vendor.additionalRecipients.map((email, idx) => (
- <Badge
- key={idx}
- variant="outline"
- className="flex items-center gap-1 pr-1"
- >
- <Mail className="h-3 w-3" />
- {email}
- <Button
- variant="ghost"
- size="sm"
- className="h-4 w-4 p-0 hover:bg-transparent"
- onClick={() => handleRemoveRecipient(vendor.vendorId, idx)}
- >
- <X className="h-3 w-3" />
- </Button>
- </Badge>
- ))}
- </div>
-
- {/* 이메일 입력 필드 */}
- <div className="flex gap-2">
- <Input
- type="email"
- placeholder="추가 수신자 이메일 입력"
- className="h-8 text-sm"
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- e.preventDefault();
- const input = e.target as HTMLInputElement;
- handleAddRecipient(vendor.vendorId, input.value);
- input.value = "";
- }
- }}
- />
- <Button
- variant="outline"
- size="sm"
- onClick={(e) => {
- const input = (e.currentTarget.previousElementSibling as HTMLInputElement);
- handleAddRecipient(vendor.vendorId, input.value);
- input.value = "";
- }}
- >
- <Plus className="h-4 w-4" />
- </Button>
- </div>
- </div>
- </div>
- ))}
- </div>
- </div>
-
- <Separator />
-
- {/* 추가 메시지 (선택사항) */}
+ {/* 추가 메시지 */}
<div className="space-y-2">
<Label htmlFor="message" className="text-sm font-medium">
추가 메시지 (선택사항)
@@ -539,14 +872,16 @@ export function SendRfqDialog({
/>
</div>
</div>
- </ScrollArea>
+ </div>
<DialogFooter className="flex-shrink-0">
- <Alert className="mr-auto max-w-md">
- <AlertCircle className="h-4 w-4" />
- <AlertDescription className="text-xs">
- 발송 후에는 취소할 수 없습니다. 발송 내용을 다시 한번 확인해주세요.
- </AlertDescription>
+ <Alert className="max-w-md">
+ <div className="flex items-center gap-2">
+ <AlertCircle className="h-4 w-4 flex-shrink-0" />
+ <AlertDescription className="text-xs">
+ 발송 후에는 취소할 수 없습니다. 발송 내용을 다시 한번 확인해주세요.
+ </AlertDescription>
+ </div>
</Alert>
<Button
variant="outline"