[CLAUDE] FE-Admin+FE-User: PE QuoteDialog bỏ checkbox isSelected + winner column highlight + loading overlay/spinner
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m1s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m1s
User feedback 2026-05-07 (annotation):
1. Bỏ checkbox "Chọn NCC này cho hạng mục" trong QuoteDialog (consolidate winner
selection chỉ ở Section 2.a NccSelectorRow — tránh 2 nơi pick winner).
2. Khi NCC là winner (selectedSupplierId === s.supplierId) → cell giá Section 4
matrix ăn theo màu xanh emerald (header + cells trong column).
3. Save có delay → hiện loading spinner / overlay để user biết đang xử lý.
Implementation:
~ QuoteDialog (× 2 app):
- Remove `isSelected` từ form state + UI checkbox
- Vẫn gửi `isSelected: existing?.isSelected ?? false` lên API (giữ nguyên
trạng thái cũ — không expose UI để tránh confusion)
- Disable Xóa/Hủy/Lưu khi `isSaving = mut.isPending || del.isPending`
- Button text: "Đang lưu báo giá…" / "Đang xóa…" thay "Lưu" / "Xóa"
- Full overlay loading: absolute z-10 + bg-white/70 backdrop-blur-sm + spinner
ring brand-600 + status text rõ ràng
~ ItemsTab matrix (× 2 app):
- Column header `<th>`: thêm `isWinner` check → bg-emerald-50 + text-emerald-700
+ prefix "✓ " trước tên NCC khi winner
- Cell `<td>`: thay `q?.isSelected` highlight → `isWinnerColumn` (entire
column ăn theo Section 2.a winner). Cells của winner column LUÔN xanh
bất kể quote đã nhập hay chưa (visual trace winner rõ ràng).
~ NccSelectorRow (× 2 app):
- Wrap Select trong `relative` div
- Thêm inline spinner + text "Đang chọn NCC + sync cột giá Section 4…"
khi setWinner.isPending — báo cho user biết delay đang xử lý
Verify: npm run build fe-admin + fe-user pass · 0 TS error.
UAT mode: skip dotnet test (FE-only), push ngay.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -567,7 +567,7 @@ function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolea
|
||||
return (
|
||||
<div className="flex items-baseline gap-3 border-b border-dotted border-slate-200 pb-1.5">
|
||||
<span className="w-44 shrink-0 text-[12px] text-slate-500">a. NCC / TP được chọn</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Select
|
||||
value={ev.selectedSupplierId ?? ''}
|
||||
onChange={e => setWinner.mutate(e.target.value)}
|
||||
@ -581,6 +581,13 @@ function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolea
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
{/* Loading spinner inline khi save có delay (user 2026-05-07) */}
|
||||
{setWinner.isPending && (
|
||||
<div className="mt-1 flex items-center gap-1.5 text-[11px] text-brand-600">
|
||||
<div className="h-3 w-3 animate-spin rounded-full border-2 border-brand-300 border-t-brand-600" />
|
||||
<span>Đang chọn NCC + sync cột giá Section 4…</span>
|
||||
</div>
|
||||
)}
|
||||
{ev.suppliers.length === 0 && (
|
||||
<p className="mt-1 text-[11px] text-amber-600">
|
||||
Thêm NCC ở Section 3 trước rồi mới chọn winner.
|
||||
@ -1185,14 +1192,24 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
|
||||
NS link · Δ
|
||||
</th>
|
||||
)}
|
||||
{ev.suppliers.map(s => (
|
||||
<th key={s.id} className="border-r border-slate-200 px-2 py-2 text-right" title={s.displayName ?? undefined}>
|
||||
{/* User 2026-05-07: dùng tên NCC (master) thay vì displayName
|
||||
(custom name) để column header rõ ràng. displayName fallback
|
||||
sang title tooltip nếu có. */}
|
||||
{s.supplierName}
|
||||
</th>
|
||||
))}
|
||||
{ev.suppliers.map(s => {
|
||||
// User 2026-05-07: dùng tên NCC (master) thay vì displayName.
|
||||
// Khi NCC là winner (selected ở Section 2.a) → column highlight
|
||||
// emerald để cell giá ăn theo màu xanh (visual trace winner).
|
||||
const isWinner = ev.selectedSupplierId === s.supplierId
|
||||
return (
|
||||
<th
|
||||
key={s.id}
|
||||
className={cn(
|
||||
'border-r border-slate-200 px-2 py-2 text-right',
|
||||
isWinner && 'bg-emerald-50 text-emerald-700',
|
||||
)}
|
||||
title={s.displayName ?? undefined}
|
||||
>
|
||||
{isWinner && '✓ '}{s.supplierName}
|
||||
</th>
|
||||
)
|
||||
})}
|
||||
{!readOnly && <th className="px-2 py-2"></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
@ -1228,6 +1245,10 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
|
||||
})()}
|
||||
{ev.suppliers.map(s => {
|
||||
const q = quoteKey(d.id, s.id)
|
||||
// Winner NCC (selected ở Section 2.a) → cell ăn theo màu xanh
|
||||
// emerald (user 2026-05-07). isSelected per-quote checkbox bỏ
|
||||
// (đã consolidate winner ở Section 2.a NccSelectorRow).
|
||||
const isWinnerColumn = ev.selectedSupplierId === s.supplierId
|
||||
return (
|
||||
<td
|
||||
key={s.id}
|
||||
@ -1235,7 +1256,7 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
|
||||
className={cn(
|
||||
'border-r border-slate-200 px-2 py-2 text-right font-mono transition',
|
||||
!readOnly && 'cursor-pointer hover:bg-brand-50',
|
||||
q?.isSelected && 'bg-emerald-50 font-semibold text-emerald-700',
|
||||
isWinnerColumn && 'bg-emerald-50 font-semibold text-emerald-700',
|
||||
)}
|
||||
>
|
||||
{q ? fmtMoney(q.thanhTien) : <span className="text-slate-300">—</span>}
|
||||
@ -1379,11 +1400,13 @@ function QuoteDialog({
|
||||
onClose: () => void
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
// User 2026-05-07: Bỏ `isSelected` checkbox per-quote (consolidate winner
|
||||
// selection ở Section 2.a NccSelectorRow). BE vẫn nhận isSelected nhưng FE
|
||||
// luôn gửi `false` (existing.isSelected nếu có để giữ nguyên trạng thái cũ).
|
||||
const [form, setForm] = useState({
|
||||
bgVat: existing?.bgVat ?? 0,
|
||||
chuaVat: existing?.chuaVat ?? 0,
|
||||
thanhTien: existing?.thanhTien ?? 0,
|
||||
isSelected: existing?.isSelected ?? false,
|
||||
note: existing?.note ?? '',
|
||||
})
|
||||
|
||||
@ -1399,6 +1422,7 @@ function QuoteDialog({
|
||||
purchaseEvaluationDetailId: detailId,
|
||||
purchaseEvaluationSupplierId: supplierRowId,
|
||||
...form,
|
||||
isSelected: existing?.isSelected ?? false, // giữ nguyên trạng thái cũ, không expose UI
|
||||
}),
|
||||
onSuccess: () => { toast.success('Đã lưu báo giá.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() },
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
@ -1410,28 +1434,37 @@ function QuoteDialog({
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const isSaving = mut.isPending || del.isPending
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
title={`Báo giá — ${supplierName}`}
|
||||
footer={<>
|
||||
{existing && <Button variant="danger" onClick={() => del.mutate()} disabled={del.isPending}>Xóa</Button>}
|
||||
<Button variant="ghost" onClick={onClose}>Hủy</Button>
|
||||
<Button onClick={() => mut.mutate()} disabled={mut.isPending}>Lưu</Button>
|
||||
{existing && <Button variant="danger" onClick={() => del.mutate()} disabled={isSaving}>{del.isPending ? 'Đang xóa…' : 'Xóa'}</Button>}
|
||||
<Button variant="ghost" onClick={onClose} disabled={isSaving}>Hủy</Button>
|
||||
<Button onClick={() => mut.mutate()} disabled={isSaving}>{mut.isPending ? 'Đang lưu…' : 'Lưu'}</Button>
|
||||
</>}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{/* Loading overlay khi save có delay (user 2026-05-07) */}
|
||||
<div className="relative space-y-3">
|
||||
{isSaving && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded bg-white/70 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2 shadow-md">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-brand-300 border-t-brand-600" />
|
||||
<span className="text-sm font-medium text-slate-700">
|
||||
{mut.isPending ? 'Đang lưu báo giá…' : 'Đang xóa…'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-slate-500">Hạng mục: <strong>{itemName}</strong> · KL {khoiLuong}</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div><Label>Đơn giá chưa VAT</Label><Input type="number" value={form.chuaVat} onChange={e => updateAndRecalc({ chuaVat: Number(e.target.value) })} /></div>
|
||||
<div><Label>Đơn giá có VAT</Label><Input type="number" value={form.bgVat} onChange={e => setForm({ ...form, bgVat: Number(e.target.value) })} /></div>
|
||||
<div><Label>Thành tiền (auto)</Label><Input type="number" value={form.thanhTien} onChange={e => setForm({ ...form, thanhTien: Number(e.target.value) })} /></div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={form.isSelected} onChange={e => setForm({ ...form, isSelected: e.target.checked })} />
|
||||
Chọn NCC này cho hạng mục
|
||||
</label>
|
||||
<div><Label>Ghi chú</Label><Input value={form.note} onChange={e => setForm({ ...form, note: e.target.value })} /></div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
@ -567,7 +567,7 @@ function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolea
|
||||
return (
|
||||
<div className="flex items-baseline gap-3 border-b border-dotted border-slate-200 pb-1.5">
|
||||
<span className="w-44 shrink-0 text-[12px] text-slate-500">a. NCC / TP được chọn</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Select
|
||||
value={ev.selectedSupplierId ?? ''}
|
||||
onChange={e => setWinner.mutate(e.target.value)}
|
||||
@ -581,6 +581,13 @@ function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolea
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
{/* Loading spinner inline khi save có delay (user 2026-05-07) */}
|
||||
{setWinner.isPending && (
|
||||
<div className="mt-1 flex items-center gap-1.5 text-[11px] text-brand-600">
|
||||
<div className="h-3 w-3 animate-spin rounded-full border-2 border-brand-300 border-t-brand-600" />
|
||||
<span>Đang chọn NCC + sync cột giá Section 4…</span>
|
||||
</div>
|
||||
)}
|
||||
{ev.suppliers.length === 0 && (
|
||||
<p className="mt-1 text-[11px] text-amber-600">
|
||||
Thêm NCC ở Section 3 trước rồi mới chọn winner.
|
||||
@ -1185,14 +1192,24 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
|
||||
NS link · Δ
|
||||
</th>
|
||||
)}
|
||||
{ev.suppliers.map(s => (
|
||||
<th key={s.id} className="border-r border-slate-200 px-2 py-2 text-right" title={s.displayName ?? undefined}>
|
||||
{/* User 2026-05-07: dùng tên NCC (master) thay vì displayName
|
||||
(custom name) để column header rõ ràng. displayName fallback
|
||||
sang title tooltip nếu có. */}
|
||||
{s.supplierName}
|
||||
</th>
|
||||
))}
|
||||
{ev.suppliers.map(s => {
|
||||
// User 2026-05-07: dùng tên NCC (master) thay vì displayName.
|
||||
// Khi NCC là winner (selected ở Section 2.a) → column highlight
|
||||
// emerald để cell giá ăn theo màu xanh (visual trace winner).
|
||||
const isWinner = ev.selectedSupplierId === s.supplierId
|
||||
return (
|
||||
<th
|
||||
key={s.id}
|
||||
className={cn(
|
||||
'border-r border-slate-200 px-2 py-2 text-right',
|
||||
isWinner && 'bg-emerald-50 text-emerald-700',
|
||||
)}
|
||||
title={s.displayName ?? undefined}
|
||||
>
|
||||
{isWinner && '✓ '}{s.supplierName}
|
||||
</th>
|
||||
)
|
||||
})}
|
||||
{!readOnly && <th className="px-2 py-2"></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
@ -1228,6 +1245,10 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
|
||||
})()}
|
||||
{ev.suppliers.map(s => {
|
||||
const q = quoteKey(d.id, s.id)
|
||||
// Winner NCC (selected ở Section 2.a) → cell ăn theo màu xanh
|
||||
// emerald (user 2026-05-07). isSelected per-quote checkbox bỏ
|
||||
// (đã consolidate winner ở Section 2.a NccSelectorRow).
|
||||
const isWinnerColumn = ev.selectedSupplierId === s.supplierId
|
||||
return (
|
||||
<td
|
||||
key={s.id}
|
||||
@ -1235,7 +1256,7 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
|
||||
className={cn(
|
||||
'border-r border-slate-200 px-2 py-2 text-right font-mono transition',
|
||||
!readOnly && 'cursor-pointer hover:bg-brand-50',
|
||||
q?.isSelected && 'bg-emerald-50 font-semibold text-emerald-700',
|
||||
isWinnerColumn && 'bg-emerald-50 font-semibold text-emerald-700',
|
||||
)}
|
||||
>
|
||||
{q ? fmtMoney(q.thanhTien) : <span className="text-slate-300">—</span>}
|
||||
@ -1379,11 +1400,13 @@ function QuoteDialog({
|
||||
onClose: () => void
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
// User 2026-05-07: Bỏ `isSelected` checkbox per-quote (consolidate winner
|
||||
// selection ở Section 2.a NccSelectorRow). BE vẫn nhận isSelected nhưng FE
|
||||
// luôn gửi `false` (existing.isSelected nếu có để giữ nguyên trạng thái cũ).
|
||||
const [form, setForm] = useState({
|
||||
bgVat: existing?.bgVat ?? 0,
|
||||
chuaVat: existing?.chuaVat ?? 0,
|
||||
thanhTien: existing?.thanhTien ?? 0,
|
||||
isSelected: existing?.isSelected ?? false,
|
||||
note: existing?.note ?? '',
|
||||
})
|
||||
|
||||
@ -1399,6 +1422,7 @@ function QuoteDialog({
|
||||
purchaseEvaluationDetailId: detailId,
|
||||
purchaseEvaluationSupplierId: supplierRowId,
|
||||
...form,
|
||||
isSelected: existing?.isSelected ?? false, // giữ nguyên trạng thái cũ, không expose UI
|
||||
}),
|
||||
onSuccess: () => { toast.success('Đã lưu báo giá.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() },
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
@ -1410,28 +1434,37 @@ function QuoteDialog({
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const isSaving = mut.isPending || del.isPending
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
title={`Báo giá — ${supplierName}`}
|
||||
footer={<>
|
||||
{existing && <Button variant="danger" onClick={() => del.mutate()} disabled={del.isPending}>Xóa</Button>}
|
||||
<Button variant="ghost" onClick={onClose}>Hủy</Button>
|
||||
<Button onClick={() => mut.mutate()} disabled={mut.isPending}>Lưu</Button>
|
||||
{existing && <Button variant="danger" onClick={() => del.mutate()} disabled={isSaving}>{del.isPending ? 'Đang xóa…' : 'Xóa'}</Button>}
|
||||
<Button variant="ghost" onClick={onClose} disabled={isSaving}>Hủy</Button>
|
||||
<Button onClick={() => mut.mutate()} disabled={isSaving}>{mut.isPending ? 'Đang lưu…' : 'Lưu'}</Button>
|
||||
</>}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{/* Loading overlay khi save có delay (user 2026-05-07) */}
|
||||
<div className="relative space-y-3">
|
||||
{isSaving && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded bg-white/70 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2 shadow-md">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-brand-300 border-t-brand-600" />
|
||||
<span className="text-sm font-medium text-slate-700">
|
||||
{mut.isPending ? 'Đang lưu báo giá…' : 'Đang xóa…'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-slate-500">Hạng mục: <strong>{itemName}</strong> · KL {khoiLuong}</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div><Label>Đơn giá chưa VAT</Label><Input type="number" value={form.chuaVat} onChange={e => updateAndRecalc({ chuaVat: Number(e.target.value) })} /></div>
|
||||
<div><Label>Đơn giá có VAT</Label><Input type="number" value={form.bgVat} onChange={e => setForm({ ...form, bgVat: Number(e.target.value) })} /></div>
|
||||
<div><Label>Thành tiền (auto)</Label><Input type="number" value={form.thanhTien} onChange={e => setForm({ ...form, thanhTien: Number(e.target.value) })} /></div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={form.isSelected} onChange={e => setForm({ ...form, isSelected: e.target.checked })} />
|
||||
Chọn NCC này cho hạng mục
|
||||
</label>
|
||||
<div><Label>Ghi chú</Label><Input value={form.note} onChange={e => setForm({ ...form, note: e.target.value })} /></div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
Reference in New Issue
Block a user