[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

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:
pqhuy1987
2026-05-07 16:49:48 +07:00
parent e320027074
commit d2306b88d1
2 changed files with 104 additions and 38 deletions

View File

@ -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á 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>

View File

@ -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á 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>