Compare commits
3 Commits
b346b41561
...
cd79d1c45b
| Author | SHA1 | Date | |
|---|---|---|---|
| cd79d1c45b | |||
| 51175ae6bb | |||
| 6d666eb365 |
@ -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` có **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 có gì đổi. So bằng **SHA đích danh**, đừng dùng `HEAD~n` trong repo có 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**) ⇒ 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**) ⇒ 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>/` ⇒ vô 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>/` ⇒ vô hình trong đo/budget. Lead đo lại: **3/3 THIẾU CONFIRMED**
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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).
|
||||||
|
|||||||
Reference in New Issue
Block a user