[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
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:
@ -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 && <p className="mt-0.5 text-[10px] text-red-600">{emailError}</p>}
|
||||
</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>
|
||||
</Dialog>
|
||||
@ -1393,20 +1459,34 @@ function HangMucCard({
|
||||
</tr>
|
||||
</thead>
|
||||
<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 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 (
|
||||
<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">
|
||||
<div className="font-medium text-slate-900">
|
||||
{isWinner && <span className="text-emerald-700">✓ </span>}{s.supplierName}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<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>
|
||||
{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 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>}
|
||||
@ -1501,7 +1581,7 @@ function HangMucCard({
|
||||
</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)} />}
|
||||
{quoteEdit && (
|
||||
<QuoteDialog
|
||||
|
||||
@ -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,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 && <p className="mt-0.5 text-[10px] text-red-600">{emailError}</p>}
|
||||
</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>
|
||||
</Dialog>
|
||||
@ -1393,20 +1457,34 @@ function HangMucCard({
|
||||
</tr>
|
||||
</thead>
|
||||
<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 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 (
|
||||
<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">
|
||||
<div className="font-medium text-slate-900">
|
||||
{isWinner && <span className="text-emerald-700">✓ </span>}{s.supplierName}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<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>
|
||||
{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 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>}
|
||||
@ -1501,7 +1579,7 @@ function HangMucCard({
|
||||
</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)} />}
|
||||
{quoteEdit && (
|
||||
<QuoteDialog
|
||||
|
||||
Reference in New Issue
Block a user