From 3ec7b5a1b04523e98c82c12cc68d4190afd3ed6b Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Mon, 11 May 2026 11:53:32 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20FE-PE:=20AddSupplier=20+S=E1=BB=91?= =?UTF-8?q?=20ti=E1=BB=81n=20inline=20+=20NCC=205-m=C3=A0u=20palette=20+?= =?UTF-8?q?=20Winner=20=F0=9F=8F=86=20n=E1=BB=95i=20b=E1=BA=ADt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User Session 20 turn 8 yêu cầu chuỗi UX NCC grid: 1. Thêm NCC dialog cho nhập luôn Số tiền báo giá cho hạng mục 2. Số tiền hiện ra cột so sánh hạng mục (đã có sẵn cột "Số tiền") 3. Trang trí 3+ NCC khác nhau 3+ màu khác nhau 4. NCC được chọn (winner) nổi bật hơn FE-only mirror fe-admin + fe-user. ### AddSupplierDialog — sequential POST tạo NCC + Quote - Thêm prop `detailId?: string` (truyền từ HangMucCard call site) - Form state +`thanhTien: 0` - showQuote = !!detailId — chỉ render input "Số tiền báo giá" khi gọi từ HangMucCard (call site khác giữ behavior cũ tạo NCC only) - Mutation 2 step: 1. POST /purchase-evaluations/{id}/suppliers → response {id} (BE controller PurchaseEvaluationsController.AddSupplier trả Ok(new {id = newId})) 2. Nếu detailId + thanhTien > 0 → POST /quotes với purchaseEvaluationDetailId + purchaseEvaluationSupplierId (newSupplierRowId) + thanhTien - Toast: "Đã thêm NCC + báo giá" (có quote) hoặc "Đã thêm NCC" (no quote) - Section input "Số tiền" trong card brand-50/40 + VND format suffix đ + hint "Để 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." - HangMucCard pass detailId={detail.id} khi mount AddSupplierDialog ### NCC row 5-màu cycle palette - NEW const NCC_PALETTES (literal Tailwind class strings để JIT scan): blue / purple / sky / teal / pink (border-l-4 colored + bg subtle 50/40) - Loop ev.suppliers.map((s, idx) → palette = NCC_PALETTES[idx % 5] - Tr className: `align-top border-l-4` + palette (non-winner) hoặc winner override ### Winner highlight nổi bật - Tr non-winner: cycle palette (5 màu) - Tr winner override: - border-l-emerald-500 (thay vì palette stripe) - bg-emerald-100/70 (đậm hơn 50/60 cũ) - font-semibold + shadow-sm - ring-1 ring-inset ring-emerald-300 (viền trong cho ô nổi) - NCC name cell: badge inline-flex rounded-full bg-emerald-600 text-white text-[9px] font-bold uppercase "🏆 Trúng thầu" (thay icon ✓ cũ) - Note text bumped lên text-amber-700 (chút đậm hơn 600 cũ cho visible khi winner bg đậm hơn) KHÔNG đụng schema BE. 2 endpoint sẵn (POST /suppliers + POST /quotes) chain. Verify: - npm run build × fe-admin pass - npm run build × fe-user pass Co-Authored-By: Claude Opus 4.7 (1M context) --- fe-admin/src/components/pe/PeDetailTabs.tsx | 98 +++++++++++++++++++-- fe-user/src/components/pe/PeDetailTabs.tsx | 96 ++++++++++++++++++-- 2 files changed, 176 insertions(+), 18 deletions(-) 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 && (