[CLAUDE] FE-PE: AddSupplier +Số tiền inline + NCC 5-màu palette + Winner 🏆 nổi bật
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m8s

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) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-11 11:53:32 +07:00
parent aab88621e8
commit 3ec7b5a1b0
2 changed files with 176 additions and 18 deletions

View File

@ -55,6 +55,16 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const isValidPhone = (s: string): boolean => !s || PHONE_RE.test(s.replace(/[\s\-.]/g, '')) const isValidPhone = (s: string): boolean => !s || PHONE_RE.test(s.replace(/[\s\-.]/g, ''))
const isValidEmail = (s: string): boolean => !s || EMAIL_RE.test(s) 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. // Main detail content — flat render 3 section không tabs.
// Tên giữ PeDetailTabs để không break callsite (rename gây churn). // 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 // trong HangMucCard (expand panel mỗi hạng mục). 2 dialog Add/Edit Supplier
// vẫn giữ vì HangMucCard call lại. // 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 qc = useQueryClient()
const suppliers = useQuery({ const suppliers = useQuery({
queryKey: ['all-suppliers'], queryKey: ['all-suppliers'],
@ -1069,14 +1086,44 @@ function AddSupplierDialog({ evaluationId, onClose }: { evaluationId: string; on
contactPhone: '', contactPhone: '',
paymentTermText: '', paymentTermText: '',
note: '', 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 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 emailError = !isValidEmail(form.contactEmail) ? 'Email không hợp lệ' : ''
const hasError = !!(phoneError || emailError) const hasError = !!(phoneError || emailError)
const showQuote = !!detailId
const mut = useMutation({ const mut = useMutation({
mutationFn: async () => api.post(`/purchase-evaluations/${evaluationId}/suppliers`, form), mutationFn: async () => {
onSuccess: () => { toast.success('Đã thêm NCC.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() }, // 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)), onError: e => toast.error(getErrorMessage(e)),
}) })
@ -1128,6 +1175,25 @@ function AddSupplierDialog({ evaluationId, onClose }: { evaluationId: string; on
{emailError && <p className="mt-0.5 text-[10px] text-red-600">{emailError}</p>} {emailError && <p className="mt-0.5 text-[10px] text-red-600">{emailError}</p>}
</div> </div>
<div className="col-span-2"><Label>Ghi chú</Label><Input value={form.note} onChange={e => setForm({ ...form, note: e.target.value })} placeholder="ĐÃ CHỐT SO SÁNH LẦN 1 / ĐÀM PHÁN THÊM..." /></div> <div className="col-span-2"><Label>Ghi chú</Label><Input value={form.note} onChange={e => setForm({ ...form, note: e.target.value })} placeholder="ĐÃ CHỐT SO SÁNH LẦN 1 / ĐÀM PHÁN THÊM..." /></div>
{showQuote && (
<div className="col-span-2 rounded-lg border border-brand-200 bg-brand-50/40 p-3">
<Label className="text-brand-700">Số tiền báo giá cho hạng mục</Label>
<div className="relative mt-1 max-w-xs">
<Input
type="text"
inputMode="numeric"
value={formatVndInput(form.thanhTien)}
onChange={e => setForm({ ...form, thanhTien: parseVnd(e.target.value) })}
placeholder="0"
className="pr-10 font-mono text-right"
/>
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
</div>
<p className="mt-1 text-[11px] text-slate-500">
Đ 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.
</p>
</div>
)}
</div> </div>
</div> </div>
</Dialog> </Dialog>
@ -1393,20 +1459,34 @@ function HangMucCard({
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-100"> <tbody className="divide-y divide-slate-100">
{ev.suppliers.map(s => { {ev.suppliers.map((s, idx) => {
const q = detail.quotes.find(x => x.purchaseEvaluationSupplierId === s.id) ?? null const q = detail.quotes.find(x => x.purchaseEvaluationSupplierId === s.id) ?? null
const isWinner = ev.selectedSupplierId === s.supplierId const isWinner = ev.selectedSupplierId === s.supplierId
const hasQuotes = ev.details.some(dd => dd.quotes.some(qq => qq.purchaseEvaluationSupplierId === s.id)) const hasQuotes = ev.details.some(dd => dd.quotes.some(qq => qq.purchaseEvaluationSupplierId === s.id))
const canDelete = !isWinner && !hasQuotes const canDelete = !isWinner && !hasQuotes
const openQuote = () => setQuoteEdit({ supplier: s, existing: q }) const openQuote = () => setQuoteEdit({ supplier: s, existing: q })
const palette = NCC_PALETTES[idx % NCC_PALETTES.length]
return ( return (
<tr key={s.id} className={cn('align-top', isWinner && 'bg-emerald-50/60')}> <tr
key={s.id}
className={cn(
'align-top border-l-4',
isWinner
? 'border-l-emerald-500 bg-emerald-100/70 font-semibold shadow-sm ring-1 ring-inset ring-emerald-300'
: palette,
)}
>
<td className="border-r border-slate-200 px-2 py-1.5"> <td className="border-r border-slate-200 px-2 py-1.5">
<div className="font-medium text-slate-900"> <div className="flex flex-wrap items-center gap-1.5">
{isWinner && <span className="text-emerald-700"> </span>}{s.supplierName} <span className="font-medium text-slate-900">{s.supplierName}</span>
{isWinner && (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-600 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wide text-white shadow-sm">
🏆 Trúng thầu
</span>
)}
</div> </div>
{s.displayName && <div className="text-[10px] text-slate-500">{s.displayName}</div>} {s.displayName && <div className="text-[10px] text-slate-500">{s.displayName}</div>}
{s.note && <div className="text-[10px] text-amber-600">{s.note}</div>} {s.note && <div className="text-[10px] text-amber-700">{s.note}</div>}
</td> </td>
<td className="border-r border-slate-200 px-2 py-1.5 text-[11px] text-slate-600 font-mono"> <td className="border-r border-slate-200 px-2 py-1.5 text-[11px] text-slate-600 font-mono">
{s.contactPhone || <span className="text-slate-300"></span>} {s.contactPhone || <span className="text-slate-300"></span>}
@ -1501,7 +1581,7 @@ function HangMucCard({
</div> </div>
)} )}
{addNccOpen && <AddSupplierDialog evaluationId={ev.id} onClose={() => setAddNccOpen(false)} />} {addNccOpen && <AddSupplierDialog evaluationId={ev.id} detailId={detail.id} onClose={() => setAddNccOpen(false)} />}
{editNccRow && <EditSupplierDialog evaluationId={ev.id} row={editNccRow} onClose={() => setEditNccRow(null)} />} {editNccRow && <EditSupplierDialog evaluationId={ev.id} row={editNccRow} onClose={() => setEditNccRow(null)} />}
{quoteEdit && ( {quoteEdit && (
<QuoteDialog <QuoteDialog

View File

@ -55,6 +55,16 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const isValidPhone = (s: string): boolean => !s || PHONE_RE.test(s.replace(/[\s\-.]/g, '')) const isValidPhone = (s: string): boolean => !s || PHONE_RE.test(s.replace(/[\s\-.]/g, ''))
const isValidEmail = (s: string): boolean => !s || EMAIL_RE.test(s) 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. // Main detail content — flat render 3 section không tabs.
// Tên giữ PeDetailTabs để không break callsite (rename gây churn). // 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 // trong HangMucCard (expand panel mỗi hạng mục). 2 dialog Add/Edit Supplier
// vẫn giữ vì HangMucCard call lại. // 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 qc = useQueryClient()
const suppliers = useQuery({ const suppliers = useQuery({
queryKey: ['all-suppliers'], queryKey: ['all-suppliers'],
@ -1069,14 +1086,42 @@ function AddSupplierDialog({ evaluationId, onClose }: { evaluationId: string; on
contactPhone: '', contactPhone: '',
paymentTermText: '', paymentTermText: '',
note: '', 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 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 emailError = !isValidEmail(form.contactEmail) ? 'Email không hợp lệ' : ''
const hasError = !!(phoneError || emailError) const hasError = !!(phoneError || emailError)
const showQuote = !!detailId
const mut = useMutation({ const mut = useMutation({
mutationFn: async () => api.post(`/purchase-evaluations/${evaluationId}/suppliers`, form), mutationFn: async () => {
onSuccess: () => { toast.success('Đã thêm NCC.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() }, 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)), onError: e => toast.error(getErrorMessage(e)),
}) })
@ -1128,6 +1173,25 @@ function AddSupplierDialog({ evaluationId, onClose }: { evaluationId: string; on
{emailError && <p className="mt-0.5 text-[10px] text-red-600">{emailError}</p>} {emailError && <p className="mt-0.5 text-[10px] text-red-600">{emailError}</p>}
</div> </div>
<div className="col-span-2"><Label>Ghi chú</Label><Input value={form.note} onChange={e => setForm({ ...form, note: e.target.value })} placeholder="ĐÃ CHỐT SO SÁNH LẦN 1 / ĐÀM PHÁN THÊM..." /></div> <div className="col-span-2"><Label>Ghi chú</Label><Input value={form.note} onChange={e => setForm({ ...form, note: e.target.value })} placeholder="ĐÃ CHỐT SO SÁNH LẦN 1 / ĐÀM PHÁN THÊM..." /></div>
{showQuote && (
<div className="col-span-2 rounded-lg border border-brand-200 bg-brand-50/40 p-3">
<Label className="text-brand-700">Số tiền báo giá cho hạng mục</Label>
<div className="relative mt-1 max-w-xs">
<Input
type="text"
inputMode="numeric"
value={formatVndInput(form.thanhTien)}
onChange={e => setForm({ ...form, thanhTien: parseVnd(e.target.value) })}
placeholder="0"
className="pr-10 font-mono text-right"
/>
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
</div>
<p className="mt-1 text-[11px] text-slate-500">
Đ 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.
</p>
</div>
)}
</div> </div>
</div> </div>
</Dialog> </Dialog>
@ -1393,20 +1457,34 @@ function HangMucCard({
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-100"> <tbody className="divide-y divide-slate-100">
{ev.suppliers.map(s => { {ev.suppliers.map((s, idx) => {
const q = detail.quotes.find(x => x.purchaseEvaluationSupplierId === s.id) ?? null const q = detail.quotes.find(x => x.purchaseEvaluationSupplierId === s.id) ?? null
const isWinner = ev.selectedSupplierId === s.supplierId const isWinner = ev.selectedSupplierId === s.supplierId
const hasQuotes = ev.details.some(dd => dd.quotes.some(qq => qq.purchaseEvaluationSupplierId === s.id)) const hasQuotes = ev.details.some(dd => dd.quotes.some(qq => qq.purchaseEvaluationSupplierId === s.id))
const canDelete = !isWinner && !hasQuotes const canDelete = !isWinner && !hasQuotes
const openQuote = () => setQuoteEdit({ supplier: s, existing: q }) const openQuote = () => setQuoteEdit({ supplier: s, existing: q })
const palette = NCC_PALETTES[idx % NCC_PALETTES.length]
return ( return (
<tr key={s.id} className={cn('align-top', isWinner && 'bg-emerald-50/60')}> <tr
key={s.id}
className={cn(
'align-top border-l-4',
isWinner
? 'border-l-emerald-500 bg-emerald-100/70 font-semibold shadow-sm ring-1 ring-inset ring-emerald-300'
: palette,
)}
>
<td className="border-r border-slate-200 px-2 py-1.5"> <td className="border-r border-slate-200 px-2 py-1.5">
<div className="font-medium text-slate-900"> <div className="flex flex-wrap items-center gap-1.5">
{isWinner && <span className="text-emerald-700"> </span>}{s.supplierName} <span className="font-medium text-slate-900">{s.supplierName}</span>
{isWinner && (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-600 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wide text-white shadow-sm">
🏆 Trúng thầu
</span>
)}
</div> </div>
{s.displayName && <div className="text-[10px] text-slate-500">{s.displayName}</div>} {s.displayName && <div className="text-[10px] text-slate-500">{s.displayName}</div>}
{s.note && <div className="text-[10px] text-amber-600">{s.note}</div>} {s.note && <div className="text-[10px] text-amber-700">{s.note}</div>}
</td> </td>
<td className="border-r border-slate-200 px-2 py-1.5 text-[11px] text-slate-600 font-mono"> <td className="border-r border-slate-200 px-2 py-1.5 text-[11px] text-slate-600 font-mono">
{s.contactPhone || <span className="text-slate-300"></span>} {s.contactPhone || <span className="text-slate-300"></span>}
@ -1501,7 +1579,7 @@ function HangMucCard({
</div> </div>
)} )}
{addNccOpen && <AddSupplierDialog evaluationId={ev.id} onClose={() => setAddNccOpen(false)} />} {addNccOpen && <AddSupplierDialog evaluationId={ev.id} detailId={detail.id} onClose={() => setAddNccOpen(false)} />}
{editNccRow && <EditSupplierDialog evaluationId={ev.id} row={editNccRow} onClose={() => setEditNccRow(null)} />} {editNccRow && <EditSupplierDialog evaluationId={ev.id} row={editNccRow} onClose={() => setEditNccRow(null)} />}
{quoteEdit && ( {quoteEdit && (
<QuoteDialog <QuoteDialog