diff --git a/fe-admin/src/components/pe/PeDetailTabs.tsx b/fe-admin/src/components/pe/PeDetailTabs.tsx index c7ad1f6..73411da 100644 --- a/fe-admin/src/components/pe/PeDetailTabs.tsx +++ b/fe-admin/src/components/pe/PeDetailTabs.tsx @@ -55,6 +55,16 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ const isValidPhone = (s: string): boolean => !s || PHONE_RE.test(s.replace(/[\s\-.]/g, '')) const isValidEmail = (s: string): boolean => !s || EMAIL_RE.test(s) +// Session 20 turn 8: trang trí 5 NCC khác màu (cycle theo index). Winner override +// thành emerald nổi bật. Literal Tailwind class để JIT scan compile được. +const NCC_PALETTES = [ + 'border-l-blue-400 bg-blue-50/40', + 'border-l-purple-400 bg-purple-50/40', + 'border-l-sky-400 bg-sky-50/40', + 'border-l-teal-400 bg-teal-50/40', + 'border-l-pink-400 bg-pink-50/40', +] as const + // Main detail content — flat render 3 section không tabs. // Tên giữ PeDetailTabs để không break callsite (rename gây churn). // @@ -1055,7 +1065,14 @@ function CreateContractDialog({ evaluation, onClose }: { evaluation: PeDetailBun // trong HangMucCard (expand panel mỗi hạng mục). 2 dialog Add/Edit Supplier // vẫn giữ vì HangMucCard call lại. -function AddSupplierDialog({ evaluationId, onClose }: { evaluationId: string; onClose: () => void }) { +// Session 20 turn 8: Dialog thêm NCC mới — khi gọi từ HangMucCard (có detailId) +// thì input "Số tiền" hiển thị + sequential POST: tạo supplier → tạo quote +// cho hạng mục đó. detailId optional cho call site khác trong tương lai. +function AddSupplierDialog({ evaluationId, detailId, onClose }: { + evaluationId: string + detailId?: string + onClose: () => void +}) { const qc = useQueryClient() const suppliers = useQuery({ queryKey: ['all-suppliers'], @@ -1069,14 +1086,44 @@ function AddSupplierDialog({ evaluationId, onClose }: { evaluationId: string; on contactPhone: '', paymentTermText: '', note: '', + thanhTien: 0, }) const phoneError = !isValidPhone(form.contactPhone) ? 'SĐT không hợp lệ (cần 10-11 số bắt đầu 0)' : '' const emailError = !isValidEmail(form.contactEmail) ? 'Email không hợp lệ' : '' const hasError = !!(phoneError || emailError) + const showQuote = !!detailId const mut = useMutation({ - mutationFn: async () => api.post(`/purchase-evaluations/${evaluationId}/suppliers`, form), - onSuccess: () => { toast.success('Đã thêm NCC.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() }, + mutationFn: async () => { + // Step 1: tạo NCC tham gia (PE.Suppliers row) + const res = await api.post<{ id: string }>(`/purchase-evaluations/${evaluationId}/suppliers`, { + supplierId: form.supplierId, + displayName: form.displayName, + contactName: form.contactName, + contactEmail: form.contactEmail, + contactPhone: form.contactPhone, + paymentTermText: form.paymentTermText, + note: form.note, + }) + const newSupplierRowId = res.data.id + // Step 2: tạo quote cho hạng mục (chỉ khi có detailId + thanhTien > 0) + if (detailId && form.thanhTien > 0) { + await api.post(`/purchase-evaluations/${evaluationId}/quotes`, { + purchaseEvaluationDetailId: detailId, + purchaseEvaluationSupplierId: newSupplierRowId, + bgVat: 0, + chuaVat: 0, + thanhTien: form.thanhTien, + note: '', + isSelected: false, + }) + } + }, + onSuccess: () => { + toast.success(showQuote && form.thanhTien > 0 ? 'Đã thêm NCC + báo giá.' : 'Đã thêm NCC.') + qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }) + onClose() + }, onError: e => toast.error(getErrorMessage(e)), }) @@ -1128,6 +1175,25 @@ function AddSupplierDialog({ evaluationId, onClose }: { evaluationId: string; on {emailError &&

{emailError}

}
setForm({ ...form, note: e.target.value })} placeholder="ĐÃ CHỐT SO SÁNH LẦN 1 / ĐÀM PHÁN THÊM..." />
+ {showQuote && ( +
+ +
+ setForm({ ...form, thanhTien: parseVnd(e.target.value) })} + placeholder="0" + className="pr-10 font-mono text-right" + /> + đ +
+

+ Để trống / 0 → chỉ tạo NCC, chưa báo giá. Sửa lại sau bằng cách click số tiền trong bảng. +

+
+ )} @@ -1393,20 +1459,34 @@ function HangMucCard({ - {ev.suppliers.map(s => { + {ev.suppliers.map((s, idx) => { const q = detail.quotes.find(x => x.purchaseEvaluationSupplierId === s.id) ?? null const isWinner = ev.selectedSupplierId === s.supplierId const hasQuotes = ev.details.some(dd => dd.quotes.some(qq => qq.purchaseEvaluationSupplierId === s.id)) const canDelete = !isWinner && !hasQuotes const openQuote = () => setQuoteEdit({ supplier: s, existing: q }) + const palette = NCC_PALETTES[idx % NCC_PALETTES.length] return ( - + -
- {isWinner && }{s.supplierName} +
+ {s.supplierName} + {isWinner && ( + + 🏆 Trúng thầu + + )}
{s.displayName &&
{s.displayName}
} - {s.note &&
{s.note}
} + {s.note &&
{s.note}
} {s.contactPhone || } @@ -1501,7 +1581,7 @@ function HangMucCard({
)} - {addNccOpen && setAddNccOpen(false)} />} + {addNccOpen && setAddNccOpen(false)} />} {editNccRow && setEditNccRow(null)} />} {quoteEdit && ( !s || PHONE_RE.test(s.replace(/[\s\-.]/g, '')) const isValidEmail = (s: string): boolean => !s || EMAIL_RE.test(s) +// Session 20 turn 8: trang trí 5 NCC khác màu (cycle theo index). Winner override +// thành emerald nổi bật. Literal Tailwind class để JIT scan compile được. +const NCC_PALETTES = [ + 'border-l-blue-400 bg-blue-50/40', + 'border-l-purple-400 bg-purple-50/40', + 'border-l-sky-400 bg-sky-50/40', + 'border-l-teal-400 bg-teal-50/40', + 'border-l-pink-400 bg-pink-50/40', +] as const + // Main detail content — flat render 3 section không tabs. // Tên giữ PeDetailTabs để không break callsite (rename gây churn). // @@ -1055,7 +1065,14 @@ function CreateContractDialog({ evaluation, onClose }: { evaluation: PeDetailBun // trong HangMucCard (expand panel mỗi hạng mục). 2 dialog Add/Edit Supplier // vẫn giữ vì HangMucCard call lại. -function AddSupplierDialog({ evaluationId, onClose }: { evaluationId: string; onClose: () => void }) { +// Session 20 turn 8: Dialog thêm NCC mới — khi gọi từ HangMucCard (có detailId) +// thì input "Số tiền" hiển thị + sequential POST: tạo supplier → tạo quote +// cho hạng mục đó. detailId optional cho call site khác trong tương lai. +function AddSupplierDialog({ evaluationId, detailId, onClose }: { + evaluationId: string + detailId?: string + onClose: () => void +}) { const qc = useQueryClient() const suppliers = useQuery({ queryKey: ['all-suppliers'], @@ -1069,14 +1086,42 @@ function AddSupplierDialog({ evaluationId, onClose }: { evaluationId: string; on contactPhone: '', paymentTermText: '', note: '', + thanhTien: 0, }) const phoneError = !isValidPhone(form.contactPhone) ? 'SĐT không hợp lệ (cần 10-11 số bắt đầu 0)' : '' const emailError = !isValidEmail(form.contactEmail) ? 'Email không hợp lệ' : '' const hasError = !!(phoneError || emailError) + const showQuote = !!detailId const mut = useMutation({ - mutationFn: async () => api.post(`/purchase-evaluations/${evaluationId}/suppliers`, form), - onSuccess: () => { toast.success('Đã thêm NCC.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() }, + mutationFn: async () => { + const res = await api.post<{ id: string }>(`/purchase-evaluations/${evaluationId}/suppliers`, { + supplierId: form.supplierId, + displayName: form.displayName, + contactName: form.contactName, + contactEmail: form.contactEmail, + contactPhone: form.contactPhone, + paymentTermText: form.paymentTermText, + note: form.note, + }) + const newSupplierRowId = res.data.id + if (detailId && form.thanhTien > 0) { + await api.post(`/purchase-evaluations/${evaluationId}/quotes`, { + purchaseEvaluationDetailId: detailId, + purchaseEvaluationSupplierId: newSupplierRowId, + bgVat: 0, + chuaVat: 0, + thanhTien: form.thanhTien, + note: '', + isSelected: false, + }) + } + }, + onSuccess: () => { + toast.success(showQuote && form.thanhTien > 0 ? 'Đã thêm NCC + báo giá.' : 'Đã thêm NCC.') + qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }) + onClose() + }, onError: e => toast.error(getErrorMessage(e)), }) @@ -1128,6 +1173,25 @@ function AddSupplierDialog({ evaluationId, onClose }: { evaluationId: string; on {emailError &&

{emailError}

}
setForm({ ...form, note: e.target.value })} placeholder="ĐÃ CHỐT SO SÁNH LẦN 1 / ĐÀM PHÁN THÊM..." />
+ {showQuote && ( +
+ +
+ setForm({ ...form, thanhTien: parseVnd(e.target.value) })} + placeholder="0" + className="pr-10 font-mono text-right" + /> + đ +
+

+ Để trống / 0 → chỉ tạo NCC, chưa báo giá. Sửa lại sau bằng cách click số tiền trong bảng. +

+
+ )} @@ -1393,20 +1457,34 @@ function HangMucCard({ - {ev.suppliers.map(s => { + {ev.suppliers.map((s, idx) => { const q = detail.quotes.find(x => x.purchaseEvaluationSupplierId === s.id) ?? null const isWinner = ev.selectedSupplierId === s.supplierId const hasQuotes = ev.details.some(dd => dd.quotes.some(qq => qq.purchaseEvaluationSupplierId === s.id)) const canDelete = !isWinner && !hasQuotes const openQuote = () => setQuoteEdit({ supplier: s, existing: q }) + const palette = NCC_PALETTES[idx % NCC_PALETTES.length] return ( - + -
- {isWinner && }{s.supplierName} +
+ {s.supplierName} + {isWinner && ( + + 🏆 Trúng thầu + + )}
{s.displayName &&
{s.displayName}
} - {s.note &&
{s.note}
} + {s.note &&
{s.note}
} {s.contactPhone || } @@ -1501,7 +1579,7 @@ function HangMucCard({
)} - {addNccOpen && setAddNccOpen(false)} />} + {addNccOpen && setAddNccOpen(false)} />} {editNccRow && setEditNccRow(null)} />} {quoteEdit && (