Compare commits

..

3 Commits

Author SHA1 Message Date
cd79d1c45b [CLAUDE] Contract: KHKK go 2 nut phu, nut + tren dong, header chi ma, xoa duoc phieu Tra lai
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 7m0s
Anh gui 2 anh + 1 dong nhan, gom 6 diem:

(1) "bo nut sua voi ve lai form": go TRON dai 2 nut goc phai cua KhkkCreatePage
    (Sua + X Ve form tao). Ca hai deu la duong PHU:
      - vao che-do sua -> but chi o DANH SACH trai (onEditPlan) = duong DUY NHAT, GIU.
      - roi che-do xem -> bam PE bat ky o cay trai, pickPe() tu setViewPlanId(null).
    Dinh chinh chu-thich S175 tung dung do ("bo han thi nguoi dung ket, khong co
    duong lui"): SAI ngay luc viet — pickPe mang nhan [S174] tuc DA TON TAI truoc
    cau canh-bao do. Thu duy nhat mat di la duong quay lai man rong huong-dan.
    Go luon import Pencil/X (het cho dung).

(2) "To dam" (ma) + (3) "Bo di" (ten): header muc 2 gio CHI con MA, in dam.
    Fallback ve TEN khi thieu ma: peWorkItemCode null khi PE khong gan hang muc
    (WorkItemId loose-Guid Mig 49, duoc phep rong) — bo ten VO DIEU KIEN thi ca do
    header tro moi chu "Hang muc (du an)", trong nhu loi render.

(4) "Them dau + them o day" + (5) "bo di": thay khoi "Them hang muc cho: [NCC...]"
    bang nut + o COT HANH-DONG cua TUNG DONG. Nut o dong nao thi BIET SAN NCC cua
    dong do (l.supplierId) nen bo duoc buoc chon NCC. Khoi cu ton tai chi vi no dung
    NGOAI moi dong nen buoc phai hoi "cho NCC nao".
    Vong doi khoi nay tron 3 nac trong CUNG luot S177 (S175 duoi bang -> doi vao
    tbody -> go han) — giu 1 dong vet de khoi ai dung lai.

(6) "Trang thai tra lai cho xoa dc luon nhe": allow-list xoa phieu
    {DangSoanThao, TuChoi} -> +TraLai.
    🔴 Day la VA LECH 2 TANG chu khong phai noi quyen: FE DA hien nut Xoa cho phieu
    TraLai tu truoc (canEdit gom TraLai => readOnly=false => thanh nut day hien),
    BE thi nem 409 — bam duoc ma khong xoa duoc (gotcha #82).
    +1 test DeleteContractSigningPlan_Allows_TraLai, DA CHUNG CO RANG: stash ban va
    -> test DO; tra ban va -> XANH. Test (8) cu khong bat duoc vi ve nghich dung
    ChoDuyet (cam ca truoc lan sau) va ve thuan dung TuChoi (cho ca truoc lan sau)
    — TraLai la phase DUY NHAT doi verdict va no vang mat khoi moi test.

645/645 test pass (+1) · 2 app build sach · SHA IDENTICAL (da verify identical @HEAD
TRUOC khi copy de) · dist local doi chieu: "Ve form tao" = 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 16:00:06 +07:00
51175ae6bb wal: flush 20260806T1532 2026-08-06 15:32:22 +07:00
6d666eb365 wal: flush 20260806T1523 2026-08-06 15:23:10 +07:00
7 changed files with 166 additions and 121 deletions

View File

@ -21,6 +21,15 @@ chain:
· **Mig 72 CONFIRMED prod** = dòng đầu `__EFMigrationsHistory`. Backfill đo dữ-liệu THẬT: 7 dòng · 5 có hạng mục · **5/5 đã có mã** (2 dòng chưa gán ⇒ NULL, đúng bất-biến) · **Mig 72 CONFIRMED prod** = dòng đầu `__EFMigrationsHistory`. Backfill đo dữ-liệu THẬT: 7 dòng · 5 có hạng mục · **5/5 đã có mã** (2 dòng chưa gán ⇒ NULL, đúng bất-biến)
· sqlcmd qua **integrated auth `-E`**, KHÔNG đụng mật khẩu prod (`$env:PROD_DB_PASSWORD` trống trong phiên ssh — không đi tìm) · sqlcmd qua **integrated auth `-E`**, KHÔNG đụng mật khẩu prod (`$env:PROD_DB_PASSWORD` trống trong phiên ssh — không đi tìm)
· smoke: admin/eoffice **200** · api `/api/contract-signing-plans` + `/api/auth/me` **401** (pipeline auth sống, không phải IIS đứng suông) · smoke: admin/eoffice **200** · api `/api/contract-signing-plans` + `/api/auth/me` **401** (pipeline auth sống, không phải IIS đứng suông)
- [x] **đợt-2 S177 `b346b41` ship + deploy VERIFIED 2-vế ×2 app** (admin `DbWf4jNk` · eoffice `BqrZMtfo`, 380s) — ảnh anh gửi = màn NHÁP KHKK/2026/008, 3 điểm → làm 2 bỏ 1:
· ① "Làm header dưới đây" — ô **Hạng mục (dự án)** dời từ mục 1 xuống header mục 2; ĐẶT NGOÀI ternary `lines.length===0` (phiếu chưa có NCC vẫn phải biết gói việc — ca *vắng-mặt trông giống ổn*)
· ② "+ thêm hạng mục ở đây" — khối "Thêm hạng mục cho:" dời TỪ DƯỚI BẢNG vào dòng cuối `tbody`, `colSpan={4}`. Panel `addFor` GIỮ ngoài bảng (form nhiều ô, nhét vào `<table>` vỡ lưới `table-fixed`). Đã đối chiếu KHÔNG nhân đôi (3 hit = 1 nút + 1 chú thích + 1 header panel)
· ③ "nút edit" — 🔴 **anh chốt BỎ** (*"à vậy thôi khỏi"*). Màn nháp đã sửa TRỰC TIẾP trên dòng; ô trống cột 4 là do dòng đến từ PE (`peReferenceAmount != 0`) nên nút xoá bị chặn CÓ CHỦ ĐÍCH từ S175 — KHÔNG phải thiếu nút
· 🔴 **GIỮ mã dự án lặp trên từng dòng** — anh: *"để sau này người ta dễ hình dung hạng mục to nó chứa các hạng mục con nào"*. Câu hỏi này em nêu 2 lượt, anh chốt 2 lần cùng hướng ⇒ ĐỪNG hỏi lại
· diff **CHỈ 2 file FE, 0 file BE** ⇒ .NET test không thể ảnh hưởng, bỏ qua vòng test đó (khai rõ). 2 app build sạch · lint 0 kêu file này · SHA IDENTICAL
· **byte-level 3/3 KHỚP local↔prod ×2 app**: `colSpan:4` admin 2=2 user 1=1 · `items-baseline` 9=9 / 8=8 · `Hạng mục (dự án)` **1**=1 (=**dời**, KHÔNG nhân đôi). Smoke 200/200/401
· 🔴 **"trước" lấy từ GIT chứ không từ mạng** (bundle cũ lại bị deploy dọn): `530af22`**0** `colSpan={4}`, `b346b41` có 2 ⇒ discriminator thật. Nút thêm: `530af22` dòng 638 > `</table>` 628 = NGOÀI · `b346b41` dòng 631 < 666 = TRONG
· 🔴 **BẪY ĐÃ SẬP 1 LẦN rồi mới thấy:** `HEAD~1` KHÔNG phải commit trước của mình Stop-hook chèn `wal: flush` turn-boundary nên `HEAD`=`6d666eb` (wal), `HEAD~1`=chính commit mình phép so đầu tiên **so bản mới với chính nó**, ra "y hệt nhau" trông như KHÔNG đổi. So bằng **SHA đích danh**, đừng dùng `HEAD~n` trong repo hook auto-commit
- [!] 🔴 `/fable-real reviewer` `wf_264fb5c2-0db` = `SETUP-REVIEW: SỬA-6` (nền ĐỨNG VỮNG) **6 điểm CHƯA vá cái nào**. Spec: `runs/2026-08-06-S176-doi-style-3-vai/spec-va-doi-style-3-vai-06-08-2026.md` - [!] 🔴 `/fable-real reviewer` `wf_264fb5c2-0db` = `SETUP-REVIEW: SỬA-6` (nền ĐỨNG VỮNG) **6 điểm CHƯA vá cái nào**. Spec: `runs/2026-08-06-S176-doi-style-3-vai/spec-va-doi-style-3-vai-06-08-2026.md`
- [!] 🔴 **SỬA-1 SỐNG-CHẾT:** thước dispatch `git diff origin/main...HEAD` (**ba-chấm**) uncommitted+untracked đội gate PRE-commit **chết CÂM** (0 spawn, 0 SKIP để lộ). Sửa 4 site 2-chấm `porcelain` lọc `??` - [!] 🔴 **SỬA-1 SỐNG-CHẾT:** thước dispatch `git diff origin/main...HEAD` (**ba-chấm**) uncommitted+untracked đội gate PRE-commit **chết CÂM** (0 spawn, 0 SKIP để lộ). Sửa 4 site 2-chấm `porcelain` lọc `??`
- [!] 🔴 **SỬA-5a (rẻ nhất):** 3 vai style thiếu dir `agent-memory/<vai>/` hình trong đo/budget. Lead đo lại: **3/3 THIẾU CONFIRMED** - [!] 🔴 **SỬA-5a (rẻ nhất):** 3 vai style thiếu dir `agent-memory/<vai>/` hình trong đo/budget. Lead đo lại: **3/3 THIẾU CONFIRMED**

View File

@ -494,10 +494,14 @@ export function KhkkDetailContent({
đúng ca "vắng-mặt trông giống ổn" (màn rỗng nhìn vẫn gọn nên không ai kêu). */} đúng ca "vắng-mặt trông giống ổn" (màn rỗng nhìn vẫn gọn nên không ai kêu). */}
<div className="mb-3 flex flex-wrap items-baseline gap-x-2 gap-y-0.5 border-b border-slate-200 pb-2"> <div className="mb-3 flex flex-wrap items-baseline gap-x-2 gap-y-0.5 border-b border-slate-200 pb-2">
<span className="label-eyebrow text-slate-500">Hạng mục (dự án)</span> <span className="label-eyebrow text-slate-500">Hạng mục (dự án)</span>
{plan.peWorkItemCode && ( {/* [S177 — anh: "Tô đậm" (mã) + "Bỏ đi" (tên)] Header giờ CHỈ còn MÃ, in đậm.
<span className="font-mono text-[11px] font-medium text-slate-500">{plan.peWorkItemCode}</span> 🔴 Fallback về TÊN khi thiếu mã: `peWorkItemCode` null khi PE không gắn hạng mục
)} (`WorkItemId` là loose-Guid, Mig 49 — được phép rỗng). Bỏ tên VÔ ĐIỀU KIỆN thì
<span className="text-sm font-semibold text-slate-800">{plan.peWorkItemName ?? '—'}</span> đúng ca đó header còn trơ mỗi chữ "Hạng mục (dự án)" — một nhãn không có giá trị
đi kèm, trông như lỗi render chứ không như "phiếu này chưa gắn hạng mục". */}
<span className="font-mono text-sm font-bold text-slate-800">
{plan.peWorkItemCode ?? plan.peWorkItemName ?? '—'}
</span>
</div> </div>
{plan.lines.length === 0 ? ( {plan.lines.length === 0 ? (
@ -605,47 +609,49 @@ export function KhkkDetailContent({
) : moneyOrDash(l.proposedAmount)} ) : moneyOrDash(l.proposedAmount)}
</td> </td>
<td className="px-3 py-2 text-right align-top"> <td className="px-3 py-2 text-right align-top">
{isDraft && !l.contractId && l.peReferenceAmount === 0 && ( {/* [S177 — anh: "Thêm dấu + thêm ở đây"] Nút THÊM về đúng cột hành-động
<button của TỪNG DÒNG, thay khối "Thêm hạng mục cho: [NCC…]" (anh: "bỏ đi").
type="button" 🔴 Vì sao gọn hơn hẳn: nút ở dòng nào thì BIẾT SẴN NCC của dòng đó
onClick={() => { if (window.confirm(`Xoá hạng mục "${l.tenHangMuc ?? '(chưa gán)'}"?`)) deleteLine.mutate(l.id) }} (`l.supplierId`), nên không phải bày danh sách NCC cho người dùng chọn
disabled={deleteLine.isPending} lại thứ mà vị trí bấm đã nói rồi. Khối cũ tồn tại chỉ vì nó đứng NGOÀI
title="Xoá hạng mục này" mọi dòng nên buộc phải hỏi "cho NCC nào". */}
className="rounded px-1 py-1 text-red-500 hover:bg-red-50" <div className="flex items-center justify-end gap-0.5">
> {isDraft && (
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</td>
</tr>
))}
{/* [S177 — anh: "+ thêm hạng mục ở đây"] Nút thêm DỜI TỪ DƯỚI BẢNG vào
thành dòng cuối của `tbody` — ngay sau dòng dữ liệu cuối, TRÊN dòng TỔNG.
🔴 `colSpan={4}` (không phải 3): bảng đã lên 4 cột từ đầu lượt S177.
🔸 Panel nhập (`addFor`) VẪN Ở NGOÀI bảng — nó là form nhiều ô, nhét vào
trong `<table>` thì vỡ lưới `table-fixed` đang giữ 3 cột số thẳng hàng. */}
{isDraft && (
<tr>
<td colSpan={4} className="px-3 py-2">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-[11px] text-slate-500">Thêm hạng mục cho:</span>
{[...new Map(plan.lines.map(l => [l.supplierId, l])).values()].map((l) => (
<button <button
key={l.supplierId}
type="button" type="button"
onClick={() => { onClick={() => {
setAddFor({ supplierId: l.supplierId, supplierName: l.supplierName }) setAddFor({ supplierId: l.supplierId, supplierName: l.supplierName })
setAddCatalogId(''); setAddAmount(''); setAddNote('') setAddCatalogId(''); setAddAmount(''); setAddNote('')
}} }}
className="inline-flex items-center gap-1 rounded border border-brand-300 px-2 py-1 text-[11px] font-medium text-brand-700 transition hover:bg-brand-50" title={`Thêm hạng mục cho ${l.supplierName ?? 'NCC này'}`}
aria-label={`Thêm hạng mục cho ${l.supplierName ?? 'NCC này'}`}
className="rounded px-1 py-1 text-brand-600 hover:bg-brand-50"
> >
<Plus className="h-3 w-3" /> {l.supplierName ?? '(không rõ NCC)'} <Plus className="h-3.5 w-3.5" />
</button> </button>
))} )}
{isDraft && !l.contractId && l.peReferenceAmount === 0 && (
<button
type="button"
onClick={() => { if (window.confirm(`Xoá hạng mục "${l.tenHangMuc ?? '(chưa gán)'}"?`)) deleteLine.mutate(l.id) }}
disabled={deleteLine.isPending}
title="Xoá hạng mục này"
className="rounded px-1 py-1 text-red-500 hover:bg-red-50"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div> </div>
</td> </td>
</tr> </tr>
)} ))}
{/* 🧊 [S177 — anh: "bỏ đi"] Khối "Thêm hạng mục cho: [NCC…]" ĐÃ GỠ HẲN.
Vòng đời đủ 3 nấc trong CÙNG lượt S177, giữ vết để khỏi ai dựng lại:
(1) S175 đặt DƯỚI bảng → (2) anh: "+ thêm hạng mục ở đây" ⇒ dời vào
`tbody` → (3) anh nhìn thật rồi chốt "bỏ đi", thay bằng nút **+** ở cột
hành-động của TỪNG DÒNG (xem `<td>` hành-động phía trên).
🔴 Chức năng KHÔNG mất: nút + trên dòng gọi đúng `setAddFor` cũ, và còn
biết sẵn NCC nên bỏ được bước chọn NCC. Panel nhập `addFor` giữ nguyên. */}
</tbody> </tbody>
{/* [S175 — anh: "chọn hạng mục tiền → thành tổng"] TỔNG = Σ giá các dòng. {/* [S175 — anh: "chọn hạng mục tiền → thành tổng"] TỔNG = Σ giá các dòng.
🔴 KHÔNG có cột "giá tham chiếu" / "giá duyệt" nữa — anh chốt "ko cần so 🔴 KHÔNG có cột "giá tham chiếu" / "giá duyệt" nữa — anh chốt "ko cần so

View File

@ -29,7 +29,7 @@
import { useState, type FormEvent, type ReactNode } from 'react' import { useState, type FormEvent, type ReactNode } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query' import { useMutation, useQuery } from '@tanstack/react-query'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { FileCheck, Pencil, X } from 'lucide-react' import { FileCheck } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { EmptyState } from '@/components/EmptyState' import { EmptyState } from '@/components/EmptyState'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
@ -218,29 +218,17 @@ export function KhkkCreatePage() {
const readOnly = !editPlan || !canEdit const readOnly = !editPlan || !canEdit
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{/* 🧊 [S175 — anh: "bỏ mấy dòng này đi"] Dải banner "Đang xem kế hoạch…" ĐÃ GỠ. {/* 🧊 [S177 — anh: "bỏ nút sửa với về lại form"] Dải 2 nút góc phải ĐÃ GỠ TRỌN
Nút của nó đi đâu: **Sửa** → bút chì ở danh sách (đường duy nhất, đúng như (**Sửa** + **✕ Về form tạo**). Cả hai đều là đường PHỤ, không phải đường duy nhất:
banner cũ vẫn dặn) · **Xóa phiếu / Gửi duyệt** → THANH NÚT ĐÁY của · **vào chế-độ sửa** → bút chì ở DANH SÁCH trái (`onEditPlan` `:202`) — GIỮ.
`KhkkDetailContent`. Chỉ giữ đúng 1 dòng nhỏ để quay về form tạo — bỏ hẳn 🔴 Bút chì đó là đường DUY NHẤT vào chế-độ sửa; gỡ nó là mất hẳn khả năng
thì người dùng mở 1 phiếu xong là kẹt, không có đường lùi. */} sửa phiếu, nên nó KHÔNG nằm trong phạm vi yêu cầu này.
<div className="flex items-center justify-end gap-2"> · **rời chế-độ xem** → bấm phiếu Duyệt NCC bất kỳ ở cây trái: `pickPe` `:163-166`
{readOnly && canEdit && ( tự `setViewPlanId(null)`. Cây trái luôn hiện ⇒ không kẹt.
<button 🔸 Đính chính chú-thích S175 từng đứng đây (*"bỏ hẳn thì người dùng kẹt, không
type="button" có đường lùi"*): SAI ngay lúc viết — `pickPe` mang nhãn **[S174]**, tức đã tồn
onClick={() => setEditPlan(true)} tại TRƯỚC câu cảnh-báo đó. Thứ duy nhất mất đi là đường quay lại màn rỗng
className="inline-flex items-center gap-1 rounded px-2 py-1 text-[11px] font-medium text-brand-700 transition hover:bg-brand-50" "Chọn phiếu Duyệt NCC…" (`:256`), mà đó chỉ là chỗ hướng-dẫn, không phải đích. */}
>
<Pencil className="h-3 w-3" /> Sửa
</button>
)}
<button
type="button"
onClick={() => { setViewPlanId(null); setEditPlan(false) }}
className="inline-flex items-center gap-1 rounded px-2 py-1 text-[11px] text-slate-500 transition hover:bg-slate-100 hover:text-slate-700"
>
<X className="h-3 w-3" /> Về form tạo
</button>
</div>
{viewPlan.isLoading && <div className="text-sm text-slate-500">Đang tải</div>} {viewPlan.isLoading && <div className="text-sm text-slate-500">Đang tải</div>}
{!viewPlan.isLoading && !viewPlan.data && ( {!viewPlan.isLoading && !viewPlan.data && (
<div className="text-sm text-red-600">Không tìm thấy phiếu.</div> <div className="text-sm text-red-600">Không tìm thấy phiếu.</div>

View File

@ -494,10 +494,14 @@ export function KhkkDetailContent({
đúng ca "vắng-mặt trông giống ổn" (màn rỗng nhìn vẫn gọn nên không ai kêu). */} đúng ca "vắng-mặt trông giống ổn" (màn rỗng nhìn vẫn gọn nên không ai kêu). */}
<div className="mb-3 flex flex-wrap items-baseline gap-x-2 gap-y-0.5 border-b border-slate-200 pb-2"> <div className="mb-3 flex flex-wrap items-baseline gap-x-2 gap-y-0.5 border-b border-slate-200 pb-2">
<span className="label-eyebrow text-slate-500">Hạng mục (dự án)</span> <span className="label-eyebrow text-slate-500">Hạng mục (dự án)</span>
{plan.peWorkItemCode && ( {/* [S177 — anh: "Tô đậm" (mã) + "Bỏ đi" (tên)] Header giờ CHỈ còn MÃ, in đậm.
<span className="font-mono text-[11px] font-medium text-slate-500">{plan.peWorkItemCode}</span> 🔴 Fallback về TÊN khi thiếu mã: `peWorkItemCode` null khi PE không gắn hạng mục
)} (`WorkItemId` là loose-Guid, Mig 49 — được phép rỗng). Bỏ tên VÔ ĐIỀU KIỆN thì
<span className="text-sm font-semibold text-slate-800">{plan.peWorkItemName ?? '—'}</span> đúng ca đó header còn trơ mỗi chữ "Hạng mục (dự án)" — một nhãn không có giá trị
đi kèm, trông như lỗi render chứ không như "phiếu này chưa gắn hạng mục". */}
<span className="font-mono text-sm font-bold text-slate-800">
{plan.peWorkItemCode ?? plan.peWorkItemName ?? '—'}
</span>
</div> </div>
{plan.lines.length === 0 ? ( {plan.lines.length === 0 ? (
@ -605,47 +609,49 @@ export function KhkkDetailContent({
) : moneyOrDash(l.proposedAmount)} ) : moneyOrDash(l.proposedAmount)}
</td> </td>
<td className="px-3 py-2 text-right align-top"> <td className="px-3 py-2 text-right align-top">
{isDraft && !l.contractId && l.peReferenceAmount === 0 && ( {/* [S177 — anh: "Thêm dấu + thêm ở đây"] Nút THÊM về đúng cột hành-động
<button của TỪNG DÒNG, thay khối "Thêm hạng mục cho: [NCC…]" (anh: "bỏ đi").
type="button" 🔴 Vì sao gọn hơn hẳn: nút ở dòng nào thì BIẾT SẴN NCC của dòng đó
onClick={() => { if (window.confirm(`Xoá hạng mục "${l.tenHangMuc ?? '(chưa gán)'}"?`)) deleteLine.mutate(l.id) }} (`l.supplierId`), nên không phải bày danh sách NCC cho người dùng chọn
disabled={deleteLine.isPending} lại thứ mà vị trí bấm đã nói rồi. Khối cũ tồn tại chỉ vì nó đứng NGOÀI
title="Xoá hạng mục này" mọi dòng nên buộc phải hỏi "cho NCC nào". */}
className="rounded px-1 py-1 text-red-500 hover:bg-red-50" <div className="flex items-center justify-end gap-0.5">
> {isDraft && (
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</td>
</tr>
))}
{/* [S177 — anh: "+ thêm hạng mục ở đây"] Nút thêm DỜI TỪ DƯỚI BẢNG vào
thành dòng cuối của `tbody` — ngay sau dòng dữ liệu cuối, TRÊN dòng TỔNG.
🔴 `colSpan={4}` (không phải 3): bảng đã lên 4 cột từ đầu lượt S177.
🔸 Panel nhập (`addFor`) VẪN Ở NGOÀI bảng — nó là form nhiều ô, nhét vào
trong `<table>` thì vỡ lưới `table-fixed` đang giữ 3 cột số thẳng hàng. */}
{isDraft && (
<tr>
<td colSpan={4} className="px-3 py-2">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-[11px] text-slate-500">Thêm hạng mục cho:</span>
{[...new Map(plan.lines.map(l => [l.supplierId, l])).values()].map((l) => (
<button <button
key={l.supplierId}
type="button" type="button"
onClick={() => { onClick={() => {
setAddFor({ supplierId: l.supplierId, supplierName: l.supplierName }) setAddFor({ supplierId: l.supplierId, supplierName: l.supplierName })
setAddCatalogId(''); setAddAmount(''); setAddNote('') setAddCatalogId(''); setAddAmount(''); setAddNote('')
}} }}
className="inline-flex items-center gap-1 rounded border border-brand-300 px-2 py-1 text-[11px] font-medium text-brand-700 transition hover:bg-brand-50" title={`Thêm hạng mục cho ${l.supplierName ?? 'NCC này'}`}
aria-label={`Thêm hạng mục cho ${l.supplierName ?? 'NCC này'}`}
className="rounded px-1 py-1 text-brand-600 hover:bg-brand-50"
> >
<Plus className="h-3 w-3" /> {l.supplierName ?? '(không rõ NCC)'} <Plus className="h-3.5 w-3.5" />
</button> </button>
))} )}
{isDraft && !l.contractId && l.peReferenceAmount === 0 && (
<button
type="button"
onClick={() => { if (window.confirm(`Xoá hạng mục "${l.tenHangMuc ?? '(chưa gán)'}"?`)) deleteLine.mutate(l.id) }}
disabled={deleteLine.isPending}
title="Xoá hạng mục này"
className="rounded px-1 py-1 text-red-500 hover:bg-red-50"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div> </div>
</td> </td>
</tr> </tr>
)} ))}
{/* 🧊 [S177 — anh: "bỏ đi"] Khối "Thêm hạng mục cho: [NCC…]" ĐÃ GỠ HẲN.
Vòng đời đủ 3 nấc trong CÙNG lượt S177, giữ vết để khỏi ai dựng lại:
(1) S175 đặt DƯỚI bảng → (2) anh: "+ thêm hạng mục ở đây" ⇒ dời vào
`tbody` → (3) anh nhìn thật rồi chốt "bỏ đi", thay bằng nút **+** ở cột
hành-động của TỪNG DÒNG (xem `<td>` hành-động phía trên).
🔴 Chức năng KHÔNG mất: nút + trên dòng gọi đúng `setAddFor` cũ, và còn
biết sẵn NCC nên bỏ được bước chọn NCC. Panel nhập `addFor` giữ nguyên. */}
</tbody> </tbody>
{/* [S175 — anh: "chọn hạng mục tiền → thành tổng"] TỔNG = Σ giá các dòng. {/* [S175 — anh: "chọn hạng mục tiền → thành tổng"] TỔNG = Σ giá các dòng.
🔴 KHÔNG có cột "giá tham chiếu" / "giá duyệt" nữa — anh chốt "ko cần so 🔴 KHÔNG có cột "giá tham chiếu" / "giá duyệt" nữa — anh chốt "ko cần so

View File

@ -29,7 +29,7 @@
import { useState, type FormEvent, type ReactNode } from 'react' import { useState, type FormEvent, type ReactNode } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query' import { useMutation, useQuery } from '@tanstack/react-query'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { FileCheck, Pencil, X } from 'lucide-react' import { FileCheck } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { EmptyState } from '@/components/EmptyState' import { EmptyState } from '@/components/EmptyState'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
@ -218,29 +218,17 @@ export function KhkkCreatePage() {
const readOnly = !editPlan || !canEdit const readOnly = !editPlan || !canEdit
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{/* 🧊 [S175 — anh: "bỏ mấy dòng này đi"] Dải banner "Đang xem kế hoạch…" ĐÃ GỠ. {/* 🧊 [S177 — anh: "bỏ nút sửa với về lại form"] Dải 2 nút góc phải ĐÃ GỠ TRỌN
Nút của nó đi đâu: **Sửa** → bút chì ở danh sách (đường duy nhất, đúng như (**Sửa** + **✕ Về form tạo**). Cả hai đều là đường PHỤ, không phải đường duy nhất:
banner cũ vẫn dặn) · **Xóa phiếu / Gửi duyệt** → THANH NÚT ĐÁY của · **vào chế-độ sửa** → bút chì ở DANH SÁCH trái (`onEditPlan` `:202`) — GIỮ.
`KhkkDetailContent`. Chỉ giữ đúng 1 dòng nhỏ để quay về form tạo — bỏ hẳn 🔴 Bút chì đó là đường DUY NHẤT vào chế-độ sửa; gỡ nó là mất hẳn khả năng
thì người dùng mở 1 phiếu xong là kẹt, không có đường lùi. */} sửa phiếu, nên nó KHÔNG nằm trong phạm vi yêu cầu này.
<div className="flex items-center justify-end gap-2"> · **rời chế-độ xem** → bấm phiếu Duyệt NCC bất kỳ ở cây trái: `pickPe` `:163-166`
{readOnly && canEdit && ( tự `setViewPlanId(null)`. Cây trái luôn hiện ⇒ không kẹt.
<button 🔸 Đính chính chú-thích S175 từng đứng đây (*"bỏ hẳn thì người dùng kẹt, không
type="button" có đường lùi"*): SAI ngay lúc viết — `pickPe` mang nhãn **[S174]**, tức đã tồn
onClick={() => setEditPlan(true)} tại TRƯỚC câu cảnh-báo đó. Thứ duy nhất mất đi là đường quay lại màn rỗng
className="inline-flex items-center gap-1 rounded px-2 py-1 text-[11px] font-medium text-brand-700 transition hover:bg-brand-50" "Chọn phiếu Duyệt NCC…" (`:256`), mà đó chỉ là chỗ hướng-dẫn, không phải đích. */}
>
<Pencil className="h-3 w-3" /> Sửa
</button>
)}
<button
type="button"
onClick={() => { setViewPlanId(null); setEditPlan(false) }}
className="inline-flex items-center gap-1 rounded px-2 py-1 text-[11px] text-slate-500 transition hover:bg-slate-100 hover:text-slate-700"
>
<X className="h-3 w-3" /> Về form tạo
</button>
</div>
{viewPlan.isLoading && <div className="text-sm text-slate-500">Đang tải</div>} {viewPlan.isLoading && <div className="text-sm text-slate-500">Đang tải</div>}
{!viewPlan.isLoading && !viewPlan.data && ( {!viewPlan.isLoading && !viewPlan.data && (
<div className="text-sm text-red-600">Không tìm thấy phiếu.</div> <div className="text-sm text-red-600">Không tìm thấy phiếu.</div>

View File

@ -1720,12 +1720,21 @@ public class DeleteContractSigningPlanCommandHandler(
if (!isOwner && !isAdmin) if (!isOwner && !isAdmin)
throw new ForbiddenException("Chỉ người soạn kế hoạch hoặc Admin được xoá phiếu này."); throw new ForbiddenException("Chỉ người soạn kế hoạch hoặc Admin được xoá phiếu này.");
// Allow-list {DangSoanThao, TuChoi} — M6 spec cũ: THIẾU đường này thì lỡ tạo nhầm 1 // Allow-list {DangSoanThao, TuChoi, TraLai} — M6 spec cũ: THIẾU đường này thì lỡ tạo
// kế hoạch là phiếu PE bị khoá 409 vĩnh viễn (rào "1 PE = 1 kế hoạch sống" ở Create // nhầm 1 kế hoạch là phiếu PE bị khoá 409 vĩnh viễn (rào "1 PE = 1 kế hoạch sống" ở
// không có cửa thoát). Xoá phiếu đang duyệt = W3 (cần rào đúng-lượt như PE). // Create không có cửa thoát). Xoá phiếu ĐANG duyệt vẫn cấm (cần rào đúng-lượt như PE).
if (plan.Phase is not (ContractSigningPlanPhase.DangSoanThao or ContractSigningPlanPhase.TuChoi)) // [S177 — anh: "Trạng thái trả lại cho xóa đc luôn nhé"] +`TraLai`.
// 🔴 ĐÂY LÀ VÁ LỆCH 2 TẦNG, không phải nới quyền mới: FE ĐÃ hiện nút Xoá cho phiếu
// TraLai từ trước (`KhkkCreatePage` tính `canEdit` gồm cả `TraLai` ⇒ `readOnly=false`
// ⇒ thanh nút đáy hiện) trong khi BE trả 409 — bấm được mà không xoá được. Rào hiển-thị
// và rào API là HAI tầng độc lập; sửa một tầng là để lại đúng cái bẫy này (gotcha #82).
// 🔸 TraLai an toàn về nghiệp-vụ: phiếu đã bị trả về tay người soạn, KHÔNG đang chiếm
// lượt của ai — cùng vị thế với DangSoanThao, khác hẳn phiếu đang chờ duyệt.
if (plan.Phase is not (ContractSigningPlanPhase.DangSoanThao
or ContractSigningPlanPhase.TuChoi
or ContractSigningPlanPhase.TraLai))
throw new ConflictException( throw new ConflictException(
"Chỉ xoá được kế hoạch ở trạng thái Nháp hoặc Từ chối. " + "Chỉ xoá được kế hoạch ở trạng thái Nháp, Trả lại hoặc Từ chối. " +
"Kế hoạch đang duyệt phải Trả lại/Từ chối trước."); "Kế hoạch đang duyệt phải Trả lại/Từ chối trước.");
var reason = string.IsNullOrWhiteSpace(request.Reason) ? null : request.Reason.Trim(); var reason = string.IsNullOrWhiteSpace(request.Reason) ? null : request.Reason.Trim();

View File

@ -576,6 +576,45 @@ public class ContractSigningPlanCrudTests
.Should().Be(admin.UserId, "người xoá thật được ghi lại là Admin, không phải chủ phiếu"); .Should().Be(admin.UserId, "người xoá thật được ghi lại là Admin, không phải chủ phiếu");
} }
// ===================================================================
// (8-bis) [S177 — anh: "Trạng thái trả lại cho xóa đc luôn nhé"] `TraLai` vào allow-list xoá.
//
// Đây là ĐỔI SPEC (allow-list {Nháp, Từ chối} → {Nháp, Từ chối, Trả lại}) nên test đi
// CÙNG commit, không để test-after.
//
// 🔴 Vì sao test cũ (8) KHÔNG bắt được thiếu sót này, dù nó có cả vế thuận lẫn vế
// nghịch: vế nghịch của nó dùng `ChoDuyet` — vẫn bị cấm ĐÚNG cả trước lẫn sau bản
// vá; vế thuận dùng `TuChoi` — được phép ĐÚNG cả trước lẫn sau. `TraLai` là phase
// DUY NHẤT đổi verdict, và nó không có mặt trong test nào ⇒ allow-list thiếu một
// phase mà toàn bộ hàng test vẫn xanh. Trục nào KHÔNG có ca thì trục đó không được đo.
//
// 🔸 Nền của bản vá là LỆCH 2 TẦNG chứ không phải nới quyền: FE đã hiện nút Xoá cho
// phiếu `TraLai` từ trước (`canEdit` gồm `TraLai`), BE thì 409 ⇒ bấm được mà không
// xoá được. Test này neo tầng BE — tầng có răng thật.
[Fact]
public async Task DeleteContractSigningPlan_Allows_TraLai()
{
using var f = new KhkkFixture();
var seeded = await SeedPeAsync(f, supplierCount: 2, winnerIndexes: [0], quotes: [[100m, 7m]]);
var created = await CreateAsync(f, seeded.PeId, seeded.WorkflowId);
await using (var db = f.NewDb())
{
var plan = db.ContractSigningPlans.Single(p => p.Id == created.Id);
plan.Phase = ContractSigningPlanPhase.TraLai;
db.SaveChanges();
}
await DeleteAsync(f, created.Id, "Phiếu bị trả lại, soạn lại từ đầu", f.Actor);
await using var final = f.NewDb();
final.ContractSigningPlans.Count(p => p.Id == created.Id)
.Should().Be(0, "phiếu Trả lại đã nằm trong tay người soạn, không chiếm lượt duyệt của ai");
final.ContractSigningPlans.IgnoreQueryFilters().AsNoTracking()
.Single(p => p.Id == created.Id).IsDeleted
.Should().BeTrue("xoá MỀM — vết phải còn để truy, không phải bốc hơi khỏi DB");
}
// =================================================================== // ===================================================================
// (9) [gate-K4b MAJOR-A @S166] Màn "Đã xoá" phải LỌC NHÓM ở SERVER // (9) [gate-K4b MAJOR-A @S166] Màn "Đã xoá" phải LỌC NHÓM ở SERVER
// (`ListDeletedContractSigningPlansQueryHandler` :896-897, vá @K4b). // (`ListDeletedContractSigningPlansQueryHandler` :896-897, vá @K4b).