[CLAUDE] FE-Admin: KHKK gop form them hang muc thanh 1 DONG cua bang (FE-only, 0-mig)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m49s

Anh: "Cho 2 cai nay ve lam mot dc ko ?" (mui ten: dong du lieu trong bang <-> panel
"Them hang muc cho:" nam duoi bang). Duoc — panel co dung 3 truong trung khit 3
truong cua mot dong (hang muc / gia / ghi chu), nen no la CHO VE thu hai cho cung
mot thu.

Panel roi -> MOT DONG cua tbody, dung DUNG luoi 4 cot:
  cot 1 STT : icon +
  cot 2     : dong "Them cho: <NCC>" + select hang muc + o ghi chu DONG PHU
              (y het dong du lieu that o tren => hai thu doc thanh CUNG hinh dang)
  cot 3 GIA : o so
  cot 4     : nut Them / Huy
NCC phai hien vi bang KHONG co cot NCC — khong noi thi khong biet dong moi thuoc ai.

🔴 VI SAO KHONG lam kieu "bam + de ngay dong trong roi nhap tai cho" (dung loi inline
cua cac dong khac, ve mat UI thi con gon hon nua):
  AddContractSigningPlanLineCommand:759 khai CatalogEntryId la Guid KHONG nullable +
  validator NotEmpty(), boi vi unique index (Plan, Supplier, CatalogEntry) CO Y khong
  loc NULL (ContractSigningPlanLineConfiguration.cs:35-38) — trong UNIQUE cua SQL
  Server thi NULL == NULL, nen dong trong thu hai cua CUNG mot NCC dung ngay dong goc
  cua chinh no. Noi index de lach = "tha troi vo han dong trung", dung thu chu-thich
  o do cam.
=> Dong nay la DONG NHAP phia client: trong va xep cot y het dong that, nhung chi goi
   API MOT LAN khi da co hang muc ⇒ may giu nguyen bat bien. Gop la gop CHO VE, khong
   cat bot truong nao va khong dong vao schema.

Do duoc (2 ve, moi ve co doi chung):
  - "Them cho: " = 1 o CA HAI dist  => form moi da vao.
  - "Them hang muc cho: " (CO dau hai cham, nhan rieng cua panel cu): nguon 3->2
    (2 hit con lai la CHU THICH), dist = 0 CA HAI  => nhan panel cu da bien mat.
  - "Chon hang muc" = 2 trong nguon la DUNG: :553 select cua dong CO SAN (sua tai cho)
    ⟂ :677 form them. Da truy tung hit thay vi tin con so.
  - 🔸 Ky vong dau cua lead SAI 1 lan: doi "Gia de xuat" = 0 sau khi go panel, do ra 6
    — vi day la nhan DUNG CHUNG (PeDetailTabs + types/purchaseEvaluation). Da doi sang
    discriminator that thay vi ep so cho khop ky vong.

Diff CHI 2 file FE, 0 file BE => .NET test khong the bi anh huong, bo qua vong test do
(khai ro). 2 app build sach · SHA IDENTICAL (verify identical @HEAD TRUOC khi copy de).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-06 16:15:29 +07:00
parent cd79d1c45b
commit 4704aafdf3
2 changed files with 158 additions and 114 deletions

View File

@ -645,6 +645,80 @@ export function KhkkDetailContent({
</td>
</tr>
))}
{/* [S177 — anh: "Cho 2 cái này về làm một đc ko?"] Form thêm giờ là MỘT DÒNG
của chính bảng, dùng ĐÚNG lưới 4 cột — không còn panel rời bên dưới.
🔴 Vì sao KHÔNG làm kiểu "bấm + đẻ ngay dòng trống rồi nhập tại chỗ" (đúng
lối inline của các dòng khác): `AddContractSigningPlanLineCommand:759` khai
`CatalogEntryId` là `Guid` **không nullable** + validator `NotEmpty()`, vì
unique index (Plan, Supplier, CatalogEntry) **CỐ Ý không lọc NULL**
(`ContractSigningPlanLineConfiguration.cs:35-38`) — trong UNIQUE của SQL
Server thì NULL == NULL, nên dòng trống thứ hai của CÙNG một NCC sẽ đụng
ngay dòng gốc của chính nó. Nới index để lách = "thả trôi vô hạn dòng
trùng", đúng thứ chú-thích ở đó cấm.
⇒ Dòng này là **dòng nháp phía client**: trông và xếp cột y hệt dòng thật,
nhưng chỉ gọi API MỘT LẦN khi đã có hạng mục ⇒ máy giữ nguyên bất biến. */}
{isDraft && addFor && (
<tr className="border-b border-brand-200 bg-brand-50/40">
<td className="px-3 py-2 text-right align-top text-brand-500">
<Plus className="ml-auto h-3.5 w-3.5" />
</td>
<td className="px-3 py-2 align-top">
{/* NCC hiện ở đây vì bảng KHÔNG có cột NCC — không nói thì người dùng
không biết dòng mới sẽ thuộc nhà cung cấp nào. */}
<div className="mb-1 text-[11px] font-medium text-brand-700">
Thêm cho: {addFor.supplierName ?? '(không rõ NCC)'}
</div>
<select
value={addCatalogId}
onChange={(e) => setAddCatalogId(e.target.value)}
aria-label="Hạng mục của dòng mới"
className="h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-xs text-slate-700 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
>
<option value=""> Chọn hạng mục </option>
{(catalogOptions.data ?? []).map((c) => (
<option key={c.id} value={c.id}>{c.code} {c.tenVi}</option>
))}
</select>
{/* Ghi chú xuống DÒNG PHỤ — y hệt dòng dữ liệu thật ở trên, để hai thứ
đọc thành CÙNG một hình dạng chứ không phải hai kiểu form. */}
<input
type="text" maxLength={1000}
value={addNote}
onChange={(e) => setAddNote(e.target.value)}
placeholder="Ghi chú (tùy chọn)…"
aria-label="Ghi chú của dòng mới"
className="mt-1 h-7 w-full rounded border border-transparent bg-transparent px-2 text-[11px] text-slate-500 hover:border-slate-200 focus-visible:border-brand-500 focus-visible:bg-white focus-visible:outline-none"
/>
</td>
<td className="px-3 py-2 align-top">
<input
type="number" min={0} step={1000}
value={addAmount}
onChange={(e) => setAddAmount(e.target.value)}
placeholder="0"
aria-label="Giá của dòng mới"
className="h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-right text-xs tabular-nums text-brand-800 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
/>
</td>
<td className="px-3 py-2 align-top">
<div className="flex items-center justify-end gap-1">
<Button
size="sm"
disabled={!addCatalogId || addAmount === '' || Number(addAmount) < 0 || addLine.isPending}
onClick={() => addLine.mutate({
supplierId: addFor.supplierId,
catalogEntryId: addCatalogId,
proposedAmount: Number(addAmount),
note: addNote.trim() || undefined,
})}
>
{addLine.isPending ? 'Đang thêm…' : 'Thêm'}
</Button>
<Button variant="outline" size="sm" onClick={() => setAddFor(null)}>Hủy</Button>
</div>
</td>
</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
@ -678,63 +752,11 @@ export function KhkkDetailContent({
mới ở đây là ký với nhà thầu chưa ai chọn (BE cũng chặn, 409).
🔴 Giữ 1 dòng vết vì đây là chỗ người sau sẽ tìm khi hỏi "nút thêm đâu rồi". */}
{addFor && (
<div className="mt-2 rounded-md border border-brand-200 bg-brand-50/50 p-3">
<div className="mb-2 text-[12px] font-semibold text-brand-800">
Thêm hạng mục cho: {addFor.supplierName ?? '(không rõ NCC)'}
</div>
<div className="flex flex-wrap items-end gap-2">
<label className="min-w-[14rem] flex-1">
<span className="label-eyebrow">Hạng mục</span>
<select
value={addCatalogId}
onChange={(e) => setAddCatalogId(e.target.value)}
className="mt-0.5 h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-xs text-slate-700"
>
<option value=""> Chọn hạng mục </option>
{(catalogOptions.data ?? []).map((c) => (
<option key={c.id} value={c.id}>{c.code} {c.tenVi}</option>
))}
</select>
</label>
<label className="w-[9rem]">
<span className="label-eyebrow">Giá đ xuất</span>
<input
type="number" min={0} step={1000}
value={addAmount}
onChange={(e) => setAddAmount(e.target.value)}
placeholder="0"
className="mt-0.5 h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-right text-xs tabular-nums"
/>
</label>
<label className="min-w-[10rem] flex-1">
<span className="label-eyebrow">Ghi chú</span>
<input
type="text" maxLength={1000}
value={addNote}
onChange={(e) => setAddNote(e.target.value)}
placeholder="(tùy chọn)"
className="mt-0.5 h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-xs"
/>
</label>
<div className="flex gap-1.5">
<Button
size="sm"
disabled={!addCatalogId || addAmount === '' || Number(addAmount) < 0 || addLine.isPending}
onClick={() => addLine.mutate({
supplierId: addFor.supplierId,
catalogEntryId: addCatalogId,
proposedAmount: Number(addAmount),
note: addNote.trim() || undefined,
})}
>
{addLine.isPending ? 'Đang thêm…' : 'Thêm'}
</Button>
<Button variant="outline" size="sm" onClick={() => setAddFor(null)}>Hủy</Button>
</div>
</div>
</div>
)}
{/* 🧊 [S177 — anh: "Cho 2 cái này về làm một đc ko?"] Panel "Thêm hạng mục" rời
ĐÃ GỠ — nội dung của nó nay là 1 DÒNG trong `tbody` (xem trên), dùng chung lưới
4 cột với các dòng thật. Giữ vết vì đây là chỗ người sau tìm khi hỏi form đâu.
🔴 Đúng 3 trường cũ (hạng mục / giá / ghi chú) đi theo nguyên vẹn — gộp là gộp
CHỖ VẼ, không cắt bớt trường nào. */}
</>
)}
{/* 🧊 [S175] 3 dòng chú-thích dưới bảng ĐÃ GỠ — anh: "Bỏ mấy dòng này đi".

View File

@ -645,6 +645,80 @@ export function KhkkDetailContent({
</td>
</tr>
))}
{/* [S177 — anh: "Cho 2 cái này về làm một đc ko?"] Form thêm giờ là MỘT DÒNG
của chính bảng, dùng ĐÚNG lưới 4 cột — không còn panel rời bên dưới.
🔴 Vì sao KHÔNG làm kiểu "bấm + đẻ ngay dòng trống rồi nhập tại chỗ" (đúng
lối inline của các dòng khác): `AddContractSigningPlanLineCommand:759` khai
`CatalogEntryId` là `Guid` **không nullable** + validator `NotEmpty()`, vì
unique index (Plan, Supplier, CatalogEntry) **CỐ Ý không lọc NULL**
(`ContractSigningPlanLineConfiguration.cs:35-38`) — trong UNIQUE của SQL
Server thì NULL == NULL, nên dòng trống thứ hai của CÙNG một NCC sẽ đụng
ngay dòng gốc của chính nó. Nới index để lách = "thả trôi vô hạn dòng
trùng", đúng thứ chú-thích ở đó cấm.
⇒ Dòng này là **dòng nháp phía client**: trông và xếp cột y hệt dòng thật,
nhưng chỉ gọi API MỘT LẦN khi đã có hạng mục ⇒ máy giữ nguyên bất biến. */}
{isDraft && addFor && (
<tr className="border-b border-brand-200 bg-brand-50/40">
<td className="px-3 py-2 text-right align-top text-brand-500">
<Plus className="ml-auto h-3.5 w-3.5" />
</td>
<td className="px-3 py-2 align-top">
{/* NCC hiện ở đây vì bảng KHÔNG có cột NCC — không nói thì người dùng
không biết dòng mới sẽ thuộc nhà cung cấp nào. */}
<div className="mb-1 text-[11px] font-medium text-brand-700">
Thêm cho: {addFor.supplierName ?? '(không rõ NCC)'}
</div>
<select
value={addCatalogId}
onChange={(e) => setAddCatalogId(e.target.value)}
aria-label="Hạng mục của dòng mới"
className="h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-xs text-slate-700 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
>
<option value=""> Chọn hạng mục </option>
{(catalogOptions.data ?? []).map((c) => (
<option key={c.id} value={c.id}>{c.code} {c.tenVi}</option>
))}
</select>
{/* Ghi chú xuống DÒNG PHỤ — y hệt dòng dữ liệu thật ở trên, để hai thứ
đọc thành CÙNG một hình dạng chứ không phải hai kiểu form. */}
<input
type="text" maxLength={1000}
value={addNote}
onChange={(e) => setAddNote(e.target.value)}
placeholder="Ghi chú (tùy chọn)…"
aria-label="Ghi chú của dòng mới"
className="mt-1 h-7 w-full rounded border border-transparent bg-transparent px-2 text-[11px] text-slate-500 hover:border-slate-200 focus-visible:border-brand-500 focus-visible:bg-white focus-visible:outline-none"
/>
</td>
<td className="px-3 py-2 align-top">
<input
type="number" min={0} step={1000}
value={addAmount}
onChange={(e) => setAddAmount(e.target.value)}
placeholder="0"
aria-label="Giá của dòng mới"
className="h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-right text-xs tabular-nums text-brand-800 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
/>
</td>
<td className="px-3 py-2 align-top">
<div className="flex items-center justify-end gap-1">
<Button
size="sm"
disabled={!addCatalogId || addAmount === '' || Number(addAmount) < 0 || addLine.isPending}
onClick={() => addLine.mutate({
supplierId: addFor.supplierId,
catalogEntryId: addCatalogId,
proposedAmount: Number(addAmount),
note: addNote.trim() || undefined,
})}
>
{addLine.isPending ? 'Đang thêm…' : 'Thêm'}
</Button>
<Button variant="outline" size="sm" onClick={() => setAddFor(null)}>Hủy</Button>
</div>
</td>
</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
@ -678,63 +752,11 @@ export function KhkkDetailContent({
mới ở đây là ký với nhà thầu chưa ai chọn (BE cũng chặn, 409).
🔴 Giữ 1 dòng vết vì đây là chỗ người sau sẽ tìm khi hỏi "nút thêm đâu rồi". */}
{addFor && (
<div className="mt-2 rounded-md border border-brand-200 bg-brand-50/50 p-3">
<div className="mb-2 text-[12px] font-semibold text-brand-800">
Thêm hạng mục cho: {addFor.supplierName ?? '(không rõ NCC)'}
</div>
<div className="flex flex-wrap items-end gap-2">
<label className="min-w-[14rem] flex-1">
<span className="label-eyebrow">Hạng mục</span>
<select
value={addCatalogId}
onChange={(e) => setAddCatalogId(e.target.value)}
className="mt-0.5 h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-xs text-slate-700"
>
<option value=""> Chọn hạng mục </option>
{(catalogOptions.data ?? []).map((c) => (
<option key={c.id} value={c.id}>{c.code} {c.tenVi}</option>
))}
</select>
</label>
<label className="w-[9rem]">
<span className="label-eyebrow">Giá đ xuất</span>
<input
type="number" min={0} step={1000}
value={addAmount}
onChange={(e) => setAddAmount(e.target.value)}
placeholder="0"
className="mt-0.5 h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-right text-xs tabular-nums"
/>
</label>
<label className="min-w-[10rem] flex-1">
<span className="label-eyebrow">Ghi chú</span>
<input
type="text" maxLength={1000}
value={addNote}
onChange={(e) => setAddNote(e.target.value)}
placeholder="(tùy chọn)"
className="mt-0.5 h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-xs"
/>
</label>
<div className="flex gap-1.5">
<Button
size="sm"
disabled={!addCatalogId || addAmount === '' || Number(addAmount) < 0 || addLine.isPending}
onClick={() => addLine.mutate({
supplierId: addFor.supplierId,
catalogEntryId: addCatalogId,
proposedAmount: Number(addAmount),
note: addNote.trim() || undefined,
})}
>
{addLine.isPending ? 'Đang thêm…' : 'Thêm'}
</Button>
<Button variant="outline" size="sm" onClick={() => setAddFor(null)}>Hủy</Button>
</div>
</div>
</div>
)}
{/* 🧊 [S177 — anh: "Cho 2 cái này về làm một đc ko?"] Panel "Thêm hạng mục" rời
ĐÃ GỠ — nội dung của nó nay là 1 DÒNG trong `tbody` (xem trên), dùng chung lưới
4 cột với các dòng thật. Giữ vết vì đây là chỗ người sau tìm khi hỏi form đâu.
🔴 Đúng 3 trường cũ (hạng mục / giá / ghi chú) đi theo nguyên vẹn — gộp là gộp
CHỖ VẼ, không cắt bớt trường nào. */}
</>
)}
{/* 🧊 [S175] 3 dòng chú-thích dưới bảng ĐÃ GỠ — anh: "Bỏ mấy dòng này đi".