[CLAUDE] Contract: KHKK UAT S175 dot-F — gom nut thao tac + hang muc du an + loc nhom (0-mig)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m11s

Anh review truc tiep tren prod, khoanh do tung cho. Land tron cum:

=== 1. GOM NUT THAO TAC (anh: "Bo het di" + "phia duoi them cac nut thao tac"
       + "cho menu dang duyet -> Duyet/Tra lai vay thoi") ===
· THANH NUT DAY moi o `KhkkDetailContent` (sticky bottom-0, khuon PE workspace):
  [Xoa phieu] · dong "Cac thay doi da tu dong luu" · [Luu & Gui duyet ->]
· Panel 3 GO "Gui duyet" + "Xoa phieu" => nay CHI con **Duyet / Tra lai**.
· GO nut "+ Lap ke hoach" (header) · nut "Sua" (muc 1) · dai banner "Dang xem ke hoach".
🔴 THU TU CO CHU DICH: KHONG go "Gui duyet" o panel 3 TRUOC khi thanh day co mat — go truoc
  la nguoi soan mat duong trinh duyet, phieu ket o Nhap. Vi vay ca cum di CHUNG 1 commit.
🔴 Thanh day chi om nhanh **Submit** — nhanh Approve/Return (o-tich level-finalize + upload
  file sau transition) O LAI panel 3. Nhan ban nhanh do sang day la de su-that thu hai cho
  duong DUYET.
🔸 KHONG lam nut "Luu" roi: moi o o man nay luu NGAY khi roi o. Them nut khong lam gi la
  hua hao — noi thang bang dong chu ben canh thay vi ve nut gia.
🔸 Giu lai dung 1 dong nho "Ve form tao" — bo han thi mo 1 phieu la ket, khong co duong lui.
· `onDeleted` noi vao ca 3 host (List / Create / Detail) de xoa xong khong dung o phieu ma.

=== 2. HANG MUC CUA DU AN (anh: "hang muc cua Du an -. Duyet NCC ay") ===
🔴 Lead bay NHAM o luot truoc: bay `line.tenHangMuc` = danh-muc **SP-002** (86 dong, dung
  phan loai HOP DONG). Anh muon **hang muc CONG VIEC cua du an** tu phieu Duyet NCC
  (`PE.WorkItemId`). Hai thu trung ten tieng Viet nhung KHAC TRUC — tron la chi sai goi thau.
· BE: `ContractSigningPlanDetailDto` +`PeWorkItemName`; handler join `WorkItems` (loose-Guid
  Mig 49, KHONG FK vat ly => join tay null-safe).
· FE: o "Hang muc (du an)" doc `plan.peWorkItemName`; go het khoi liet-ke SP-002 khoi muc 1.

=== 3. MUC 2 RUT VE "HANG MUC + GIA -> TONG" ===
Anh: "Cho nay doi lai la hang muc la dc roi, chon hang muc tien -> thanh tong con lai bo het"
 + "cho chon nhieu dong nhap gia thanh gia tong, ko can so sanh j voi so duyet PRO hay CCM".
· Tieu de: "2. Gia de xuat theo NCC trung thau" -> "2. Hang muc".
· Cot: 6 -> 3 (HANG MUC | GIA | thao-tac). GO **Nha cung cap · Gia tham chieu · Gia duyet**.
· Ghi chu xuong DONG PHU duoi o hang muc (van nhap duoc, khong chiem cot).
· +hang TONG = Sigma gia cac dong.
🔸 Du-lieu 2 cot go di VAN trong DTO; `peReferenceAmount` con dung lam DAU phan biet
  dong-goc / dong-them-tay o nut xoa.

=== 4. LOC THEO NHOM (anh: "Loc theo quy trinh luon") ===
Tang duoi man Thao tac hien phieu N1/N7 trong khi dang dung o leaf N2 => doc nham "nhom nay
dang co viec". Nay loc theo `approvalGroup` cua leaf menu. `group == null` => khong loc
(giu duong cu cho host khong truyen).

=== 5. GO DAI ACCENT + 2 O LINK HO SO/GHI CHU ===
· `card-accent` boc `KhkkDetailContent` GO HAN (dai xanh doc man Thao tac). GO CLASS chu
  KHONG de `border-0` — class nay ngoai `@layer` nen thang utility (gotcha #66).
· Khoi "Link ho so + Ghi chu" GO TRON ca 2 nhanh ternary. Luot truoc lead moi go o luoi tren
  nen 2 o van hien o khoi duoi — anh chup lai dung cho do.

=== CON NO ===
🔴 Cay man Thao tac: anh muon **Nam > Du an > Hang muc > 4 giai doan** (giong cay o man Danh
  sach). Hien moi co Du an > Hang muc (gap/mo duoc). Chua land.
🔴 CHUA soi mat prod 375/1366/1440.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-05 21:47:12 +07:00
parent 95f72dc278
commit 99cabd8ebe
15 changed files with 430 additions and 471 deletions

View File

@ -21,7 +21,7 @@
import { useState, type ReactNode } from 'react' import { useState, type ReactNode } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { import {
ClipboardList, Download, Eye, FileCheck, Lock, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users, ClipboardList, Download, Eye, FileCheck, Lock, Paperclip, Pencil, Plus, Trash2, Upload, Users,
} from 'lucide-react' } from 'lucide-react'
// [S175] Dùng LẠI dialog xem-trước của PE (đã có prop `basePath` từ đợt B) thay vì dựng bản // [S175] Dùng LẠI dialog xem-trước của PE (đã có prop `basePath` từ đợt B) thay vì dựng bản
// thứ hai cho KHKK — 2 bản = 2 sự thật, sửa 1 chỗ quên chỗ kia. // thứ hai cho KHKK — 2 bản = 2 sự thật, sửa 1 chỗ quên chỗ kia.
@ -40,7 +40,7 @@ import {
DOSSIER_KIND_LABELS, DOSSIER_STATUS_BADGE, DOSSIER_STATUS_LABELS, DOSSIER_KIND_LABELS, DOSSIER_STATUS_BADGE, DOSSIER_STATUS_LABELS,
DossierItemKind, DossierItemStatus, DossierItemKind, DossierItemStatus,
KHKK_APPROVAL_GROUP_LABELS, KHKK_PHASE_BADGE, KHKK_PHASE_LABELS, KHKK_APPROVAL_GROUP_LABELS, KHKK_PHASE_BADGE, KHKK_PHASE_LABELS,
KhkkAttachmentPurpose, KhkkPhase, KhkkAttachmentPurpose, KhkkPhase, KhkkTransitionAction, type KhkkTransitionInput,
type DossierItemKindValue, type DossierItemStatusValue, type KhkkAttachmentDto, type DossierItemKindValue, type DossierItemStatusValue, type KhkkAttachmentDto,
type KhkkDetailDto, type KhkkDossierItemDto, type KhkkPhaseValue, type KhkkDetailDto, type KhkkDossierItemDto, type KhkkPhaseValue,
type UpsertKhkkDossierItemInput, type UpsertKhkkDossierItemInput,
@ -149,17 +149,17 @@ export function KhkkDetailContent({
plan, plan,
readOnly, readOnly,
onChanged, onChanged,
onDeleted,
}: { }: {
plan: KhkkDetailDto plan: KhkkDetailDto
/** [P2-7] Host quyết: `true` khi phiếu ≠ Nháp/Trả lại HOẶC user ≠ người soạn. */ /** [P2-7] Host quyết: `true` khi phiếu ≠ Nháp/Trả lại HOẶC user ≠ người soạn. */
readOnly: boolean readOnly: boolean
onChanged?: () => void onChanged?: () => void
/** [S175] Gọi sau khi XÓA phiếu ở thanh nút đáy — host bỏ chọn / quay về danh sách. */
onDeleted?: () => void
}) { }) {
const qc = useQueryClient() const qc = useQueryClient()
const id = plan.id const id = plan.id
const [editHeader, setEditHeader] = useState(false)
const [ghiChu, setGhiChu] = useState('')
const [hoSoLink, setHoSoLink] = useState('')
const [itemForm, setItemForm] = useState<UpsertKhkkDossierItemInput | null>(null) const [itemForm, setItemForm] = useState<UpsertKhkkDossierItemInput | null>(null)
// [S175] File đang xem-trước inline trong khối "File đính kèm" đã gộp. // [S175] File đang xem-trước inline trong khối "File đính kèm" đã gộp.
const [previewAtt, setPreviewAtt] = useState<KhkkAttachmentDto | null>(null) const [previewAtt, setPreviewAtt] = useState<KhkkAttachmentDto | null>(null)
@ -180,16 +180,6 @@ export function KhkkDetailContent({
onChanged?.() onChanged?.()
} }
const saveHeader = useMutation({
mutationFn: async () => {
await api.put(`/contract-signing-plans/${id}`, {
ghiChu: ghiChu.trim() || null,
hoSoLink: hoSoLink.trim() || null,
})
},
onSuccess: () => { toast.success('Đã lưu'); setEditHeader(false); invalidate() },
onError: (e) => toast.error(getErrorMessage(e)),
})
// Danh mục hạng mục SP-002 — chỉ tải khi phiếu còn sửa được (tránh 1 request thừa). // Danh mục hạng mục SP-002 — chỉ tải khi phiếu còn sửa được (tránh 1 request thừa).
const catalogOptions = useQuery({ const catalogOptions = useQuery({
@ -234,6 +224,26 @@ export function KhkkDetailContent({
onError: (e) => toast.error(getErrorMessage(e)), onError: (e) => toast.error(getErrorMessage(e)),
}) })
// [S175 — anh: "phía dưới thêm các nút thao tác"] 2 mutation cho THANH NÚT ĐÁY.
// 🔴 CỐ Ý chỉ ôm nhánh **Submit** — nhánh Approve/Return có máy phức tạp (ô-tích
// level-finalize + upload file đính kèm SAU khi transition thành công) và nó ở LẠI
// `KhkkWorkflowPanel`. Nhân bản nhánh đó sang đây là đẻ sự-thật thứ hai cho đường DUYỆT;
// nhánh Submit thì chỉ có đúng `action` + `comment: null` nên bê là an toàn.
const submitPlan = useMutation({
mutationFn: async () => {
const body: KhkkTransitionInput = { action: KhkkTransitionAction.Submit, comment: null }
return api.post(`/contract-signing-plans/${id}/transitions`, body)
},
onSuccess: () => { toast.success('Đã gửi duyệt'); invalidate(); onChanged?.() },
onError: (e) => toast.error(getErrorMessage(e)),
})
const deletePlan = useMutation({
mutationFn: async () => api.delete(`/contract-signing-plans/${id}`),
onSuccess: () => { toast.success('Đã xóa phiếu'); onDeleted?.() },
onError: (e) => toast.error(getErrorMessage(e)),
})
const upsertItem = useMutation({ const upsertItem = useMutation({
mutationFn: async (body: UpsertKhkkDossierItemInput) => { mutationFn: async (body: UpsertKhkkDossierItemInput) => {
if (!body.name.trim()) throw new Error('Vui lòng nhập tên căn cứ') if (!body.name.trim()) throw new Error('Vui lòng nhập tên căn cứ')
@ -306,11 +316,6 @@ export function KhkkDetailContent({
} }
} }
const startEditHeader = () => {
setGhiChu(plan.ghiChu ?? '')
setHoSoLink(plan.hoSoLink ?? '')
setEditHeader(true)
}
// File "đính kèm khi duyệt" thuộc panel 3 (mục 5) — Section 4 chỉ liệt kê hồ-sơ của phiếu. // File "đính kèm khi duyệt" thuộc panel 3 (mục 5) — Section 4 chỉ liệt kê hồ-sơ của phiếu.
// [S175 · O4] Chia 2 rổ: file GẮN VÀO một căn cứ hiện ngay dưới dòng căn cứ đó; Section 4 // [S175 · O4] Chia 2 rổ: file GẮN VÀO một căn cứ hiện ngay dưới dòng căn cứ đó; Section 4
@ -403,6 +408,11 @@ export function KhkkDetailContent({
<div className="divide-y divide-slate-200"> <div className="divide-y divide-slate-200">
{/* ─────────────── 1 ─────────────── */} {/* ─────────────── 1 ─────────────── */}
{/* 🧊 [S175 — anh: "Bỏ hết đi"] Nút "Sửa" ĐÃ GỠ khỏi `actions`. Nó chỉ mở form 2 trường
**Link hồ sơ + Ghi chú** — mà chính 2 trường đó anh cũng bảo bỏ khỏi màn ở cùng lượt.
Giữ nút mở form sửa một thứ không còn hiển thị ở đâu là mâu thuẫn.
🔸 Máy KHÔNG mất: `hoSoLink`/`ghiChu` vẫn nằm trong DTO + BE
(`UpdateContractSigningPlanDraftCommand`), muốn bày lại chỉ là thêm chỗ vẽ. */}
<Section <Section
title="1. Thông tin kế hoạch" title="1. Thông tin kế hoạch"
icon={<FileCheck className="h-4 w-4" />} icon={<FileCheck className="h-4 w-4" />}
@ -410,13 +420,6 @@ export function KhkkDetailContent({
head="text-brand-800" head="text-brand-800"
chipBg="var(--color-brand-50)" chipBg="var(--color-brand-50)"
chipFg="var(--color-brand-600)" chipFg="var(--color-brand-600)"
actions={
isDraft && !editHeader ? (
<Button variant="outline" size="sm" onClick={startEditHeader}>
<Pencil className="mr-2 h-3.5 w-3.5" /> Sửa
</Button>
) : undefined
}
> >
{/* [S175 — anh chốt trên ảnh khoanh đỏ] Mục 1 RÚT GỌN còn 4 ô: {/* [S175 — anh chốt trên ảnh khoanh đỏ] Mục 1 RÚT GỌN còn 4 ô:
· "Bỏ hết đi" → GỠ **Phòng ban · Quy trình · Hạn xử lý** · "Bỏ hết đi" → GỠ **Phòng ban · Quy trình · Hạn xử lý**
@ -428,68 +431,25 @@ export function KhkkDetailContent({
<div className="grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2"> <div className="grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2">
<Field label="Mã kế hoạch" value={plan.maKeHoach ?? '—'} mono /> <Field label="Mã kế hoạch" value={plan.maKeHoach ?? '—'} mono />
<Field label="Dự án" value={plan.projectName ?? '—'} /> <Field label="Dự án" value={plan.projectName ?? '—'} />
{/* 1 phiếu nay được N hạng mục (S175) ⇒ liệt kê DUY-NHẤT, giữ thứ tự dòng. {/* [S175 — anh: "hạng mục của Dự án, Duyệt NCC ấy"] Đây là HẠNG MỤC CÔNG VIỆC của
Dòng chưa gán KHÔNG bịa tên — đếm riêng để người soạn biết còn nợ mấy dòng, dự án, lấy từ phiếu Duyệt NCC nguồn (`PE.WorkItemId`).
vì submit-guard sẽ chặn trình duyệt khi còn dòng chưa gán. */} 🔴 KHÁC hẳn `line.tenHangMuc` — cái đó là danh-mục SP-002 dùng phân loại HỢP
<Field label="Hạng mục" full> ĐỒNG (86 dòng, chọn ở mục 2). Hai thứ trùng tên tiếng Việt nhưng khác trục;
{(() => { lượt trước lead bày nhầm cái SP-002 ở đây nên anh bảo "Bỏ đi". */}
const names = [...new Set(plan.lines.map(l => l.tenHangMuc).filter(Boolean))] as string[] <Field label="Hạng mục (dự án)" value={plan.peWorkItemName ?? '—'} />
const unassigned = plan.lines.filter(l => !l.catalogEntryId).length
if (names.length === 0 && unassigned === 0) return DASH
return (
<span className="flex flex-wrap items-center gap-1">
{names.map(n => (
<span key={n} className="inline-flex items-center rounded bg-brand-50 px-1.5 py-0.5 text-[11px] font-medium text-brand-700">
{n}
</span>
))}
{unassigned > 0 && (
<span className="inline-flex items-center rounded bg-amber-100 px-1.5 py-0.5 text-[11px] font-medium text-amber-800">
còn {unassigned} dòng chưa gán
</span>
)}
</span>
)
})()}
</Field>
<Field label="Người soạn" value={plan.drafterFullName ?? '—'} /> <Field label="Người soạn" value={plan.drafterFullName ?? '—'} />
<Field label="Ngày tạo" value={formatDateTime(plan.createdAt)} /> <Field label="Ngày tạo" value={formatDateTime(plan.createdAt)} />
</div> </div>
{editHeader ? ( {/* 🧊 [S175 — anh: "Bỏ đi"] Khối **Link hồ sơ + Ghi chú** (cả ô XEM lẫn form SỬA)
<div className="mt-4 space-y-3 rounded-lg border border-slate-200 bg-slate-50/60 p-4"> ĐÃ GỠ. Lượt trước lead mới gỡ ở lưới trên nên 2 ô này VẪN HIỆN ở khối dưới —
<div> anh chụp lại đúng chỗ đó. Nay gỡ trọn cả 2 nhánh của ternary.
<Label htmlFor="hoSoLink">Link hồ (NAS)</Label> 🔸 Máy KHÔNG mất: hoSoLink / ghiChu còn nguyên trong DTO + BE. */}
<Input id="hoSoLink" value={hoSoLink} onChange={(e) => setHoSoLink(e.target.value)} maxLength={1000} placeholder="\\\\nas\\du-an\\..." />
</div>
<div>
<Label htmlFor="ghiChu">Ghi chú</Label>
<Textarea id="ghiChu" value={ghiChu} onChange={(e) => setGhiChu(e.target.value)} maxLength={2000} rows={4} />
<div className="mt-1 text-xs text-muted-foreground">{ghiChu.length}/2000</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => setEditHeader(false)}>Huỷ</Button>
<Button size="sm" onClick={() => saveHeader.mutate()} disabled={saveHeader.isPending}>
<Save className="mr-2 h-3.5 w-3.5" />
{saveHeader.isPending ? 'Đang lưu...' : 'Lưu'}
</Button>
</div>
</div>
) : (
<div className="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2">
<Field label="Link hồ sơ" full>
{plan.hoSoLink ? <span className="break-all font-normal text-slate-700">{plan.hoSoLink}</span> : <span className="text-slate-300"></span>}
</Field>
<Field label="Ghi chú" full>
{plan.ghiChu ? <span className="whitespace-pre-wrap font-normal text-slate-700">{plan.ghiChu}</span> : <span className="text-slate-300"></span>}
</Field>
</div>
)}
</Section> </Section>
{/* ─────────────── 2 ─────────────── */} {/* ─────────────── 2 ─────────────── */}
<Section <Section
title={`2. Giá đề xuất theo NCC trúng thầu (${plan.lines.length} dòng)`} title={`2. Hạng mục (${plan.lines.length} dòng)`}
icon={<Users className="h-4 w-4" />} icon={<Users className="h-4 w-4" />}
accent="var(--color-teal-500)" accent="var(--color-teal-500)"
head="text-teal-700" head="text-teal-700"
@ -520,34 +480,20 @@ export function KhkkDetailContent({
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full min-w-[880px] table-fixed text-xs"> <table className="w-full min-w-[880px] table-fixed text-xs">
<colgroup> <colgroup>
<col className="w-[22%]" /> <col className="w-[52%]" />
<col className="w-[22%]" /> <col className="w-[32%]" />
<col className="w-[14%]" /> <col className="w-[16%]" />
<col className="w-[14%]" />
<col className="w-[14%]" />
<col className="w-[14%]" />
</colgroup> </colgroup>
<thead className="border-b border-slate-200 bg-slate-50/70"> <thead className="border-b border-slate-200 bg-slate-50/70">
<tr> <tr>
<th className="label-eyebrow px-3 py-2 text-left">Nhà cung cấp</th>
<th className="label-eyebrow px-3 py-2 text-left">Hạng mục</th> <th className="label-eyebrow px-3 py-2 text-left">Hạng mục</th>
<th className="label-eyebrow px-3 py-2 text-right">Giá tham chiếu</th> <th className="label-eyebrow px-3 py-2 text-right">Giá</th>
<th className="label-eyebrow px-3 py-2 text-right">Giá đ xuất</th> <th className="label-eyebrow px-3 py-2 text-right"></th>
<th className="label-eyebrow px-3 py-2 text-right">Giá duyệt</th>
<th className="label-eyebrow px-3 py-2 text-left">Ghi chú</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{plan.lines.map((l) => ( {plan.lines.map((l) => (
<tr key={l.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/60"> <tr key={l.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/60">
<td className="px-3 py-2 font-medium text-brand-800">
{orDash(l.supplierName)}
{l.contractId && (
<a href={`/contracts/${l.contractId}`} className="ml-2 text-[10px] font-medium text-brand-600 hover:underline" title="Dòng này đã được đưa vào hợp đồng">
</a>
)}
</td>
<td className="px-3 py-2 text-slate-700"> <td className="px-3 py-2 text-slate-700">
{isDraft && !l.contractId ? ( {isDraft && !l.contractId ? (
<> <>
@ -555,12 +501,10 @@ export function KhkkDetailContent({
value={l.catalogEntryId ?? ''} value={l.catalogEntryId ?? ''}
onChange={(e) => e.target.value && assignLine.mutate({ lineId: l.id, catalogEntryId: e.target.value })} onChange={(e) => e.target.value && assignLine.mutate({ lineId: l.id, catalogEntryId: e.target.value })}
disabled={assignLine.isPending} disabled={assignLine.isPending}
aria-label={`n hạng mục SP-002 cho dòng ${l.supplierName ?? l.id}`} aria-label={`Chọn hạng mục cho dòng ${l.id}`}
className="h-8 w-full min-w-[12rem] 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" 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> <option value=""> Chọn hạng mục </option>
{/* Hạng mục ĐÃ LƯU mà không có trong options ⇒ chèn option đại diện,
không thì dòng đã gán TRÔNG như chưa gán. */}
{l.catalogEntryId && !(catalogOptions.data ?? []).some((c) => c.id === l.catalogEntryId) && ( {l.catalogEntryId && !(catalogOptions.data ?? []).some((c) => c.id === l.catalogEntryId) && (
<option value={l.catalogEntryId}>{l.tenHangMuc ?? '(hạng mục đã gán)'}</option> <option value={l.catalogEntryId}>{l.tenHangMuc ?? '(hạng mục đã gán)'}</option>
)} )}
@ -568,30 +512,30 @@ export function KhkkDetailContent({
<option key={c.id} value={c.id}>{c.code} {c.tenVi}</option> <option key={c.id} value={c.id}>{c.code} {c.tenVi}</option>
))} ))}
</select> </select>
{catalogOptions.isError && ( {/* Ghi chú xuống DÒNG PHỤ thay vì chiếm 1 cột — anh chốt bảng chỉ
<div className="mt-1 text-[11px] text-red-600"> còn "hạng mục + giá", nhưng ghi chú thì anh vẫn muốn nhập được. */}
Không tải đưc danh mục hạng mục {' '} <input
<button type="button" className="underline" onClick={() => catalogOptions.refetch()}>thử lại</button> type="text"
</div> maxLength={1000}
)} defaultValue={l.note ?? ''}
{catalogOptions.isSuccess && (catalogOptions.data?.length ?? 0) === 0 && ( placeholder="Ghi chú (tùy chọn)…"
<div className="mt-1 text-[11px] text-amber-700"> disabled={saveLineField.isPending}
Nhóm duyệt N{plan.approvalGroup} chưa hạng mục đang hiệu lực liên hệ quản trị danh mục. aria-label="Ghi chú dòng"
</div> onBlur={(e) => {
)} const v = e.target.value.trim()
if (v !== (l.note ?? '')) saveLineField.mutate({ lineId: l.id, note: v })
}}
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"
/>
</> </>
) : orDash(l.tenHangMuc)} ) : (
<>
<div>{orDash(l.tenHangMuc)}</div>
{l.note && <div className="mt-0.5 text-[11px] text-slate-500">{l.note}</div>}
</>
)}
</td> </td>
{/* Dòng THÊM TAY có `peReferenceAmount = 0` — BE cố ý KHÔNG copy số tham <td className="px-3 py-2 text-right align-top tabular-nums text-brand-800">
chiếu của NCC (đã nằm trọn ở dòng gốc), lặp lại là đếm đôi. Hiện
em-dash mờ thay vì "0 đ" để không đọc nhầm thành "tham chiếu bằng 0". */}
<td className="px-3 py-2 text-right tabular-nums text-slate-600">
{l.peReferenceAmount === 0 ? DASH : formatVnd(l.peReferenceAmount)}
</td>
{/* [S175] GIÁ ĐỀ XUẤT nhập tay — anh chốt "nhiều hạng mục → giá và ghi chú".
Lưu khi rời ô (onBlur) và chỉ khi SỐ ĐỔI THẬT, tránh bắn 1 request mỗi
lần click vào rồi click ra. */}
<td className="px-3 py-2 text-right tabular-nums text-brand-800">
{isDraft && !l.contractId ? ( {isDraft && !l.contractId ? (
<input <input
type="number" type="number"
@ -599,52 +543,46 @@ export function KhkkDetailContent({
step={1000} step={1000}
defaultValue={l.proposedAmount} defaultValue={l.proposedAmount}
disabled={saveLineField.isPending} disabled={saveLineField.isPending}
aria-label={`Giá đề xuất dòng ${l.tenHangMuc ?? l.supplierName ?? l.id}`} aria-label="Giá của hạng mục"
onBlur={(e) => { onBlur={(e) => {
const v = Number(e.target.value) const v = Number(e.target.value)
if (!Number.isFinite(v) || v < 0) { e.target.value = String(l.proposedAmount); return } if (!Number.isFinite(v) || v < 0) { e.target.value = String(l.proposedAmount); return }
if (v !== l.proposedAmount) saveLineField.mutate({ lineId: l.id, proposedAmount: v }) if (v !== l.proposedAmount) saveLineField.mutate({ lineId: l.id, proposedAmount: v })
}} }}
className="h-8 w-full min-w-[7rem] 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" 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"
/> />
) : moneyOrDash(l.proposedAmount)} ) : moneyOrDash(l.proposedAmount)}
</td> </td>
<td className="px-3 py-2 text-right tabular-nums text-emerald-700">{moneyOrDash(l.approvedAmount)}</td> <td className="px-3 py-2 text-right align-top">
<td className="px-3 py-2 text-slate-600"> {isDraft && !l.contractId && l.peReferenceAmount === 0 && (
{isDraft && !l.contractId ? (
<div className="flex items-center gap-1">
<input
type="text"
maxLength={1000}
defaultValue={l.note ?? ''}
placeholder="Ghi chú…"
disabled={saveLineField.isPending}
aria-label={`Ghi chú dòng ${l.tenHangMuc ?? l.supplierName ?? l.id}`}
onBlur={(e) => {
const v = e.target.value.trim()
if (v !== (l.note ?? '')) saveLineField.mutate({ lineId: l.id, note: v })
}}
className="h-8 w-full min-w-[6rem] 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"
/>
{/* Chỉ dòng THÊM TAY mới xoá được. Dòng gốc sinh từ phiếu Duyệt NCC
mang snapshot tham chiếu — BE cũng chặn, đây chỉ là không mời bấm. */}
{l.peReferenceAmount === 0 && (
<button <button
type="button" type="button"
onClick={() => { if (window.confirm(`Xoá hạng mục "${l.tenHangMuc ?? '(chưa gán)'}"?`)) deleteLine.mutate(l.id) }} onClick={() => { if (window.confirm(`Xoá hạng mục "${l.tenHangMuc ?? '(chưa gán)'}"?`)) deleteLine.mutate(l.id) }}
disabled={deleteLine.isPending} disabled={deleteLine.isPending}
title="Xoá hạng mục này" title="Xoá hạng mục này"
className="shrink-0 rounded px-1 py-1 text-red-500 hover:bg-red-50" className="rounded px-1 py-1 text-red-500 hover:bg-red-50"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</button> </button>
)} )}
</div>
) : orDash(l.note)}
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
{/* [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
sánh j với số duyệt PRO hay CCM j cả". Dữ-liệu 2 cột đó VẪN nằm trong DTO
(`peReferenceAmount` / `approvedAmount`), chỉ bỏ chỗ vẽ; `peReferenceAmount`
còn được dùng làm DẤU phân biệt dòng-gốc ⟂ dòng-thêm-tay ở nút xoá trên. */}
<tfoot>
<tr className="border-t-2 border-slate-300 bg-slate-50">
<td className="px-3 py-2 text-right text-xs font-semibold uppercase tracking-wide text-slate-600">Tổng</td>
<td className="px-3 py-2 text-right text-sm font-bold tabular-nums text-brand-800">
{formatVnd(plan.lines.reduce((s, l) => s + (l.proposedAmount ?? 0), 0))}
</td>
<td />
</tr>
</tfoot>
</table> </table>
</div> </div>
@ -939,6 +877,62 @@ export function KhkkDetailContent({
</div> </div>
)} )}
</Dialog> </Dialog>
{/* [S175 — anh: "phía dưới thêm các nút thao tác"] THANH NÚT ĐÁY, khuôn PE workspace.
Gom về ĐÂY những nút anh bảo gỡ khỏi chỗ khác: "Gửi duyệt" + "Xóa phiếu" (trước ở
khối HÀNH ĐỘNG panel 3). Panel 3 từ nay chỉ còn **Duyệt / Trả lại** — đúng câu anh
chốt "chỗ menu đang duyệt → Duyệt/Trả lại vậy thôi".
🔴 `sticky bottom-0`: phiếu dài, nút nằm cuối trang thì phải cuộn hết mới thấy —
dính đáy là để lúc nào cũng với tới được.
🔸 KHÔNG có nút "Lưu" rời: mọi ô ở màn này lưu NGAY khi rời ô (hạng mục / giá / ghi
chú / file). Thêm một nút "Lưu" không làm gì là hứa hão — nói thẳng bằng dòng chữ
bên cạnh thay vì vẽ nút giả. */}
{!readOnly && (
<div className="sticky bottom-0 z-10 flex flex-wrap items-center gap-3 border-t border-slate-200 bg-white/95 px-5 py-3 backdrop-blur">
<button
type="button"
onClick={() => {
if (window.confirm(`Xóa phiếu ${plan.maKeHoach ?? ''}? Phiếu chuyển sang mục "Đã xóa".`)) deletePlan.mutate()
}}
disabled={deletePlan.isPending}
className="inline-flex items-center gap-1.5 rounded-md bg-red-600 px-3 py-2 text-xs font-semibold text-white transition hover:bg-red-700 disabled:opacity-50"
>
<Trash2 className="h-3.5 w-3.5" />
{deletePlan.isPending ? 'Đang xóa…' : 'Xóa phiếu'}
</button>
<span className="min-w-0 flex-1 text-[12px] text-emerald-700">
Các thay đi đã tự đng lưu khi chỉnh sửa từng phần.
</span>
{(() => {
// Rào HIỂN THỊ mirror submit-guard BE: chưa pin quy trình, hoặc còn dòng chưa gán
// hạng mục ⇒ BE sẽ chặn. Nói TRƯỚC lý do thay vì để bấm rồi ăn 409.
const unassigned = plan.lines.filter(l => !l.catalogEntryId).length
const noWorkflow = !plan.approvalWorkflowId
const blocked = noWorkflow || unassigned > 0 || plan.lines.length === 0
const why = noWorkflow ? 'Chưa pin quy trình duyệt cho phiếu.'
: plan.lines.length === 0 ? 'Phiếu chưa có dòng NCC nào.'
: unassigned > 0 ? `Còn ${unassigned} dòng chưa gán hạng mục.`
: undefined
return (
<button
type="button"
onClick={() => { if (!blocked) submitPlan.mutate() }}
disabled={blocked || submitPlan.isPending}
title={why ?? 'Gửi phiếu vào quy trình duyệt'}
className={cn(
'inline-flex items-center gap-1.5 rounded-md px-3 py-2 text-xs font-semibold text-white transition',
blocked ? 'cursor-not-allowed bg-slate-300' : 'bg-brand-600 hover:bg-brand-700',
submitPlan.isPending && 'opacity-50',
)}
>
{submitPlan.isPending ? 'Đang gửi…' : 'Lưu & Gửi duyệt →'}
</button>
)
})()}
</div>
)}
</div> </div>
) )
} }

View File

@ -81,6 +81,7 @@ export function KhkkWorkListPanel({
selectedPeId, selectedPeId,
selectedPlanId, selectedPlanId,
onEditPlan, onEditPlan,
group,
}: { }: {
/** Bấm PE ở tầng trên → host nạp phiếu đó vào form tạo (Ở LẠI màn, không điều hướng). */ /** Bấm PE ở tầng trên → host nạp phiếu đó vào form tạo (Ở LẠI màn, không điều hướng). */
onPickPe: (peId: string) => void onPickPe: (peId: string) => void
@ -92,6 +93,10 @@ export function KhkkWorkListPanel({
/** [S175 · O7] Bấm bút chì trên card KHKK → host mở phiếu ở chế-độ SỬA. Không truyền ⇒ /** [S175 · O7] Bấm bút chì trên card KHKK → host mở phiếu ở chế-độ SỬA. Không truyền ⇒
* không vẽ bút chì (cột giữ đúng hình cũ). */ * không vẽ bút chì (cột giữ đúng hình cũ). */
onEditPlan?: (planId: string) => void onEditPlan?: (planId: string) => void
/** [S175 - anh: "Loc theo quy trinh luon"] Nhom duyet cua leaf menu dang dung.
* Tang duoi CHI hien ke hoach thuoc dung nhom nay - dung o leaf N2 ma thay phieu
* N1/N7 la doc nham "nhom nay dang co viec". `null` = khong loc. */
group?: number | null
}) { }) {
// [S175 — anh: "Cho hiển thị theo cây nằm dự án hạng mục"] Tập KHOÁ đang GẬP. // [S175 — anh: "Cho hiển thị theo cây nằm dự án hạng mục"] Tập KHOÁ đang GẬP.
// Dùng tập-gập (không phải tập-mở) để mặc-định là MỞ HẾT: dự án/hạng mục mới xuất hiện sau // Dùng tập-gập (không phải tập-mở) để mặc-định là MỞ HẾT: dự án/hạng mục mới xuất hiện sau
@ -156,7 +161,10 @@ export function KhkkWorkListPanel({
// (2) cửa-sổ 30 phiếu mới nhất có thể toàn phiếu đã duyệt ⇒ tầng dưới rỗng dù còn nháp // (2) cửa-sổ 30 phiếu mới nhất có thể toàn phiếu đã duyệt ⇒ tầng dưới rỗng dù còn nháp
// cũ hơn. Chấp nhận ở UAT; muốn chuẩn thì BE nhận `phases[]` rồi lọc SERVER-SIDE. // cũ hơn. Chấp nhận ở UAT; muốn chuẩn thì BE nhận `phases[]` rồi lọc SERVER-SIDE.
const OPEN_PHASES: number[] = [KhkkPhase.DangSoanThao, KhkkPhase.TraLai, KhkkPhase.TuChoi] const OPEN_PHASES: number[] = [KhkkPhase.DangSoanThao, KhkkPhase.TraLai, KhkkPhase.TuChoi]
const planItems = (plans.data?.items ?? []).filter((k) => OPEN_PHASES.includes(k.phase)) const planItems = (plans.data?.items ?? [])
.filter((k) => OPEN_PHASES.includes(k.phase))
// [S175] +loc NHOM. `group == null` => khong loc (giu duong cu cho host khong truyen).
.filter((k) => group == null || k.approvalGroup === group)
return ( return (
<aside className="flex min-h-0 flex-col overflow-hidden border-r border-slate-200 bg-white"> <aside className="flex min-h-0 flex-col overflow-hidden border-r border-slate-200 bg-white">

View File

@ -33,7 +33,7 @@
import { useMemo, useRef, useState } from 'react' import { useMemo, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Download, Eye, History, Paperclip, Send, Trash2 } from 'lucide-react' import { Download, Eye, History, Paperclip } from 'lucide-react'
import { AttachmentPreviewDialog, isPreviewable } from '@/components/pe/AttachmentPreviewDialog' import { AttachmentPreviewDialog, isPreviewable } from '@/components/pe/AttachmentPreviewDialog'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog' import { Dialog } from '@/components/ui/Dialog'
@ -114,8 +114,8 @@ export function KhkkWorkflowPanel({
const isWaiting = phase === KhkkPhase.ChoDuyet const isWaiting = phase === KhkkPhase.ChoDuyet
const isApproved = phase === KhkkPhase.DaDuyet const isApproved = phase === KhkkPhase.DaDuyet
const isDraftLike = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TraLai // 🧊 [S175] `isDraftLike` / `canSubmit` / `canDeletePlan` ĐÃ GỠ cùng 2 nút "Gửi duyệt" +
const canDeletePlan = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TuChoi // "Xóa phiếu" — panel này nay chỉ lo màn ĐANG DUYỆT (Duyệt / Trả lại).
const curStepIdx = plan.currentWorkflowStepIndex const curStepIdx = plan.currentWorkflowStepIndex
const curLevelOrder = plan.currentApprovalLevelOrder const curLevelOrder = plan.currentApprovalLevelOrder
@ -172,7 +172,6 @@ export function KhkkWorkflowPanel({
// Nút "Gửi duyệt": guard THẬT ở BE (`CreatedBy DeptManager Admin`). FE KHÔNG suy được // Nút "Gửi duyệt": guard THẬT ở BE (`CreatedBy DeptManager Admin`). FE KHÔNG suy được
// vế DeptManager ⇒ CỐ Ý không ẩn nút theo vai (ẩn = giấu nút với đúng người có quyền, im // vế DeptManager ⇒ CỐ Ý không ẩn nút theo vai (ẩn = giấu nút với đúng người có quyền, im
// lặng — bug-class gotcha #44). Chỉ chặn ca chắc-chắn-hỏng: chưa pin quy trình. // lặng — bug-class gotcha #44). Chỉ chặn ca chắc-chắn-hỏng: chưa pin quy trình.
const canSubmit = isDraftLike && !!plan.approvalWorkflowId
const approvalFiles: KhkkAttachmentDto[] = const approvalFiles: KhkkAttachmentDto[] =
(plan.attachments ?? []).filter(a => a.purpose === KhkkAttachmentPurpose.ApprovalAttachment) (plan.attachments ?? []).filter(a => a.purpose === KhkkAttachmentPurpose.ApprovalAttachment)
@ -434,24 +433,15 @@ export function KhkkWorkflowPanel({
)} )}
{/* ── 4. Hành động + nút Xóa ─────────────────────────────────────────── */} {/* ── 4. Hành động + nút Xóa ─────────────────────────────────────────── */}
{(canSubmit || isWaiting || canDeletePlan) && ( {/* 🧊 [S175 — anh: "Bỏ hết đi" + "chỗ menu đang duyệt → Duyệt/Trả lại vậy thôi"]
"Gửi duyệt" và "Xóa phiếu" ĐÃ RỜI khỏi đây → nay nằm ở THANH NÚT ĐÁY của
`KhkkDetailContent`. Panel này từ nay CHỈ còn **Duyệt / Trả lại** — đúng phạm vi
"màn đang duyệt". Máy `KhkkTransitionAction.Submit` vẫn nguyên ở BE và ở thanh đáy;
gỡ ở đây chỉ là gỡ ĐƯỜNG BẤM TRÙNG, không phải bỏ chức năng. */}
{isWaiting && (
<div> <div>
<Label className="text-xs">Hành đng:</Label> <Label className="text-xs">Hành đng:</Label>
<div className="mt-1 flex flex-wrap gap-1.5"> <div className="mt-1 flex flex-wrap gap-1.5">
{canSubmit && (
<button
type="button"
onClick={() => { setComment(''); setPendingFiles([]); setAction(KhkkTransitionAction.Submit) }}
className="inline-flex items-center gap-1 rounded border border-brand-300 px-2 py-1 text-[11px] font-bold text-brand-700 transition hover:bg-brand-50"
>
<Send className="h-3 w-3" /> Gửi duyệt
</button>
)}
{isDraftLike && !plan.approvalWorkflowId && (
<span className="rounded border border-dashed border-slate-300 px-2 py-1 text-[11px] text-slate-500">
Chưa pin quy trình duyệt không gửi duyệt đưc.
</span>
)}
{isWaiting && ( {isWaiting && (
<> <>
<button <button
@ -494,16 +484,6 @@ export function KhkkWorkflowPanel({
và mở lại sau này chỉ là thêm nút. Gỡ cả máy = mất dữ-liệu lịch sử. */} và mở lại sau này chỉ là thêm nút. Gỡ cả máy = mất dữ-liệu lịch sử. */}
</> </>
)} )}
{canDeletePlan && (
<button
type="button"
onClick={() => setConfirmDelete(true)}
title="Xóa phiếu này (phiếu chuyển sang mục &ldquo;Đã xóa&rdquo;)"
className="inline-flex items-center gap-1 rounded border border-red-300 bg-white px-2 py-1 text-[11px] font-bold text-red-700 transition hover:bg-red-50"
>
<Trash2 className="h-3 w-3" /> Xóa phiếu
</button>
)}
</div> </div>
</div> </div>
)} )}

View File

@ -28,7 +28,7 @@
// tái-hiện vá-5 (đi từ leaf nhóm 2 sang leaf nhóm 5 phải F5 mới đúng). ĐỪNG ĐỘNG. // tái-hiện vá-5 (đi từ leaf nhóm 2 sang leaf nhóm 5 phải F5 mới đúng). ĐỪNG ĐỘNG.
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 { useNavigate, useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { FileCheck, Pencil, X } from 'lucide-react' import { FileCheck, Pencil, X } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { EmptyState } from '@/components/EmptyState' import { EmptyState } from '@/components/EmptyState'
@ -39,7 +39,6 @@ import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
import { KhkkWorkListPanel } from '@/components/khkk/KhkkWorkListPanel' import { KhkkWorkListPanel } from '@/components/khkk/KhkkWorkListPanel'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError' import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import { useAuth } from '@/contexts/AuthContext' import { useAuth } from '@/contexts/AuthContext'
import { import {
KHKK_APPROVAL_GROUP_DEFAULT, KHKK_APPROVAL_GROUP_DEFAULT,
@ -67,7 +66,6 @@ interface WorkflowDto {
// chỉnh"). Giữ lại thành hàm chết thì lượt sau có người gọi lại. // chỉnh"). Giữ lại thành hàm chết thì lượt sau có người gọi lại.
export function KhkkCreatePage() { export function KhkkCreatePage() {
const navigate = useNavigate()
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const groupParam = searchParams.get('group') const groupParam = searchParams.get('group')
@ -203,6 +201,7 @@ export function KhkkCreatePage() {
selectedPeId={purchaseEvaluationId || null} selectedPeId={purchaseEvaluationId || null}
selectedPlanId={viewPlanId} selectedPlanId={viewPlanId}
onEditPlan={(id) => { setViewPlanId(id); setEditPlan(true) }} onEditPlan={(id) => { setViewPlanId(id); setEditPlan(true) }}
group={approvalGroup}
/> />
<main className="min-h-0 overflow-y-auto bg-slate-50 p-4 lg:p-6"> <main className="min-h-0 overflow-y-auto bg-slate-50 p-4 lg:p-6">
@ -220,44 +219,35 @@ export function KhkkCreatePage() {
const readOnly = !editPlan || !canEdit const readOnly = !editPlan || !canEdit
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className={cn( {/* 🧊 [S175 — anh: "bỏ mấy dòng này đi"] Dải banner "Đang xem kế hoạch…" ĐÃ GỠ.
'flex flex-wrap items-center justify-between gap-2 rounded-lg border px-4 py-2', Nút của nó đi đâu: **Sửa** → bút chì ở danh sách (đường duy nhất, đúng như
readOnly ? 'border-violet-200 bg-violet-50' : 'border-emerald-300 bg-emerald-50', banner cũ vẫn dặn) · **Xóa phiếu / Gửi duyệt** → THANH NÚT ĐÁY của
)}> `KhkkDetailContent`. Chỉ giữ đúng 1 dòng nhỏ để quay về form tạo — bỏ hẳn
<span className={cn('text-[12px] font-medium', readOnly ? 'text-violet-800' : 'text-emerald-800')}> thì người dùng mở 1 phiếu xong là kẹt, không có đường lùi. */}
{readOnly <div className="flex items-center justify-end gap-2">
? (editPlan && !canEdit
? 'Kế hoạch này không sửa được (đã gửi duyệt / đã duyệt, hoặc bạn không phải người soạn) — chỉ xem.'
: 'Đang xem kế hoạch — chỉ xem. Bấm bút chì ở danh sách để sửa.')
: '✎ Đang SỬA kế hoạch — thay đổi lưu thẳng vào phiếu.'}
</span>
<div className="flex items-center gap-2">
{readOnly && canEdit && ( {readOnly && canEdit && (
<Button variant="outline" size="sm" onClick={() => setEditPlan(true)}> <button
<Pencil className="mr-1.5 h-3.5 w-3.5" /> type="button"
Sửa onClick={() => setEditPlan(true)}
</Button> className="inline-flex items-center gap-1 rounded px-2 py-1 text-[11px] font-medium text-brand-700 transition hover:bg-brand-50"
>
<Pencil className="h-3 w-3" /> Sửa
</button>
)} )}
{!readOnly && ( <button
<Button variant="outline" size="sm" onClick={() => setEditPlan(false)}> type="button"
Xong onClick={() => { setViewPlanId(null); setEditPlan(false) }}
</Button> 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"
)} >
<Button variant="outline" size="sm" onClick={() => navigate(`/khkk/${viewPlanId}`)}> <X className="h-3 w-3" /> Về form tạo
Mở trang đy đ </button>
</Button>
<Button variant="ghost" size="sm" onClick={() => { setViewPlanId(null); setEditPlan(false) }}>
<X className="mr-1.5 h-3.5 w-3.5" />
Về form tạo
</Button>
</div>
</div> </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>
)} )}
{viewPlan.data && ( {viewPlan.data && (
<KhkkDetailContent plan={viewPlan.data} readOnly={readOnly} onChanged={() => viewPlan.refetch()} /> <KhkkDetailContent plan={viewPlan.data} readOnly={readOnly} onChanged={() => viewPlan.refetch()} onDeleted={() => { setViewPlanId(null); setEditPlan(false) }} />
)} )}
</div> </div>
) )

View File

@ -170,7 +170,7 @@ export function KhkkDetailPage() {
<KhkkWorkflowPanel plan={k} onChanged={invalidate} onDeleted={() => navigate(backToList)} /> <KhkkWorkflowPanel plan={k} onChanged={invalidate} onDeleted={() => navigate(backToList)} />
</div> </div>
<KhkkDetailContent plan={k} readOnly={readOnly} onChanged={invalidate} /> <KhkkDetailContent plan={k} readOnly={readOnly} onChanged={invalidate} onDeleted={() => navigate("/khkk/list")} />
{bridgeOpen && <CreateContractFromKhkkDialog plan={k} onClose={() => setBridgeOpen(false)} />} {bridgeOpen && <CreateContractFromKhkkDialog plan={k} onClose={() => setBridgeOpen(false)} />}
</div> </div>

View File

@ -27,9 +27,8 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { FileCheck, Plus, X } from 'lucide-react' import { FileCheck, X } from 'lucide-react'
import { EmptyState } from '@/components/EmptyState' import { EmptyState } from '@/components/EmptyState'
import { Button } from '@/components/ui/Button'
import { KhkkListPanel } from '@/components/khkk/KhkkListPanel' import { KhkkListPanel } from '@/components/khkk/KhkkListPanel'
import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent' import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
import { KhkkWorkflowPanel } from '@/components/khkk/KhkkWorkflowPanel' import { KhkkWorkflowPanel } from '@/components/khkk/KhkkWorkflowPanel'
@ -162,12 +161,10 @@ export function KhkkListPage() {
Tên nhóm đã có trong `headerTitle` ngay bên trái (dạng ngắn "— N2"), và dải 4 giai Tên nhóm đã có trong `headerTitle` ngay bên trái (dạng ngắn "— N2"), và dải 4 giai
đoạn ở panel giữa đã chỉ rõ đang đứng ở GĐ2 ⇒ chip này là lần kể thứ ba. */} đoạn ở panel giữa đã chỉ rõ đang đứng ở GĐ2 ⇒ chip này là lần kể thứ ba. */}
</div> </div>
{!deletedView && ( {/* [S175 - anh: "Bo het di"] Nut "+ Lap ke hoach" o header DA GO. Duong lap ke
<Button onClick={() => navigate(group !== null ? `/khkk/create?group=${group}` : '/khkk/create')}> hoach di tu man Thao tac (bam phieu PE o cot trai = nap thang vao form tao) —
<Plus className="mr-2 h-4 w-4" /> do moi la duong dung nghiep-vu; nut o day de nguoi dung mo form TRONG khong
Lập kế hoạch gan PE nao. */}
</Button>
)}
</header> </header>
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[400px_1fr_360px]"> <div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[400px_1fr_360px]">
@ -239,7 +236,7 @@ export function KhkkListPage() {
<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>
)} )}
{selectedId && plan && ( {selectedId && plan && (
<KhkkDetailContent plan={plan} readOnly={readOnly} onChanged={() => detail.refetch()} /> <KhkkDetailContent plan={plan} readOnly={readOnly} onChanged={() => detail.refetch()} onDeleted={() => setId(null)} />
)} )}
</main> </main>

View File

@ -174,6 +174,9 @@ export interface KhkkListItemDto {
purchaseEvaluationId: string purchaseEvaluationId: string
peMaPhieu: string | null peMaPhieu: string | null
peTenGoiThau: string | null // [F-5 S161] BE trả `PeTenGoiThau` (List :39 + Detail :124) — KHÔNG phải tenGoiThau peTenGoiThau: string | null // [F-5 S161] BE trả `PeTenGoiThau` (List :39 + Detail :124) — KHÔNG phải tenGoiThau
// [S175] Hang muc CONG VIEC cua du an, tu phieu Duyet NCC nguon.
// KHAC `KhkkLineDto.tenHangMuc` (danh-muc SP-002 phan loai hop dong) - 2 truc khac nhau.
peWorkItemName: string | null
projectId: string projectId: string
projectName: string | null projectName: string | null
departmentId: string | null departmentId: string | null

View File

@ -21,7 +21,7 @@
import { useState, type ReactNode } from 'react' import { useState, type ReactNode } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { import {
ClipboardList, Download, Eye, FileCheck, Lock, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users, ClipboardList, Download, Eye, FileCheck, Lock, Paperclip, Pencil, Plus, Trash2, Upload, Users,
} from 'lucide-react' } from 'lucide-react'
// [S175] Dùng LẠI dialog xem-trước của PE (đã có prop `basePath` từ đợt B) thay vì dựng bản // [S175] Dùng LẠI dialog xem-trước của PE (đã có prop `basePath` từ đợt B) thay vì dựng bản
// thứ hai cho KHKK — 2 bản = 2 sự thật, sửa 1 chỗ quên chỗ kia. // thứ hai cho KHKK — 2 bản = 2 sự thật, sửa 1 chỗ quên chỗ kia.
@ -40,7 +40,7 @@ import {
DOSSIER_KIND_LABELS, DOSSIER_STATUS_BADGE, DOSSIER_STATUS_LABELS, DOSSIER_KIND_LABELS, DOSSIER_STATUS_BADGE, DOSSIER_STATUS_LABELS,
DossierItemKind, DossierItemStatus, DossierItemKind, DossierItemStatus,
KHKK_APPROVAL_GROUP_LABELS, KHKK_PHASE_BADGE, KHKK_PHASE_LABELS, KHKK_APPROVAL_GROUP_LABELS, KHKK_PHASE_BADGE, KHKK_PHASE_LABELS,
KhkkAttachmentPurpose, KhkkPhase, KhkkAttachmentPurpose, KhkkPhase, KhkkTransitionAction, type KhkkTransitionInput,
type DossierItemKindValue, type DossierItemStatusValue, type KhkkAttachmentDto, type DossierItemKindValue, type DossierItemStatusValue, type KhkkAttachmentDto,
type KhkkDetailDto, type KhkkDossierItemDto, type KhkkPhaseValue, type KhkkDetailDto, type KhkkDossierItemDto, type KhkkPhaseValue,
type UpsertKhkkDossierItemInput, type UpsertKhkkDossierItemInput,
@ -149,17 +149,17 @@ export function KhkkDetailContent({
plan, plan,
readOnly, readOnly,
onChanged, onChanged,
onDeleted,
}: { }: {
plan: KhkkDetailDto plan: KhkkDetailDto
/** [P2-7] Host quyết: `true` khi phiếu ≠ Nháp/Trả lại HOẶC user ≠ người soạn. */ /** [P2-7] Host quyết: `true` khi phiếu ≠ Nháp/Trả lại HOẶC user ≠ người soạn. */
readOnly: boolean readOnly: boolean
onChanged?: () => void onChanged?: () => void
/** [S175] Gọi sau khi XÓA phiếu ở thanh nút đáy — host bỏ chọn / quay về danh sách. */
onDeleted?: () => void
}) { }) {
const qc = useQueryClient() const qc = useQueryClient()
const id = plan.id const id = plan.id
const [editHeader, setEditHeader] = useState(false)
const [ghiChu, setGhiChu] = useState('')
const [hoSoLink, setHoSoLink] = useState('')
const [itemForm, setItemForm] = useState<UpsertKhkkDossierItemInput | null>(null) const [itemForm, setItemForm] = useState<UpsertKhkkDossierItemInput | null>(null)
// [S175] File đang xem-trước inline trong khối "File đính kèm" đã gộp. // [S175] File đang xem-trước inline trong khối "File đính kèm" đã gộp.
const [previewAtt, setPreviewAtt] = useState<KhkkAttachmentDto | null>(null) const [previewAtt, setPreviewAtt] = useState<KhkkAttachmentDto | null>(null)
@ -180,16 +180,6 @@ export function KhkkDetailContent({
onChanged?.() onChanged?.()
} }
const saveHeader = useMutation({
mutationFn: async () => {
await api.put(`/contract-signing-plans/${id}`, {
ghiChu: ghiChu.trim() || null,
hoSoLink: hoSoLink.trim() || null,
})
},
onSuccess: () => { toast.success('Đã lưu'); setEditHeader(false); invalidate() },
onError: (e) => toast.error(getErrorMessage(e)),
})
// Danh mục hạng mục SP-002 — chỉ tải khi phiếu còn sửa được (tránh 1 request thừa). // Danh mục hạng mục SP-002 — chỉ tải khi phiếu còn sửa được (tránh 1 request thừa).
const catalogOptions = useQuery({ const catalogOptions = useQuery({
@ -234,6 +224,26 @@ export function KhkkDetailContent({
onError: (e) => toast.error(getErrorMessage(e)), onError: (e) => toast.error(getErrorMessage(e)),
}) })
// [S175 — anh: "phía dưới thêm các nút thao tác"] 2 mutation cho THANH NÚT ĐÁY.
// 🔴 CỐ Ý chỉ ôm nhánh **Submit** — nhánh Approve/Return có máy phức tạp (ô-tích
// level-finalize + upload file đính kèm SAU khi transition thành công) và nó ở LẠI
// `KhkkWorkflowPanel`. Nhân bản nhánh đó sang đây là đẻ sự-thật thứ hai cho đường DUYỆT;
// nhánh Submit thì chỉ có đúng `action` + `comment: null` nên bê là an toàn.
const submitPlan = useMutation({
mutationFn: async () => {
const body: KhkkTransitionInput = { action: KhkkTransitionAction.Submit, comment: null }
return api.post(`/contract-signing-plans/${id}/transitions`, body)
},
onSuccess: () => { toast.success('Đã gửi duyệt'); invalidate(); onChanged?.() },
onError: (e) => toast.error(getErrorMessage(e)),
})
const deletePlan = useMutation({
mutationFn: async () => api.delete(`/contract-signing-plans/${id}`),
onSuccess: () => { toast.success('Đã xóa phiếu'); onDeleted?.() },
onError: (e) => toast.error(getErrorMessage(e)),
})
const upsertItem = useMutation({ const upsertItem = useMutation({
mutationFn: async (body: UpsertKhkkDossierItemInput) => { mutationFn: async (body: UpsertKhkkDossierItemInput) => {
if (!body.name.trim()) throw new Error('Vui lòng nhập tên căn cứ') if (!body.name.trim()) throw new Error('Vui lòng nhập tên căn cứ')
@ -306,11 +316,6 @@ export function KhkkDetailContent({
} }
} }
const startEditHeader = () => {
setGhiChu(plan.ghiChu ?? '')
setHoSoLink(plan.hoSoLink ?? '')
setEditHeader(true)
}
// File "đính kèm khi duyệt" thuộc panel 3 (mục 5) — Section 4 chỉ liệt kê hồ-sơ của phiếu. // File "đính kèm khi duyệt" thuộc panel 3 (mục 5) — Section 4 chỉ liệt kê hồ-sơ của phiếu.
// [S175 · O4] Chia 2 rổ: file GẮN VÀO một căn cứ hiện ngay dưới dòng căn cứ đó; Section 4 // [S175 · O4] Chia 2 rổ: file GẮN VÀO một căn cứ hiện ngay dưới dòng căn cứ đó; Section 4
@ -403,6 +408,11 @@ export function KhkkDetailContent({
<div className="divide-y divide-slate-200"> <div className="divide-y divide-slate-200">
{/* ─────────────── 1 ─────────────── */} {/* ─────────────── 1 ─────────────── */}
{/* 🧊 [S175 — anh: "Bỏ hết đi"] Nút "Sửa" ĐÃ GỠ khỏi `actions`. Nó chỉ mở form 2 trường
**Link hồ sơ + Ghi chú** — mà chính 2 trường đó anh cũng bảo bỏ khỏi màn ở cùng lượt.
Giữ nút mở form sửa một thứ không còn hiển thị ở đâu là mâu thuẫn.
🔸 Máy KHÔNG mất: `hoSoLink`/`ghiChu` vẫn nằm trong DTO + BE
(`UpdateContractSigningPlanDraftCommand`), muốn bày lại chỉ là thêm chỗ vẽ. */}
<Section <Section
title="1. Thông tin kế hoạch" title="1. Thông tin kế hoạch"
icon={<FileCheck className="h-4 w-4" />} icon={<FileCheck className="h-4 w-4" />}
@ -410,13 +420,6 @@ export function KhkkDetailContent({
head="text-brand-800" head="text-brand-800"
chipBg="var(--color-brand-50)" chipBg="var(--color-brand-50)"
chipFg="var(--color-brand-600)" chipFg="var(--color-brand-600)"
actions={
isDraft && !editHeader ? (
<Button variant="outline" size="sm" onClick={startEditHeader}>
<Pencil className="mr-2 h-3.5 w-3.5" /> Sửa
</Button>
) : undefined
}
> >
{/* [S175 — anh chốt trên ảnh khoanh đỏ] Mục 1 RÚT GỌN còn 4 ô: {/* [S175 — anh chốt trên ảnh khoanh đỏ] Mục 1 RÚT GỌN còn 4 ô:
· "Bỏ hết đi" → GỠ **Phòng ban · Quy trình · Hạn xử lý** · "Bỏ hết đi" → GỠ **Phòng ban · Quy trình · Hạn xử lý**
@ -428,68 +431,25 @@ export function KhkkDetailContent({
<div className="grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2"> <div className="grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2">
<Field label="Mã kế hoạch" value={plan.maKeHoach ?? '—'} mono /> <Field label="Mã kế hoạch" value={plan.maKeHoach ?? '—'} mono />
<Field label="Dự án" value={plan.projectName ?? '—'} /> <Field label="Dự án" value={plan.projectName ?? '—'} />
{/* 1 phiếu nay được N hạng mục (S175) ⇒ liệt kê DUY-NHẤT, giữ thứ tự dòng. {/* [S175 — anh: "hạng mục của Dự án, Duyệt NCC ấy"] Đây là HẠNG MỤC CÔNG VIỆC của
Dòng chưa gán KHÔNG bịa tên — đếm riêng để người soạn biết còn nợ mấy dòng, dự án, lấy từ phiếu Duyệt NCC nguồn (`PE.WorkItemId`).
vì submit-guard sẽ chặn trình duyệt khi còn dòng chưa gán. */} 🔴 KHÁC hẳn `line.tenHangMuc` — cái đó là danh-mục SP-002 dùng phân loại HỢP
<Field label="Hạng mục" full> ĐỒNG (86 dòng, chọn ở mục 2). Hai thứ trùng tên tiếng Việt nhưng khác trục;
{(() => { lượt trước lead bày nhầm cái SP-002 ở đây nên anh bảo "Bỏ đi". */}
const names = [...new Set(plan.lines.map(l => l.tenHangMuc).filter(Boolean))] as string[] <Field label="Hạng mục (dự án)" value={plan.peWorkItemName ?? '—'} />
const unassigned = plan.lines.filter(l => !l.catalogEntryId).length
if (names.length === 0 && unassigned === 0) return DASH
return (
<span className="flex flex-wrap items-center gap-1">
{names.map(n => (
<span key={n} className="inline-flex items-center rounded bg-brand-50 px-1.5 py-0.5 text-[11px] font-medium text-brand-700">
{n}
</span>
))}
{unassigned > 0 && (
<span className="inline-flex items-center rounded bg-amber-100 px-1.5 py-0.5 text-[11px] font-medium text-amber-800">
còn {unassigned} dòng chưa gán
</span>
)}
</span>
)
})()}
</Field>
<Field label="Người soạn" value={plan.drafterFullName ?? '—'} /> <Field label="Người soạn" value={plan.drafterFullName ?? '—'} />
<Field label="Ngày tạo" value={formatDateTime(plan.createdAt)} /> <Field label="Ngày tạo" value={formatDateTime(plan.createdAt)} />
</div> </div>
{editHeader ? ( {/* 🧊 [S175 — anh: "Bỏ đi"] Khối **Link hồ sơ + Ghi chú** (cả ô XEM lẫn form SỬA)
<div className="mt-4 space-y-3 rounded-lg border border-slate-200 bg-slate-50/60 p-4"> ĐÃ GỠ. Lượt trước lead mới gỡ ở lưới trên nên 2 ô này VẪN HIỆN ở khối dưới —
<div> anh chụp lại đúng chỗ đó. Nay gỡ trọn cả 2 nhánh của ternary.
<Label htmlFor="hoSoLink">Link hồ (NAS)</Label> 🔸 Máy KHÔNG mất: hoSoLink / ghiChu còn nguyên trong DTO + BE. */}
<Input id="hoSoLink" value={hoSoLink} onChange={(e) => setHoSoLink(e.target.value)} maxLength={1000} placeholder="\\\\nas\\du-an\\..." />
</div>
<div>
<Label htmlFor="ghiChu">Ghi chú</Label>
<Textarea id="ghiChu" value={ghiChu} onChange={(e) => setGhiChu(e.target.value)} maxLength={2000} rows={4} />
<div className="mt-1 text-xs text-muted-foreground">{ghiChu.length}/2000</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => setEditHeader(false)}>Huỷ</Button>
<Button size="sm" onClick={() => saveHeader.mutate()} disabled={saveHeader.isPending}>
<Save className="mr-2 h-3.5 w-3.5" />
{saveHeader.isPending ? 'Đang lưu...' : 'Lưu'}
</Button>
</div>
</div>
) : (
<div className="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2">
<Field label="Link hồ sơ" full>
{plan.hoSoLink ? <span className="break-all font-normal text-slate-700">{plan.hoSoLink}</span> : <span className="text-slate-300"></span>}
</Field>
<Field label="Ghi chú" full>
{plan.ghiChu ? <span className="whitespace-pre-wrap font-normal text-slate-700">{plan.ghiChu}</span> : <span className="text-slate-300"></span>}
</Field>
</div>
)}
</Section> </Section>
{/* ─────────────── 2 ─────────────── */} {/* ─────────────── 2 ─────────────── */}
<Section <Section
title={`2. Giá đề xuất theo NCC trúng thầu (${plan.lines.length} dòng)`} title={`2. Hạng mục (${plan.lines.length} dòng)`}
icon={<Users className="h-4 w-4" />} icon={<Users className="h-4 w-4" />}
accent="var(--color-teal-500)" accent="var(--color-teal-500)"
head="text-teal-700" head="text-teal-700"
@ -520,34 +480,20 @@ export function KhkkDetailContent({
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full min-w-[880px] table-fixed text-xs"> <table className="w-full min-w-[880px] table-fixed text-xs">
<colgroup> <colgroup>
<col className="w-[22%]" /> <col className="w-[52%]" />
<col className="w-[22%]" /> <col className="w-[32%]" />
<col className="w-[14%]" /> <col className="w-[16%]" />
<col className="w-[14%]" />
<col className="w-[14%]" />
<col className="w-[14%]" />
</colgroup> </colgroup>
<thead className="border-b border-slate-200 bg-slate-50/70"> <thead className="border-b border-slate-200 bg-slate-50/70">
<tr> <tr>
<th className="label-eyebrow px-3 py-2 text-left">Nhà cung cấp</th>
<th className="label-eyebrow px-3 py-2 text-left">Hạng mục</th> <th className="label-eyebrow px-3 py-2 text-left">Hạng mục</th>
<th className="label-eyebrow px-3 py-2 text-right">Giá tham chiếu</th> <th className="label-eyebrow px-3 py-2 text-right">Giá</th>
<th className="label-eyebrow px-3 py-2 text-right">Giá đ xuất</th> <th className="label-eyebrow px-3 py-2 text-right"></th>
<th className="label-eyebrow px-3 py-2 text-right">Giá duyệt</th>
<th className="label-eyebrow px-3 py-2 text-left">Ghi chú</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{plan.lines.map((l) => ( {plan.lines.map((l) => (
<tr key={l.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/60"> <tr key={l.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/60">
<td className="px-3 py-2 font-medium text-brand-800">
{orDash(l.supplierName)}
{l.contractId && (
<a href={`/contracts/${l.contractId}`} className="ml-2 text-[10px] font-medium text-brand-600 hover:underline" title="Dòng này đã được đưa vào hợp đồng">
</a>
)}
</td>
<td className="px-3 py-2 text-slate-700"> <td className="px-3 py-2 text-slate-700">
{isDraft && !l.contractId ? ( {isDraft && !l.contractId ? (
<> <>
@ -555,12 +501,10 @@ export function KhkkDetailContent({
value={l.catalogEntryId ?? ''} value={l.catalogEntryId ?? ''}
onChange={(e) => e.target.value && assignLine.mutate({ lineId: l.id, catalogEntryId: e.target.value })} onChange={(e) => e.target.value && assignLine.mutate({ lineId: l.id, catalogEntryId: e.target.value })}
disabled={assignLine.isPending} disabled={assignLine.isPending}
aria-label={`n hạng mục SP-002 cho dòng ${l.supplierName ?? l.id}`} aria-label={`Chọn hạng mục cho dòng ${l.id}`}
className="h-8 w-full min-w-[12rem] 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" 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> <option value=""> Chọn hạng mục </option>
{/* Hạng mục ĐÃ LƯU mà không có trong options ⇒ chèn option đại diện,
không thì dòng đã gán TRÔNG như chưa gán. */}
{l.catalogEntryId && !(catalogOptions.data ?? []).some((c) => c.id === l.catalogEntryId) && ( {l.catalogEntryId && !(catalogOptions.data ?? []).some((c) => c.id === l.catalogEntryId) && (
<option value={l.catalogEntryId}>{l.tenHangMuc ?? '(hạng mục đã gán)'}</option> <option value={l.catalogEntryId}>{l.tenHangMuc ?? '(hạng mục đã gán)'}</option>
)} )}
@ -568,30 +512,30 @@ export function KhkkDetailContent({
<option key={c.id} value={c.id}>{c.code} {c.tenVi}</option> <option key={c.id} value={c.id}>{c.code} {c.tenVi}</option>
))} ))}
</select> </select>
{catalogOptions.isError && ( {/* Ghi chú xuống DÒNG PHỤ thay vì chiếm 1 cột — anh chốt bảng chỉ
<div className="mt-1 text-[11px] text-red-600"> còn "hạng mục + giá", nhưng ghi chú thì anh vẫn muốn nhập được. */}
Không tải đưc danh mục hạng mục {' '} <input
<button type="button" className="underline" onClick={() => catalogOptions.refetch()}>thử lại</button> type="text"
</div> maxLength={1000}
)} defaultValue={l.note ?? ''}
{catalogOptions.isSuccess && (catalogOptions.data?.length ?? 0) === 0 && ( placeholder="Ghi chú (tùy chọn)…"
<div className="mt-1 text-[11px] text-amber-700"> disabled={saveLineField.isPending}
Nhóm duyệt N{plan.approvalGroup} chưa hạng mục đang hiệu lực liên hệ quản trị danh mục. aria-label="Ghi chú dòng"
</div> onBlur={(e) => {
)} const v = e.target.value.trim()
if (v !== (l.note ?? '')) saveLineField.mutate({ lineId: l.id, note: v })
}}
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"
/>
</> </>
) : orDash(l.tenHangMuc)} ) : (
<>
<div>{orDash(l.tenHangMuc)}</div>
{l.note && <div className="mt-0.5 text-[11px] text-slate-500">{l.note}</div>}
</>
)}
</td> </td>
{/* Dòng THÊM TAY có `peReferenceAmount = 0` — BE cố ý KHÔNG copy số tham <td className="px-3 py-2 text-right align-top tabular-nums text-brand-800">
chiếu của NCC (đã nằm trọn ở dòng gốc), lặp lại là đếm đôi. Hiện
em-dash mờ thay vì "0 đ" để không đọc nhầm thành "tham chiếu bằng 0". */}
<td className="px-3 py-2 text-right tabular-nums text-slate-600">
{l.peReferenceAmount === 0 ? DASH : formatVnd(l.peReferenceAmount)}
</td>
{/* [S175] GIÁ ĐỀ XUẤT nhập tay — anh chốt "nhiều hạng mục → giá và ghi chú".
Lưu khi rời ô (onBlur) và chỉ khi SỐ ĐỔI THẬT, tránh bắn 1 request mỗi
lần click vào rồi click ra. */}
<td className="px-3 py-2 text-right tabular-nums text-brand-800">
{isDraft && !l.contractId ? ( {isDraft && !l.contractId ? (
<input <input
type="number" type="number"
@ -599,52 +543,46 @@ export function KhkkDetailContent({
step={1000} step={1000}
defaultValue={l.proposedAmount} defaultValue={l.proposedAmount}
disabled={saveLineField.isPending} disabled={saveLineField.isPending}
aria-label={`Giá đề xuất dòng ${l.tenHangMuc ?? l.supplierName ?? l.id}`} aria-label="Giá của hạng mục"
onBlur={(e) => { onBlur={(e) => {
const v = Number(e.target.value) const v = Number(e.target.value)
if (!Number.isFinite(v) || v < 0) { e.target.value = String(l.proposedAmount); return } if (!Number.isFinite(v) || v < 0) { e.target.value = String(l.proposedAmount); return }
if (v !== l.proposedAmount) saveLineField.mutate({ lineId: l.id, proposedAmount: v }) if (v !== l.proposedAmount) saveLineField.mutate({ lineId: l.id, proposedAmount: v })
}} }}
className="h-8 w-full min-w-[7rem] 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" 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"
/> />
) : moneyOrDash(l.proposedAmount)} ) : moneyOrDash(l.proposedAmount)}
</td> </td>
<td className="px-3 py-2 text-right tabular-nums text-emerald-700">{moneyOrDash(l.approvedAmount)}</td> <td className="px-3 py-2 text-right align-top">
<td className="px-3 py-2 text-slate-600"> {isDraft && !l.contractId && l.peReferenceAmount === 0 && (
{isDraft && !l.contractId ? (
<div className="flex items-center gap-1">
<input
type="text"
maxLength={1000}
defaultValue={l.note ?? ''}
placeholder="Ghi chú…"
disabled={saveLineField.isPending}
aria-label={`Ghi chú dòng ${l.tenHangMuc ?? l.supplierName ?? l.id}`}
onBlur={(e) => {
const v = e.target.value.trim()
if (v !== (l.note ?? '')) saveLineField.mutate({ lineId: l.id, note: v })
}}
className="h-8 w-full min-w-[6rem] 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"
/>
{/* Chỉ dòng THÊM TAY mới xoá được. Dòng gốc sinh từ phiếu Duyệt NCC
mang snapshot tham chiếu — BE cũng chặn, đây chỉ là không mời bấm. */}
{l.peReferenceAmount === 0 && (
<button <button
type="button" type="button"
onClick={() => { if (window.confirm(`Xoá hạng mục "${l.tenHangMuc ?? '(chưa gán)'}"?`)) deleteLine.mutate(l.id) }} onClick={() => { if (window.confirm(`Xoá hạng mục "${l.tenHangMuc ?? '(chưa gán)'}"?`)) deleteLine.mutate(l.id) }}
disabled={deleteLine.isPending} disabled={deleteLine.isPending}
title="Xoá hạng mục này" title="Xoá hạng mục này"
className="shrink-0 rounded px-1 py-1 text-red-500 hover:bg-red-50" className="rounded px-1 py-1 text-red-500 hover:bg-red-50"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</button> </button>
)} )}
</div>
) : orDash(l.note)}
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
{/* [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
sánh j với số duyệt PRO hay CCM j cả". Dữ-liệu 2 cột đó VẪN nằm trong DTO
(`peReferenceAmount` / `approvedAmount`), chỉ bỏ chỗ vẽ; `peReferenceAmount`
còn được dùng làm DẤU phân biệt dòng-gốc ⟂ dòng-thêm-tay ở nút xoá trên. */}
<tfoot>
<tr className="border-t-2 border-slate-300 bg-slate-50">
<td className="px-3 py-2 text-right text-xs font-semibold uppercase tracking-wide text-slate-600">Tổng</td>
<td className="px-3 py-2 text-right text-sm font-bold tabular-nums text-brand-800">
{formatVnd(plan.lines.reduce((s, l) => s + (l.proposedAmount ?? 0), 0))}
</td>
<td />
</tr>
</tfoot>
</table> </table>
</div> </div>
@ -939,6 +877,62 @@ export function KhkkDetailContent({
</div> </div>
)} )}
</Dialog> </Dialog>
{/* [S175 — anh: "phía dưới thêm các nút thao tác"] THANH NÚT ĐÁY, khuôn PE workspace.
Gom về ĐÂY những nút anh bảo gỡ khỏi chỗ khác: "Gửi duyệt" + "Xóa phiếu" (trước ở
khối HÀNH ĐỘNG panel 3). Panel 3 từ nay chỉ còn **Duyệt / Trả lại** — đúng câu anh
chốt "chỗ menu đang duyệt → Duyệt/Trả lại vậy thôi".
🔴 `sticky bottom-0`: phiếu dài, nút nằm cuối trang thì phải cuộn hết mới thấy —
dính đáy là để lúc nào cũng với tới được.
🔸 KHÔNG có nút "Lưu" rời: mọi ô ở màn này lưu NGAY khi rời ô (hạng mục / giá / ghi
chú / file). Thêm một nút "Lưu" không làm gì là hứa hão — nói thẳng bằng dòng chữ
bên cạnh thay vì vẽ nút giả. */}
{!readOnly && (
<div className="sticky bottom-0 z-10 flex flex-wrap items-center gap-3 border-t border-slate-200 bg-white/95 px-5 py-3 backdrop-blur">
<button
type="button"
onClick={() => {
if (window.confirm(`Xóa phiếu ${plan.maKeHoach ?? ''}? Phiếu chuyển sang mục "Đã xóa".`)) deletePlan.mutate()
}}
disabled={deletePlan.isPending}
className="inline-flex items-center gap-1.5 rounded-md bg-red-600 px-3 py-2 text-xs font-semibold text-white transition hover:bg-red-700 disabled:opacity-50"
>
<Trash2 className="h-3.5 w-3.5" />
{deletePlan.isPending ? 'Đang xóa…' : 'Xóa phiếu'}
</button>
<span className="min-w-0 flex-1 text-[12px] text-emerald-700">
Các thay đi đã tự đng lưu khi chỉnh sửa từng phần.
</span>
{(() => {
// Rào HIỂN THỊ mirror submit-guard BE: chưa pin quy trình, hoặc còn dòng chưa gán
// hạng mục ⇒ BE sẽ chặn. Nói TRƯỚC lý do thay vì để bấm rồi ăn 409.
const unassigned = plan.lines.filter(l => !l.catalogEntryId).length
const noWorkflow = !plan.approvalWorkflowId
const blocked = noWorkflow || unassigned > 0 || plan.lines.length === 0
const why = noWorkflow ? 'Chưa pin quy trình duyệt cho phiếu.'
: plan.lines.length === 0 ? 'Phiếu chưa có dòng NCC nào.'
: unassigned > 0 ? `Còn ${unassigned} dòng chưa gán hạng mục.`
: undefined
return (
<button
type="button"
onClick={() => { if (!blocked) submitPlan.mutate() }}
disabled={blocked || submitPlan.isPending}
title={why ?? 'Gửi phiếu vào quy trình duyệt'}
className={cn(
'inline-flex items-center gap-1.5 rounded-md px-3 py-2 text-xs font-semibold text-white transition',
blocked ? 'cursor-not-allowed bg-slate-300' : 'bg-brand-600 hover:bg-brand-700',
submitPlan.isPending && 'opacity-50',
)}
>
{submitPlan.isPending ? 'Đang gửi…' : 'Lưu & Gửi duyệt →'}
</button>
)
})()}
</div>
)}
</div> </div>
) )
} }

View File

@ -81,6 +81,7 @@ export function KhkkWorkListPanel({
selectedPeId, selectedPeId,
selectedPlanId, selectedPlanId,
onEditPlan, onEditPlan,
group,
}: { }: {
/** Bấm PE ở tầng trên → host nạp phiếu đó vào form tạo (Ở LẠI màn, không điều hướng). */ /** Bấm PE ở tầng trên → host nạp phiếu đó vào form tạo (Ở LẠI màn, không điều hướng). */
onPickPe: (peId: string) => void onPickPe: (peId: string) => void
@ -92,6 +93,10 @@ export function KhkkWorkListPanel({
/** [S175 · O7] Bấm bút chì trên card KHKK → host mở phiếu ở chế-độ SỬA. Không truyền ⇒ /** [S175 · O7] Bấm bút chì trên card KHKK → host mở phiếu ở chế-độ SỬA. Không truyền ⇒
* không vẽ bút chì (cột giữ đúng hình cũ). */ * không vẽ bút chì (cột giữ đúng hình cũ). */
onEditPlan?: (planId: string) => void onEditPlan?: (planId: string) => void
/** [S175 - anh: "Loc theo quy trinh luon"] Nhom duyet cua leaf menu dang dung.
* Tang duoi CHI hien ke hoach thuoc dung nhom nay - dung o leaf N2 ma thay phieu
* N1/N7 la doc nham "nhom nay dang co viec". `null` = khong loc. */
group?: number | null
}) { }) {
// [S175 — anh: "Cho hiển thị theo cây nằm dự án hạng mục"] Tập KHOÁ đang GẬP. // [S175 — anh: "Cho hiển thị theo cây nằm dự án hạng mục"] Tập KHOÁ đang GẬP.
// Dùng tập-gập (không phải tập-mở) để mặc-định là MỞ HẾT: dự án/hạng mục mới xuất hiện sau // Dùng tập-gập (không phải tập-mở) để mặc-định là MỞ HẾT: dự án/hạng mục mới xuất hiện sau
@ -156,7 +161,10 @@ export function KhkkWorkListPanel({
// (2) cửa-sổ 30 phiếu mới nhất có thể toàn phiếu đã duyệt ⇒ tầng dưới rỗng dù còn nháp // (2) cửa-sổ 30 phiếu mới nhất có thể toàn phiếu đã duyệt ⇒ tầng dưới rỗng dù còn nháp
// cũ hơn. Chấp nhận ở UAT; muốn chuẩn thì BE nhận `phases[]` rồi lọc SERVER-SIDE. // cũ hơn. Chấp nhận ở UAT; muốn chuẩn thì BE nhận `phases[]` rồi lọc SERVER-SIDE.
const OPEN_PHASES: number[] = [KhkkPhase.DangSoanThao, KhkkPhase.TraLai, KhkkPhase.TuChoi] const OPEN_PHASES: number[] = [KhkkPhase.DangSoanThao, KhkkPhase.TraLai, KhkkPhase.TuChoi]
const planItems = (plans.data?.items ?? []).filter((k) => OPEN_PHASES.includes(k.phase)) const planItems = (plans.data?.items ?? [])
.filter((k) => OPEN_PHASES.includes(k.phase))
// [S175] +loc NHOM. `group == null` => khong loc (giu duong cu cho host khong truyen).
.filter((k) => group == null || k.approvalGroup === group)
return ( return (
<aside className="flex min-h-0 flex-col overflow-hidden border-r border-slate-200 bg-white"> <aside className="flex min-h-0 flex-col overflow-hidden border-r border-slate-200 bg-white">

View File

@ -33,7 +33,7 @@
import { useMemo, useRef, useState } from 'react' import { useMemo, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Download, Eye, History, Paperclip, Send, Trash2 } from 'lucide-react' import { Download, Eye, History, Paperclip } from 'lucide-react'
import { AttachmentPreviewDialog, isPreviewable } from '@/components/pe/AttachmentPreviewDialog' import { AttachmentPreviewDialog, isPreviewable } from '@/components/pe/AttachmentPreviewDialog'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog' import { Dialog } from '@/components/ui/Dialog'
@ -114,8 +114,8 @@ export function KhkkWorkflowPanel({
const isWaiting = phase === KhkkPhase.ChoDuyet const isWaiting = phase === KhkkPhase.ChoDuyet
const isApproved = phase === KhkkPhase.DaDuyet const isApproved = phase === KhkkPhase.DaDuyet
const isDraftLike = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TraLai // 🧊 [S175] `isDraftLike` / `canSubmit` / `canDeletePlan` ĐÃ GỠ cùng 2 nút "Gửi duyệt" +
const canDeletePlan = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TuChoi // "Xóa phiếu" — panel này nay chỉ lo màn ĐANG DUYỆT (Duyệt / Trả lại).
const curStepIdx = plan.currentWorkflowStepIndex const curStepIdx = plan.currentWorkflowStepIndex
const curLevelOrder = plan.currentApprovalLevelOrder const curLevelOrder = plan.currentApprovalLevelOrder
@ -172,7 +172,6 @@ export function KhkkWorkflowPanel({
// Nút "Gửi duyệt": guard THẬT ở BE (`CreatedBy DeptManager Admin`). FE KHÔNG suy được // Nút "Gửi duyệt": guard THẬT ở BE (`CreatedBy DeptManager Admin`). FE KHÔNG suy được
// vế DeptManager ⇒ CỐ Ý không ẩn nút theo vai (ẩn = giấu nút với đúng người có quyền, im // vế DeptManager ⇒ CỐ Ý không ẩn nút theo vai (ẩn = giấu nút với đúng người có quyền, im
// lặng — bug-class gotcha #44). Chỉ chặn ca chắc-chắn-hỏng: chưa pin quy trình. // lặng — bug-class gotcha #44). Chỉ chặn ca chắc-chắn-hỏng: chưa pin quy trình.
const canSubmit = isDraftLike && !!plan.approvalWorkflowId
const approvalFiles: KhkkAttachmentDto[] = const approvalFiles: KhkkAttachmentDto[] =
(plan.attachments ?? []).filter(a => a.purpose === KhkkAttachmentPurpose.ApprovalAttachment) (plan.attachments ?? []).filter(a => a.purpose === KhkkAttachmentPurpose.ApprovalAttachment)
@ -434,24 +433,15 @@ export function KhkkWorkflowPanel({
)} )}
{/* ── 4. Hành động + nút Xóa ─────────────────────────────────────────── */} {/* ── 4. Hành động + nút Xóa ─────────────────────────────────────────── */}
{(canSubmit || isWaiting || canDeletePlan) && ( {/* 🧊 [S175 — anh: "Bỏ hết đi" + "chỗ menu đang duyệt → Duyệt/Trả lại vậy thôi"]
"Gửi duyệt" và "Xóa phiếu" ĐÃ RỜI khỏi đây → nay nằm ở THANH NÚT ĐÁY của
`KhkkDetailContent`. Panel này từ nay CHỈ còn **Duyệt / Trả lại** — đúng phạm vi
"màn đang duyệt". Máy `KhkkTransitionAction.Submit` vẫn nguyên ở BE và ở thanh đáy;
gỡ ở đây chỉ là gỡ ĐƯỜNG BẤM TRÙNG, không phải bỏ chức năng. */}
{isWaiting && (
<div> <div>
<Label className="text-xs">Hành đng:</Label> <Label className="text-xs">Hành đng:</Label>
<div className="mt-1 flex flex-wrap gap-1.5"> <div className="mt-1 flex flex-wrap gap-1.5">
{canSubmit && (
<button
type="button"
onClick={() => { setComment(''); setPendingFiles([]); setAction(KhkkTransitionAction.Submit) }}
className="inline-flex items-center gap-1 rounded border border-brand-300 px-2 py-1 text-[11px] font-bold text-brand-700 transition hover:bg-brand-50"
>
<Send className="h-3 w-3" /> Gửi duyệt
</button>
)}
{isDraftLike && !plan.approvalWorkflowId && (
<span className="rounded border border-dashed border-slate-300 px-2 py-1 text-[11px] text-slate-500">
Chưa pin quy trình duyệt không gửi duyệt đưc.
</span>
)}
{isWaiting && ( {isWaiting && (
<> <>
<button <button
@ -494,16 +484,6 @@ export function KhkkWorkflowPanel({
và mở lại sau này chỉ là thêm nút. Gỡ cả máy = mất dữ-liệu lịch sử. */} và mở lại sau này chỉ là thêm nút. Gỡ cả máy = mất dữ-liệu lịch sử. */}
</> </>
)} )}
{canDeletePlan && (
<button
type="button"
onClick={() => setConfirmDelete(true)}
title="Xóa phiếu này (phiếu chuyển sang mục &ldquo;Đã xóa&rdquo;)"
className="inline-flex items-center gap-1 rounded border border-red-300 bg-white px-2 py-1 text-[11px] font-bold text-red-700 transition hover:bg-red-50"
>
<Trash2 className="h-3 w-3" /> Xóa phiếu
</button>
)}
</div> </div>
</div> </div>
)} )}

View File

@ -28,7 +28,7 @@
// tái-hiện vá-5 (đi từ leaf nhóm 2 sang leaf nhóm 5 phải F5 mới đúng). ĐỪNG ĐỘNG. // tái-hiện vá-5 (đi từ leaf nhóm 2 sang leaf nhóm 5 phải F5 mới đúng). ĐỪNG ĐỘNG.
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 { useNavigate, useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { FileCheck, Pencil, X } from 'lucide-react' import { FileCheck, Pencil, X } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { EmptyState } from '@/components/EmptyState' import { EmptyState } from '@/components/EmptyState'
@ -39,7 +39,6 @@ import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
import { KhkkWorkListPanel } from '@/components/khkk/KhkkWorkListPanel' import { KhkkWorkListPanel } from '@/components/khkk/KhkkWorkListPanel'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError' import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import { useAuth } from '@/contexts/AuthContext' import { useAuth } from '@/contexts/AuthContext'
import { import {
KHKK_APPROVAL_GROUP_DEFAULT, KHKK_APPROVAL_GROUP_DEFAULT,
@ -67,7 +66,6 @@ interface WorkflowDto {
// chỉnh"). Giữ lại thành hàm chết thì lượt sau có người gọi lại. // chỉnh"). Giữ lại thành hàm chết thì lượt sau có người gọi lại.
export function KhkkCreatePage() { export function KhkkCreatePage() {
const navigate = useNavigate()
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const groupParam = searchParams.get('group') const groupParam = searchParams.get('group')
@ -203,6 +201,7 @@ export function KhkkCreatePage() {
selectedPeId={purchaseEvaluationId || null} selectedPeId={purchaseEvaluationId || null}
selectedPlanId={viewPlanId} selectedPlanId={viewPlanId}
onEditPlan={(id) => { setViewPlanId(id); setEditPlan(true) }} onEditPlan={(id) => { setViewPlanId(id); setEditPlan(true) }}
group={approvalGroup}
/> />
<main className="min-h-0 overflow-y-auto bg-slate-50 p-4 lg:p-6"> <main className="min-h-0 overflow-y-auto bg-slate-50 p-4 lg:p-6">
@ -220,44 +219,35 @@ export function KhkkCreatePage() {
const readOnly = !editPlan || !canEdit const readOnly = !editPlan || !canEdit
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className={cn( {/* 🧊 [S175 — anh: "bỏ mấy dòng này đi"] Dải banner "Đang xem kế hoạch…" ĐÃ GỠ.
'flex flex-wrap items-center justify-between gap-2 rounded-lg border px-4 py-2', Nút của nó đi đâu: **Sửa** → bút chì ở danh sách (đường duy nhất, đúng như
readOnly ? 'border-violet-200 bg-violet-50' : 'border-emerald-300 bg-emerald-50', banner cũ vẫn dặn) · **Xóa phiếu / Gửi duyệt** → THANH NÚT ĐÁY của
)}> `KhkkDetailContent`. Chỉ giữ đúng 1 dòng nhỏ để quay về form tạo — bỏ hẳn
<span className={cn('text-[12px] font-medium', readOnly ? 'text-violet-800' : 'text-emerald-800')}> thì người dùng mở 1 phiếu xong là kẹt, không có đường lùi. */}
{readOnly <div className="flex items-center justify-end gap-2">
? (editPlan && !canEdit
? 'Kế hoạch này không sửa được (đã gửi duyệt / đã duyệt, hoặc bạn không phải người soạn) — chỉ xem.'
: 'Đang xem kế hoạch — chỉ xem. Bấm bút chì ở danh sách để sửa.')
: '✎ Đang SỬA kế hoạch — thay đổi lưu thẳng vào phiếu.'}
</span>
<div className="flex items-center gap-2">
{readOnly && canEdit && ( {readOnly && canEdit && (
<Button variant="outline" size="sm" onClick={() => setEditPlan(true)}> <button
<Pencil className="mr-1.5 h-3.5 w-3.5" /> type="button"
Sửa onClick={() => setEditPlan(true)}
</Button> className="inline-flex items-center gap-1 rounded px-2 py-1 text-[11px] font-medium text-brand-700 transition hover:bg-brand-50"
>
<Pencil className="h-3 w-3" /> Sửa
</button>
)} )}
{!readOnly && ( <button
<Button variant="outline" size="sm" onClick={() => setEditPlan(false)}> type="button"
Xong onClick={() => { setViewPlanId(null); setEditPlan(false) }}
</Button> 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"
)} >
<Button variant="outline" size="sm" onClick={() => navigate(`/khkk/${viewPlanId}`)}> <X className="h-3 w-3" /> Về form tạo
Mở trang đy đ </button>
</Button>
<Button variant="ghost" size="sm" onClick={() => { setViewPlanId(null); setEditPlan(false) }}>
<X className="mr-1.5 h-3.5 w-3.5" />
Về form tạo
</Button>
</div>
</div> </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>
)} )}
{viewPlan.data && ( {viewPlan.data && (
<KhkkDetailContent plan={viewPlan.data} readOnly={readOnly} onChanged={() => viewPlan.refetch()} /> <KhkkDetailContent plan={viewPlan.data} readOnly={readOnly} onChanged={() => viewPlan.refetch()} onDeleted={() => { setViewPlanId(null); setEditPlan(false) }} />
)} )}
</div> </div>
) )

View File

@ -170,7 +170,7 @@ export function KhkkDetailPage() {
<KhkkWorkflowPanel plan={k} onChanged={invalidate} onDeleted={() => navigate(backToList)} /> <KhkkWorkflowPanel plan={k} onChanged={invalidate} onDeleted={() => navigate(backToList)} />
</div> </div>
<KhkkDetailContent plan={k} readOnly={readOnly} onChanged={invalidate} /> <KhkkDetailContent plan={k} readOnly={readOnly} onChanged={invalidate} onDeleted={() => navigate("/khkk/list")} />
{bridgeOpen && <CreateContractFromKhkkDialog plan={k} onClose={() => setBridgeOpen(false)} />} {bridgeOpen && <CreateContractFromKhkkDialog plan={k} onClose={() => setBridgeOpen(false)} />}
</div> </div>

View File

@ -27,9 +27,8 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { FileCheck, Plus, X } from 'lucide-react' import { FileCheck, X } from 'lucide-react'
import { EmptyState } from '@/components/EmptyState' import { EmptyState } from '@/components/EmptyState'
import { Button } from '@/components/ui/Button'
import { KhkkListPanel } from '@/components/khkk/KhkkListPanel' import { KhkkListPanel } from '@/components/khkk/KhkkListPanel'
import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent' import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
import { KhkkWorkflowPanel } from '@/components/khkk/KhkkWorkflowPanel' import { KhkkWorkflowPanel } from '@/components/khkk/KhkkWorkflowPanel'
@ -162,12 +161,10 @@ export function KhkkListPage() {
Tên nhóm đã có trong `headerTitle` ngay bên trái (dạng ngắn "— N2"), và dải 4 giai Tên nhóm đã có trong `headerTitle` ngay bên trái (dạng ngắn "— N2"), và dải 4 giai
đoạn ở panel giữa đã chỉ rõ đang đứng ở GĐ2 ⇒ chip này là lần kể thứ ba. */} đoạn ở panel giữa đã chỉ rõ đang đứng ở GĐ2 ⇒ chip này là lần kể thứ ba. */}
</div> </div>
{!deletedView && ( {/* [S175 - anh: "Bo het di"] Nut "+ Lap ke hoach" o header DA GO. Duong lap ke
<Button onClick={() => navigate(group !== null ? `/khkk/create?group=${group}` : '/khkk/create')}> hoach di tu man Thao tac (bam phieu PE o cot trai = nap thang vao form tao) —
<Plus className="mr-2 h-4 w-4" /> do moi la duong dung nghiep-vu; nut o day de nguoi dung mo form TRONG khong
Lập kế hoạch gan PE nao. */}
</Button>
)}
</header> </header>
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[400px_1fr_360px]"> <div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[400px_1fr_360px]">
@ -239,7 +236,7 @@ export function KhkkListPage() {
<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>
)} )}
{selectedId && plan && ( {selectedId && plan && (
<KhkkDetailContent plan={plan} readOnly={readOnly} onChanged={() => detail.refetch()} /> <KhkkDetailContent plan={plan} readOnly={readOnly} onChanged={() => detail.refetch()} onDeleted={() => setId(null)} />
)} )}
</main> </main>

View File

@ -174,6 +174,9 @@ export interface KhkkListItemDto {
purchaseEvaluationId: string purchaseEvaluationId: string
peMaPhieu: string | null peMaPhieu: string | null
peTenGoiThau: string | null // [F-5 S161] BE trả `PeTenGoiThau` (List :39 + Detail :124) — KHÔNG phải tenGoiThau peTenGoiThau: string | null // [F-5 S161] BE trả `PeTenGoiThau` (List :39 + Detail :124) — KHÔNG phải tenGoiThau
// [S175] Hang muc CONG VIEC cua du an, tu phieu Duyet NCC nguon.
// KHAC `KhkkLineDto.tenHangMuc` (danh-muc SP-002 phan loai hop dong) - 2 truc khac nhau.
peWorkItemName: string | null
projectId: string projectId: string
projectName: string | null projectName: string | null
departmentId: string | null departmentId: string | null

View File

@ -173,6 +173,9 @@ public record ContractSigningPlanDetailDto(
Guid PurchaseEvaluationId, Guid PurchaseEvaluationId,
string? PeMaPhieu, string? PeMaPhieu,
string? PeTenGoiThau, string? PeTenGoiThau,
/// [S175] Hang muc CONG VIEC cua du an, lay tu phieu Duyet NCC nguon (PE.WorkItemId).
/// KHONG phai Line.TenHangMuc (do la danh-muc SP-002 phan loai hop dong).
string? PeWorkItemName,
Guid ProjectId, Guid ProjectId,
string? ProjectName, string? ProjectName,
string? ProjectCode, string? ProjectCode,
@ -911,10 +914,21 @@ public class GetContractSigningPlanQueryHandler(IApplicationDbContext db, ICurre
// [W2 S161 — F-8] rào xem mirror PE (list bịt thì detail phải bịt cùng vị ngữ). // [W2 S161 — F-8] rào xem mirror PE (list bịt thì detail phải bịt cùng vị ngữ).
await ContractSigningPlanScope.EnsureCanViewAsync(db, currentUser, plan, ct); await ContractSigningPlanScope.EnsureCanViewAsync(db, currentUser, plan, ct);
// [S175] +`WorkItemId` để lấy HẠNG MỤC CÔNG VIỆC CỦA DỰ ÁN — anh: "hạng mục của Dự án,
// Duyệt NCC ấy". 🔴 KHÁC hẳn `TenHangMuc` của Line: cái đó là hạng mục danh-mục SP-002
// (86 dòng, dùng để phân loại HỢP ĐỒNG); cái này là gói việc của dự án trên phiếu PE.
// Trùng tên tiếng Việt nhưng hai trục khác nhau — trộn là chỉ sai gói thầu.
var pe = await db.PurchaseEvaluations.AsNoTracking() var pe = await db.PurchaseEvaluations.AsNoTracking()
.Where(p => p.Id == plan.PurchaseEvaluationId) .Where(p => p.Id == plan.PurchaseEvaluationId)
.Select(p => new { p.MaPhieu, p.TenGoiThau }) .Select(p => new { p.MaPhieu, p.TenGoiThau, p.WorkItemId })
.FirstOrDefaultAsync(ct); .FirstOrDefaultAsync(ct);
// `WorkItemId` là loose-Guid (Mig 49 — KHÔNG FK vật lý) ⇒ join tay, null-safe.
var peWorkItemName = pe != null && pe.WorkItemId != null
? await db.WorkItems.AsNoTracking()
.Where(w => w.Id == pe.WorkItemId)
.Select(w => w.Name)
.FirstOrDefaultAsync(ct)
: null;
var project = await db.Projects.AsNoTracking() var project = await db.Projects.AsNoTracking()
.Where(p => p.Id == plan.ProjectId) .Where(p => p.Id == plan.ProjectId)
.Select(p => new { p.Name, p.Code }) .Select(p => new { p.Name, p.Code })
@ -1115,6 +1129,7 @@ public class GetContractSigningPlanQueryHandler(IApplicationDbContext db, ICurre
return new ContractSigningPlanDetailDto( return new ContractSigningPlanDetailDto(
plan.Id, plan.MaKeHoach, plan.PurchaseEvaluationId, pe?.MaPhieu, pe?.TenGoiThau, plan.Id, plan.MaKeHoach, plan.PurchaseEvaluationId, pe?.MaPhieu, pe?.TenGoiThau,
peWorkItemName,
plan.ProjectId, project?.Name, project?.Code, plan.ProjectId, project?.Name, project?.Code,
plan.DepartmentId, deptName, plan.DrafterUserId, drafterName, plan.DepartmentId, deptName, plan.DrafterUserId, drafterName,
plan.Phase, plan.ApprovalWorkflowId, wfCode, wfName, plan.Phase, plan.ApprovalWorkflowId, wfCode, wfName,