[CLAUDE] FE-Admin+FE-User: PE detail polish B12 — Lưu (no close), Xóa phiếu, header bar simplify, NCC name col, no-delete có quotes
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m12s

User feedback 2026-05-07 (annotation screenshot):
1. "Lưu" thay "Lưu (đóng)" — KHÔNG đóng workspace, chỉ toast + invalidate sync
2. Thêm nút "Xóa phiếu" bottom — CHỈ Bản nháp (DangSoanThao), KHÔNG xóa Trả lại
   (đã có lịch sử workflow). Soft-delete (AuditableEntity IsDeleted=true,
   không xóa hoàn toàn DB).
3. Bỏ nút "Sửa header" + "Đóng" + "Xóa" header bar workspace mode (chuyển
   xuống bottom action bar). Header bar chỉ còn nhóm display info + nút "Đóng"
   cho non-workspace view (Danh sách / Duyệt readOnly).
4. Section 4 column header NCC: dùng s.supplierName (master) thay vì
   displayName ?? supplierName (custom). displayName fallback sang title tooltip.
5. Section 3 row Xóa: nếu NCC đã có quotes (báo giá ở Section 4) → KHÔNG cho
   xóa (tránh mất báo giá). Hiển thị icon disabled + tooltip "xóa báo giá
   trước rồi mới xóa NCC".

Implementation:
  ~ PeDetailTabs.tsx (× 2 app)
    - Header bar workspace mode actions: bỏ "Sửa header" Pencil button (có
      inline edit Section 1 + pencil hover Panel 1 thay thế), bỏ "Xóa" (chuyển
      xuống bottom). "Đóng" giữ chỉ cho readOnly + non-workspace view.
    - useNavigate import bỏ (chỉ dùng còn ở CreateContractDialog scope local).
    - Bottom action bar workspace + canEdit + !readOnly:
      * LEFT: "Xóa phiếu" red button (chỉ phase === DangSoanThao) + confirm
        dialog "soft-delete, không xóa hoàn toàn DB" + onDelete callback (existing
        DELETE /pe/:id endpoint, AuditableEntity IsDeleted=true).
      * CENTER: status text "✓ Các thay đổi đã tự động lưu khi chỉnh sửa..."
      * RIGHT: "Lưu" ghost button → invalidate ['pe-detail', id] + ['pe-list']
        + toast "Đã lưu — sync server" (KHÔNG onBack — workspace stay open).
      * RIGHT: "Lưu & Gửi Duyệt →" giữ nguyên (POST transitions).
    - SuppliersTab row actions: hasQuotes computed (= ev.details.some(d =>
      d.quotes.some(q => q.purchaseEvaluationSupplierId === s.id))). canDelete
      = !isWinner && !hasQuotes. Render Trash button enabled vs disabled span
      với tooltip "xóa báo giá trước".
    - ItemsTab matrix column header: {s.supplierName} (was {s.displayName ??
      s.supplierName}). title attr giữ displayName tooltip.

Verify: npm run build fe-admin + fe-user pass · 0 TS error · áp rule strict
verify khi remove import + button/condition logic changes.

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:37:04 +07:00
parent 6e7a6db4e8
commit 378c9939e6
2 changed files with 156 additions and 82 deletions

View File

@ -69,10 +69,10 @@ export function PeDetailTabs({
/** Auto open Section 1 InfoTab in edit mode khi mount — triggered từ pencil icon Panel 1 */
autoEditHeader?: boolean
}) {
const navigate = useNavigate()
const qc = useQueryClient()
// isDraft renamed → canEditPhase: bao gồm cả TraLai (per user 2026-05-07).
// Header bar action buttons (Sửa header / Xóa) hiện khi phase editable + !readOnly.
// canEditPhase: bao gồm cả TraLai (user 2026-05-07). Header bar action
// buttons "Sửa header" + "Xóa" + "Đóng" workspace mode đã chuyển xuống bottom
// action bar (B11+ user 2026-05-07).
const canEditPhase = isEditablePhase(evaluation.phase)
const opinionsReadOnly = readOnly || mode === 'workspace'
@ -137,19 +137,14 @@ export function PeDetailTabs({
{evaluation.drafterName && <><span>·</span><span>Soạn: {evaluation.drafterName}</span></>}
</div>
</div>
<div className="flex gap-2">
{canEditPhase && !readOnly && (
<>
<Button variant="ghost" onClick={() => navigate(`/purchase-evaluations/new?id=${evaluation.id}`)} className="gap-1.5 text-xs">
<Pencil className="h-3.5 w-3.5" /> Sửa header
</Button>
<Button variant="danger" onClick={onDelete} className="gap-1.5 text-xs">
<Trash2 className="h-3.5 w-3.5" /> Xóa
</Button>
</>
)}
<Button variant="ghost" onClick={onBack} className="text-xs"> Đóng</Button>
</div>
{/* Header bar actions: User 2026-05-07 chốt bỏ "Sửa header" + "Xóa" +
"Đóng" (workspace mode actions chuyển xuống bottom action bar). Vẫn
giữ Đóng cho non-workspace view (Danh sách + Duyệt — readOnly). */}
{(readOnly || mode !== 'workspace') && (
<div className="flex gap-2">
<Button variant="ghost" onClick={onBack} className="text-xs"> Đóng</Button>
</div>
)}
</div>
<div className="divide-y divide-slate-200">
@ -176,17 +171,45 @@ export function PeDetailTabs({
</Section>
</div>
{/* Action bar bottom — workspace mode + canEdit + !readOnly. 2 nút Lưu
(đóng workspace, các thay đổi đã auto-save inline) + Lưu & Gửi Duyệt
(POST /transitions → next phase, vào quy trình duyệt). User 2026-05-07. */}
{/* Action bar bottom — workspace mode + canEdit + !readOnly. 3 nút:
- Xóa phiếu (CHỈ Bản nháp, soft-delete BE) — bên trái red
- Lưu (toast confirm, KHÔNG đóng workspace) — chính giữa ghost
- Lưu & Gửi Duyệt → (POST /transitions → next phase) — bên phải brand
User 2026-05-07. */}
{mode === 'workspace' && canEditPhase && !readOnly && (
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-slate-200 bg-slate-50 px-5 py-3">
<div className="text-[11px] text-slate-500">
Các thay đi đã tự đng lưu khi chỉnh sửa từng phần.
<div className="flex items-center gap-3">
{/* Xóa phiếu — CHỈ DangSoanThao (bản nháp). TraLai không cho xóa
(đã có lịch sử workflow). Soft-delete qua DELETE /pe/:id endpoint
(AuditableEntity IsDeleted=true, không xóa hoàn toàn DB). */}
{evaluation.phase === PurchaseEvaluationPhase.DangSoanThao && (
<Button
variant="danger"
onClick={() => {
if (confirm(`Xóa phiếu "${evaluation.tenGoiThau}"? Phiếu sẽ ẩn khỏi danh sách (soft-delete, không xóa hoàn toàn trong DB).`)) {
onDelete()
}
}}
className="gap-1.5 text-xs"
>
<Trash2 className="h-3.5 w-3.5" /> Xóa phiếu
</Button>
)}
<span className="text-[11px] text-slate-500">
Các thay đi đã tự đng lưu khi chỉnh sửa từng phần.
</span>
</div>
<div className="flex gap-2">
<Button variant="ghost" onClick={onBack} className="text-xs">
Lưu (đóng)
<Button
variant="ghost"
onClick={() => {
qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
toast.success('Đã lưu — sync server.')
}}
className="text-xs"
>
Lưu
</Button>
<Button
onClick={() => {
@ -926,7 +949,11 @@ function SuppliersTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?:
// User 2026-05-07: NCC đã được chọn (winner) → KHÔNG cho
// sửa/xóa (tránh thay đổi NCC đã chốt). Chỉ hiển thị
// checkmark active state.
// User 2026-05-07 (B11+): NCC đã có hạng mục báo giá (quotes
// entered in Section 4) → KHÔNG cho xóa (tránh mất báo giá đã nhập).
const isWinner = ev.selectedSupplierId === s.supplierId
const hasQuotes = ev.details.some(d => d.quotes.some(q => q.purchaseEvaluationSupplierId === s.id))
const canDelete = !isWinner && !hasQuotes
return (
<td className="px-3 py-2">
<div className="flex justify-end gap-1">
@ -943,22 +970,29 @@ function SuppliersTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?:
<Check className="h-3.5 w-3.5" />
</button>
{!isWinner && (
<>
<button
onClick={() => setEditRow(s)}
className="rounded px-1.5 py-0.5 text-slate-500 hover:bg-slate-100"
title="Sửa"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => { if (confirm('Xóa NCC này khỏi phiếu?')) remove.mutate(s.id) }}
className="rounded px-1.5 py-0.5 text-red-500 hover:bg-red-50"
title="Xóa"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
<button
onClick={() => setEditRow(s)}
className="rounded px-1.5 py-0.5 text-slate-500 hover:bg-slate-100"
title="Sửa"
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
{canDelete ? (
<button
onClick={() => { if (confirm('Xóa NCC này khỏi phiếu?')) remove.mutate(s.id) }}
className="rounded px-1.5 py-0.5 text-red-500 hover:bg-red-50"
title="Xóa"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
) : !isWinner && hasQuotes && (
<span
className="rounded px-1.5 py-0.5 text-slate-300 cursor-not-allowed"
title="NCC đã có báo giá ở Section 4 — xóa báo giá trước rồi mới xóa NCC"
>
<Trash2 className="h-3.5 w-3.5" />
</span>
)}
</div>
</td>
@ -1138,8 +1172,11 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
</th>
)}
{ev.suppliers.map(s => (
<th key={s.id} className="border-r border-slate-200 px-2 py-2 text-right">
{s.displayName ?? s.supplierName}
<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>
))}
{!readOnly && <th className="px-2 py-2"></th>}

View File

@ -69,10 +69,10 @@ export function PeDetailTabs({
/** Auto open Section 1 InfoTab in edit mode khi mount — triggered từ pencil icon Panel 1 */
autoEditHeader?: boolean
}) {
const navigate = useNavigate()
const qc = useQueryClient()
// isDraft renamed → canEditPhase: bao gồm cả TraLai (per user 2026-05-07).
// Header bar action buttons (Sửa header / Xóa) hiện khi phase editable + !readOnly.
// canEditPhase: bao gồm cả TraLai (user 2026-05-07). Header bar action
// buttons "Sửa header" + "Xóa" + "Đóng" workspace mode đã chuyển xuống bottom
// action bar (B11+ user 2026-05-07).
const canEditPhase = isEditablePhase(evaluation.phase)
const opinionsReadOnly = readOnly || mode === 'workspace'
@ -137,19 +137,14 @@ export function PeDetailTabs({
{evaluation.drafterName && <><span>·</span><span>Soạn: {evaluation.drafterName}</span></>}
</div>
</div>
<div className="flex gap-2">
{canEditPhase && !readOnly && (
<>
<Button variant="ghost" onClick={() => navigate(`/purchase-evaluations/new?id=${evaluation.id}`)} className="gap-1.5 text-xs">
<Pencil className="h-3.5 w-3.5" /> Sửa header
</Button>
<Button variant="danger" onClick={onDelete} className="gap-1.5 text-xs">
<Trash2 className="h-3.5 w-3.5" /> Xóa
</Button>
</>
)}
<Button variant="ghost" onClick={onBack} className="text-xs"> Đóng</Button>
</div>
{/* Header bar actions: User 2026-05-07 chốt bỏ "Sửa header" + "Xóa" +
"Đóng" (workspace mode actions chuyển xuống bottom action bar). Vẫn
giữ Đóng cho non-workspace view (Danh sách + Duyệt — readOnly). */}
{(readOnly || mode !== 'workspace') && (
<div className="flex gap-2">
<Button variant="ghost" onClick={onBack} className="text-xs"> Đóng</Button>
</div>
)}
</div>
<div className="divide-y divide-slate-200">
@ -176,17 +171,45 @@ export function PeDetailTabs({
</Section>
</div>
{/* Action bar bottom — workspace mode + canEdit + !readOnly. 2 nút Lưu
(đóng workspace, các thay đổi đã auto-save inline) + Lưu & Gửi Duyệt
(POST /transitions → next phase, vào quy trình duyệt). User 2026-05-07. */}
{/* Action bar bottom — workspace mode + canEdit + !readOnly. 3 nút:
- Xóa phiếu (CHỈ Bản nháp, soft-delete BE) — bên trái red
- Lưu (toast confirm, KHÔNG đóng workspace) — chính giữa ghost
- Lưu & Gửi Duyệt → (POST /transitions → next phase) — bên phải brand
User 2026-05-07. */}
{mode === 'workspace' && canEditPhase && !readOnly && (
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-slate-200 bg-slate-50 px-5 py-3">
<div className="text-[11px] text-slate-500">
Các thay đi đã tự đng lưu khi chỉnh sửa từng phần.
<div className="flex items-center gap-3">
{/* Xóa phiếu — CHỈ DangSoanThao (bản nháp). TraLai không cho xóa
(đã có lịch sử workflow). Soft-delete qua DELETE /pe/:id endpoint
(AuditableEntity IsDeleted=true, không xóa hoàn toàn DB). */}
{evaluation.phase === PurchaseEvaluationPhase.DangSoanThao && (
<Button
variant="danger"
onClick={() => {
if (confirm(`Xóa phiếu "${evaluation.tenGoiThau}"? Phiếu sẽ ẩn khỏi danh sách (soft-delete, không xóa hoàn toàn trong DB).`)) {
onDelete()
}
}}
className="gap-1.5 text-xs"
>
<Trash2 className="h-3.5 w-3.5" /> Xóa phiếu
</Button>
)}
<span className="text-[11px] text-slate-500">
Các thay đi đã tự đng lưu khi chỉnh sửa từng phần.
</span>
</div>
<div className="flex gap-2">
<Button variant="ghost" onClick={onBack} className="text-xs">
Lưu (đóng)
<Button
variant="ghost"
onClick={() => {
qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
toast.success('Đã lưu — sync server.')
}}
className="text-xs"
>
Lưu
</Button>
<Button
onClick={() => {
@ -926,7 +949,11 @@ function SuppliersTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?:
// User 2026-05-07: NCC đã được chọn (winner) → KHÔNG cho
// sửa/xóa (tránh thay đổi NCC đã chốt). Chỉ hiển thị
// checkmark active state.
// User 2026-05-07 (B11+): NCC đã có hạng mục báo giá (quotes
// entered in Section 4) → KHÔNG cho xóa (tránh mất báo giá đã nhập).
const isWinner = ev.selectedSupplierId === s.supplierId
const hasQuotes = ev.details.some(d => d.quotes.some(q => q.purchaseEvaluationSupplierId === s.id))
const canDelete = !isWinner && !hasQuotes
return (
<td className="px-3 py-2">
<div className="flex justify-end gap-1">
@ -943,22 +970,29 @@ function SuppliersTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?:
<Check className="h-3.5 w-3.5" />
</button>
{!isWinner && (
<>
<button
onClick={() => setEditRow(s)}
className="rounded px-1.5 py-0.5 text-slate-500 hover:bg-slate-100"
title="Sửa"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => { if (confirm('Xóa NCC này khỏi phiếu?')) remove.mutate(s.id) }}
className="rounded px-1.5 py-0.5 text-red-500 hover:bg-red-50"
title="Xóa"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
<button
onClick={() => setEditRow(s)}
className="rounded px-1.5 py-0.5 text-slate-500 hover:bg-slate-100"
title="Sửa"
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
{canDelete ? (
<button
onClick={() => { if (confirm('Xóa NCC này khỏi phiếu?')) remove.mutate(s.id) }}
className="rounded px-1.5 py-0.5 text-red-500 hover:bg-red-50"
title="Xóa"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
) : !isWinner && hasQuotes && (
<span
className="rounded px-1.5 py-0.5 text-slate-300 cursor-not-allowed"
title="NCC đã có báo giá ở Section 4 — xóa báo giá trước rồi mới xóa NCC"
>
<Trash2 className="h-3.5 w-3.5" />
</span>
)}
</div>
</td>
@ -1138,8 +1172,11 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
</th>
)}
{ev.suppliers.map(s => (
<th key={s.id} className="border-r border-slate-200 px-2 py-2 text-right">
{s.displayName ?? s.supplierName}
<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>
))}
{!readOnly && <th className="px-2 py-2"></th>}