summaryrefslogtreecommitdiff
path: root/lib/rfq-last
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-09 10:32:34 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-09 10:32:34 +0000
commitc62ec046327fd388ebce04571b55910747e69a3b (patch)
tree41ccdc4a8dea99808622f6d5d52014ac59a2d7ab /lib/rfq-last
parentebcec3f296d1d27943caf8a3aed26efef117cdc5 (diff)
(정희성, 최겸, 대표님) formatDate 변경 등
Diffstat (limited to 'lib/rfq-last')
-rw-r--r--lib/rfq-last/service.ts310
-rw-r--r--lib/rfq-last/vendor/rfq-vendor-table.tsx205
-rw-r--r--lib/rfq-last/vendor/send-rfq-dialog.tsx803
3 files changed, 1022 insertions, 296 deletions
diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts
index 0c75e72f..ac7104df 100644
--- a/lib/rfq-last/service.ts
+++ b/lib/rfq-last/service.ts
@@ -3,7 +3,7 @@
import { revalidatePath, unstable_cache, unstable_noStore } from "next/cache";
import db from "@/db/db";
-import {paymentTerms,incoterms, rfqLastVendorQuotationItems,rfqLastVendorAttachments,rfqLastVendorResponses, RfqsLastView, rfqLastAttachmentRevisions, rfqLastAttachments, rfqsLast, rfqsLastView, users, rfqPrItems, prItemsLastView ,vendors, rfqLastDetails, rfqLastVendorResponseHistory, rfqLastDetailsView} from "@/db/schema";
+import {paymentTerms,incoterms, rfqLastVendorQuotationItems,rfqLastVendorAttachments,rfqLastVendorResponses, RfqsLastView, rfqLastAttachmentRevisions, rfqLastAttachments, rfqsLast, rfqsLastView, users, rfqPrItems, prItemsLastView ,vendors, rfqLastDetails, rfqLastVendorResponseHistory, rfqLastDetailsView, vendorContacts,projects} from "@/db/schema";
import { sql, and, desc, asc, like, ilike, or, eq, SQL, count, gte, lte, isNotNull, ne, inArray } from "drizzle-orm";
import { filterColumns } from "@/lib/filter-columns";
import { GetRfqLastAttachmentsSchema, GetRfqsSchema } from "./validations";
@@ -1570,18 +1570,27 @@ export async function getRfqVendorResponses(rfqId: number) {
)
.orderBy(desc(rfqLastVendorResponses.createdAt));
+ if (!vendorResponsesData || vendorResponsesData.length === 0) {
+ return {
+ success: true,
+ data: [],
+ rfq: rfqData[0],
+ details: details,
+ };
+ }
+
// 4. 각 벤더 응답별 견적 아이템 수와 첨부파일 수 계산
const vendorResponsesWithCounts = await Promise.all(
vendorResponsesData.map(async (response) => {
// 견적 아이템 수 조회
const itemCount = await db
- .select({ count: sql`COUNT(*)::int` })
+ .select({ count: count()})
.from(rfqLastVendorQuotationItems)
.where(eq(rfqLastVendorQuotationItems.vendorResponseId, response.id));
// 첨부파일 수 조회
const attachmentCount = await db
- .select({ count: sql`COUNT(*)::int` })
+ .select({ count: count()})
.from(rfqLastVendorAttachments)
.where(eq(rfqLastVendorAttachments.vendorResponseId, response.id));
@@ -1594,7 +1603,8 @@ export async function getRfqVendorResponses(rfqId: number) {
);
// 5. 응답 데이터 정리
- const formattedResponses = vendorResponsesWithCounts.map(response => ({
+ const formattedResponses = vendorResponsesWithCounts
+ .filter(response => response && response.id).map(response => ({
id: response.id,
rfqsLastId: response.rfqsLastId,
rfqLastDetailsId: response.rfqLastDetailsId,
@@ -2311,4 +2321,296 @@ export async function getRfqVendors(rfqId: number) {
export async function getRfqAttachments(rfqId: number) {
const fullInfo = await getRfqFullInfo(rfqId);
return fullInfo.attachments;
+}
+
+
+// RFQ 발송용 데이터 타입
+export interface RfqSendData {
+ rfqInfo: {
+ rfqCode: string;
+ rfqTitle: string;
+ rfqType: string;
+ projectCode?: string;
+ projectName?: string;
+ picName?: string;
+ picCode?: string;
+ picTeam?: string;
+ packageNo?: string;
+ packageName?: string;
+ designPicName?: string;
+ designTeam?: string;
+ materialGroup?: string;
+ materialGroupDesc?: string;
+ dueDate: Date;
+ quotationType?: string;
+ evaluationApply?: boolean;
+ contractType?: string;
+ };
+ attachments: Array<{
+ id: number;
+ attachmentType: string;
+ serialNo: string;
+ currentRevision: string;
+ description?: string | null;
+ fileName?: string | null;
+ fileSize?: number | null;
+ uploadedAt?: Date;
+ }>;
+}
+
+// 선택된 벤더의 이메일 정보 조회
+export interface VendorEmailInfo {
+ vendorId: number;
+ vendorName: string;
+ vendorCode?: string | null;
+ vendorCountry?: string | null;
+ vendorEmail?: string | null; // vendors 테이블의 기본 이메일
+ representativeEmail?: string | null; // 대표자 이메일
+ contactEmails: string[]; // 영업/대표 담당자 이메일들
+ primaryEmail?: string | null; // 최종 선택된 주 이메일
+ currency?: string | null;
+}
+
+/**
+ * RFQ 발송 다이얼로그용 데이터 조회
+ */
+export async function getRfqSendData(rfqId: number): Promise<RfqSendData> {
+ try {
+ // 1. RFQ 기본 정보 조회
+ const [rfqData] = await db
+ .select({
+ rfq: rfqsLast,
+ project: projects,
+ picUser: users,
+ })
+ .from(rfqsLast)
+ .leftJoin(projects, eq(rfqsLast.projectId, projects.id))
+ .leftJoin(users, eq(rfqsLast.pic, users.id))
+ .where(eq(rfqsLast.id, rfqId))
+ .limit(1);
+
+ if (!rfqData) {
+ throw new Error(`RFQ ID ${rfqId}를 찾을 수 없습니다.`);
+ }
+
+ const { rfq, project, picUser } = rfqData;
+
+ // 2. PR Items에서 자재그룹 정보 조회 (Major Item)
+ const [majorItem] = await db
+ .select({
+ materialCategory: rfqPrItems.materialCategory,
+ materialDescription: rfqPrItems.materialDescription,
+ })
+ .from(rfqPrItems)
+ .where(and(
+ eq(rfqPrItems.rfqsLastId, rfqId),
+ eq(rfqPrItems.majorYn, true)
+ ))
+ .limit(1);
+
+ // 3. 첨부파일 정보 조회
+ const attachmentsData = await db
+ .select({
+ attachment: rfqLastAttachments,
+ revision: rfqLastAttachmentRevisions,
+ })
+ .from(rfqLastAttachments)
+ .leftJoin(
+ rfqLastAttachmentRevisions,
+ and(
+ eq(rfqLastAttachments.latestRevisionId, rfqLastAttachmentRevisions.id),
+ eq(rfqLastAttachmentRevisions.isLatest, true)
+ )
+ )
+ .where(eq(rfqLastAttachments.rfqId, rfqId));
+
+ const attachments = attachmentsData.map(a => ({
+ id: a.attachment.id,
+ attachmentType: a.attachment.attachmentType,
+ serialNo: a.attachment.serialNo,
+ currentRevision: a.attachment.currentRevision,
+ description: a.attachment.description,
+ fileName: a.revision?.originalFileName ?? null,
+ fileSize: a.revision?.fileSize ?? null,
+ uploadedAt: a.attachment.createdAt,
+ }));
+
+ // 4. RFQ 정보 조합
+ const rfqInfo = {
+ rfqCode: rfq.rfqCode || '',
+ rfqTitle: rfq.rfqTitle || '',
+ rfqType: rfq.rfqType || '',
+ projectCode: project?.code || undefined,
+ projectName: project?.name || undefined,
+ picName: rfq.picName || undefined,
+ picCode: rfq.picCode || undefined,
+ picTeam: picUser?.deptName || undefined,
+ packageNo: rfq.packageNo || undefined,
+ packageName: rfq.packageName || undefined,
+ designPicName: rfq.EngPicName || undefined,
+ rfqTitle: rfq.rfqTitle || undefined,
+ rfqType: rfq.rfqType || undefined,
+ designTeam: undefined, // 필요시 추가 조회
+ materialGroup: majorItem?.materialCategory || undefined,
+ materialGroupDesc: majorItem?.materialDescription || undefined,
+ dueDate: rfq.dueDate || new Date(),
+ quotationType: rfq.rfqType || undefined,
+ evaluationApply: true, // 기본값 또는 별도 필드
+ contractType: undefined, // 필요시 추가
+ };
+
+ return {
+ rfqInfo,
+ attachments,
+ };
+ } catch (error) {
+ console.error("RFQ 발송 데이터 조회 실패:", error);
+ throw error;
+ }
+}
+
+interface ContactDetail {
+ id: number;
+ name: string;
+ position?: string | null;
+ department?: string | null;
+ email: string;
+ phone?: string | null;
+ isPrimary: boolean;
+}
+
+/**
+ * 벤더 이메일 정보 조회
+ */
+export async function getVendorEmailInfo(vendorIds: number[]): Promise<VendorEmailInfo[]> {
+ try {
+ // 1. 벤더 기본 정보 조회
+ const vendorsData = await db
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ country: vendors.country,
+ email: vendors.email,
+ representativeEmail: vendors.representativeEmail,
+ })
+ .from(vendors)
+ .where(sql`${vendors.id} IN ${vendorIds}`);
+
+ // 2. 각 벤더의 모든 담당자 정보 조회
+ const contactsData = await db
+ .select({
+ id: vendorContacts.id,
+ vendorId: vendorContacts.vendorId,
+ contactName: vendorContacts.contactName,
+ contactPosition: vendorContacts.contactPosition,
+ contactDepartment: vendorContacts.contactDepartment,
+ contactEmail: vendorContacts.contactEmail,
+ contactPhone: vendorContacts.contactPhone,
+ isPrimary: vendorContacts.isPrimary,
+ })
+ .from(vendorContacts)
+ .where(sql`${vendorContacts.vendorId} IN ${vendorIds}`);
+
+ // 3. 데이터 조합
+ const vendorEmailInfos: VendorEmailInfo[] = vendorsData.map(vendor => {
+ const vendorContacts = contactsData.filter(c => c.vendorId === vendor.id);
+
+ // ContactDetail 형식으로 변환
+ const contacts: ContactDetail[] = vendorContacts.map(c => ({
+ id: c.id,
+ name: c.contactName,
+ position: c.contactPosition,
+ department: c.contactDepartment,
+ email: c.contactEmail,
+ phone: c.contactPhone,
+ isPrimary: c.isPrimary,
+ }));
+
+ // 포지션별로 그룹화
+ const contactsByPosition: Record<string, ContactDetail[]> = {};
+ contacts.forEach(contact => {
+ const position = contact.position || '기타';
+ if (!contactsByPosition[position]) {
+ contactsByPosition[position] = [];
+ }
+ contactsByPosition[position].push(contact);
+ });
+
+ // 주 이메일 선택 우선순위:
+ // 1. isPrimary가 true인 담당자 이메일
+ // 2. 대표자 이메일
+ // 3. vendors 테이블의 기본 이메일
+ // 4. 영업 담당자 이메일
+ // 5. 첫번째 담당자 이메일
+ const primaryContact = contacts.find(c => c.isPrimary);
+ const salesContact = contacts.find(c => c.position === '영업');
+ const primaryEmail =
+ primaryContact?.email ||
+ vendor.representativeEmail ||
+ vendor.email ||
+ salesContact?.email ||
+ contacts[0]?.email ||
+ null;
+
+ return {
+ vendorId: vendor.id,
+ vendorName: vendor.vendorName,
+ vendorCode: vendor.vendorCode,
+ vendorCountry: vendor.country,
+ vendorEmail: vendor.email,
+ representativeEmail: vendor.representativeEmail,
+ contacts,
+ contactsByPosition,
+ primaryEmail,
+ currency: 'KRW', // 기본값, 필요시 별도 조회
+ };
+ });
+
+ return vendorEmailInfos;
+ } catch (error) {
+ console.error("벤더 이메일 정보 조회 실패:", error);
+ throw error;
+ }
+}
+
+/**
+ * 선택된 벤더들의 상세 정보 조회 (RFQ Detail 포함)
+ */
+export async function getSelectedVendorsWithEmails(
+ rfqId: number,
+ vendorIds: number[]
+): Promise<Array<VendorEmailInfo & { currency?: string | null }>> {
+ try {
+ // 1. 벤더 이메일 정보 조회
+ const vendorEmailInfos = await getVendorEmailInfo(vendorIds);
+
+ // 2. RFQ Detail에서 통화 정보 조회 (옵션)
+ const rfqDetailsData = await db
+ .select({
+ vendorId: rfqLastDetails.vendorsId,
+ currency: rfqLastDetails.currency,
+ })
+ .from(rfqLastDetails)
+ .where(
+ and(
+ eq(rfqLastDetails.rfqsLastId, rfqId),
+ sql`${rfqLastDetails.vendorsId} IN ${vendorIds}`
+ )
+ );
+
+ // 3. 통화 정보 병합
+ const result = vendorEmailInfos.map(vendor => {
+ const detail = rfqDetailsData.find(d => d.vendorId === vendor.vendorId);
+ return {
+ ...vendor,
+ currency: detail?.currency || vendor.currency || 'KRW',
+ };
+ });
+
+ return result;
+ } catch (error) {
+ console.error("선택된 벤더 정보 조회 실패:", error);
+ throw error;
+ }
} \ No newline at end of file
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"