[CLAUDE] Contract: UAT S174 dot-D + C3 — O6/O4/O7/O8 + 2 tong toan phieu (FE-only, 0-mig)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m24s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m24s
Owner S175 chot cach doc "sum tong" (gate TIEN): (1) truc = CHI TONG TOAN PHIEU, KHONG co
dong tong per-NCC; (2) hien 2 TONG RIENG canh nhau — Sigma gia DE XUAT ⟂ Sigma gia DUYET,
dat PHIA TREN bang. Mirror 9 CAP file sha256 khop 9/9; build XANH ca 2 app, 0 error TS.
=== O6 — bam card PE tren cay GIU NGUYEN man KHKK ===
+callback `onOpenPe` (usePipelineStages) mirror DOI XUNG `onOpenKhkk` cua leaf GD2.
KHONG truyen => giu `navigate('/purchase-evaluations?id=..&expand=1')` => 5 host cay con lai
0 doi hanh vi (da verify: chi KhkkListPage truyen).
Host: `?pe=<id>` render `PeDetailTabs readOnly mode="workspace"` ngay panel 2 — DUNG LAI
renderer that cua man PE thay vi dung ban rut gon thu hai (2 ban = 2 su that).
🔴 `pe` va `id` LOAI TRU NHAU (2 setter cung xoa key kia): panel 2 chi ve duoc mot thu, de
ca hai thi lan chon sau khong xoa duoc lan truoc.
`<lg` roi ve duong cu (khong co panel 2 thi xem-tai-cho la bat kha).
Panel 3 doi chu khi dang xem PE — cau cu "se hien khi chon phieu" la SAI (phieu DA chon roi).
=== O4 — shopdrawing / can cu ho so cho dinh kem file (FE-only) ===
🔴 0 BE MOI — rail da du: controller `[FromForm] Guid? dossierItemId` (:181) + DTO
`KhkkAttachmentDto.dossierItemId` (:241) co tu Mig 69. FE truoc day don gian la KHONG BAO GIO
gui truong do len => moi file roi ve muc PHIEU. Nay upload gui kem dossierItemId.
Chia 2 ro: file gan can cu hien ngay DUOI dong can cu do; Section 4 chi con file muc phieu —
khong chia thi file hien o CA HAI cho, nguoi dung dem 2 lan.
Dong-con file hien CA KHI read-only (nguoi duyet can xem shopdrawing, chi khong them/xoa).
=== O7 — nut edit tren card KHKK (man Thao tac) ===
But chi khuon `PeListPanel:208-232`. LUON hien roi MO DI khi khong sua duoc: nut chi-hien-khi-
hover thi tren may cam-ung khong ai tim ra; an han khi phieu khoa thi nguoi dung tuong chuc
nang khong ton tai thay vi hieu la phieu da di duyet.
🔴 Nut NGOAI the <button> cua card — long button trong button la HTML khong hop le.
Host: but chi => mo che-do SUA; than card => chi xem nhu cu. Guard readOnly Y HET
`KhkkListPage:147-150` (cung mot cau hoi "ai duoc sua" phai cho cung mot cau tra loi o moi man).
Da verify khong phai co-hoc: `KhkkDetailContent:161` `isDraft = draftPhase && !readOnly`.
=== O8 — cay 2 cap Du an > Hang muc ===
🔴 0 ENDPOINT MOI: `workItemName` DA co san trong `KhkkPeCandidateDto:423` => cap 2 la gom lai
tren du-lieu dang co. Truoc day 1 cap theo du an nen mot du an nhieu goi thau do ra danh sach
phang dai, khong thay ranh gioi hang muc.
🔴 2 muc `z` khac nhau (z-20 cha / z-10 con): cung z thi header hang muc truot DE len header
du an khi cuon, mat cap cha.
=== C3 — Field: value ve slate-900, label giu brand-600 ===
Truoc day MOI value deu to brand => mau thuong-hieu mat het suc nang: ca trang deu la mau nhan
thi khong con gi la nhan, va khong chua duoc mau cho thu THAT SU can noi (NCC trung thau,
canh bao vuot ngan sach). PE giu slate cho value dung vi ly do nay.
=== O3 — MOI LAND VE (2). VE (1) CHUA LAM — xem CON NO ===
🔴 KHONG cong gop 2 cot thanh mot so "hien hanh": de-xuat va duyet la 2 so co y nghia phap-ly
khac nhau (mot cai chua ai chot, mot cai da qua choke-point `ApplyApprovedValuesOnFinalize`);
tron lai la bia ra mot so khong ton tai trong ho-so nao.
Tong duyet chi cong phan DA co, va noi ro con bao nhieu dong chua chot.
=== 🔴 CON NO — O3 ve (1) "hang muc cho chon NHIEU" KHONG PHAI FE-ONLY ===
Spec ghi O3 = "N-ROW 0-migration". "0-migration" DUNG (unique 3 cot PlanId+SupplierId+
CatalogEntryId co tu Mig 71) NHUNG KHONG co nghia la 0 BE. Do that:
· KHONG co endpoint TAO line. Chi co `PUT /{id}/lines/{lineId}` = gan catalogEntryId cho
dong DA CO (Controller:121). Danh sach route day du: 0 route POST/DELETE line.
· Line duoc auto-sinh 1-dong-moi-NCC-trung-thau luc TAO PHIEU (`Features.cs:508-527`),
tien moi = tong bao gia DUOC CHON cua NCC do.
=> Muon 1 NCC co N hang muc phai TACH tien cua NCC do ra N phan — va KHONG CO quy tac nao
trong he thong noi chia the nao. Do la cau hoi TIEN, chua ai tra loi.
🔸 Tin tot: BE cau HD da nhan N dong san (`CreateContractFromSigningPlanFeatures:101-115`
nhan `LineIds` list, chi rao "moi HD 1 NCC"). Cai hep la FE dialog dung radio single-select
(`KhkkDetailPage:310`) — do la UI, doi duoc.
=== CON NO KHAC ===
🔴 CHUA soi mat tren prod 375/1366/1440 (5 lane NO-SCREENSHOT, gotcha #3) — moi ket luan la
DOC MA, khong phai da nhin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@ -18,7 +18,7 @@
|
||||
// Không dựng workspace riêng cho việc này.
|
||||
// [R-8] KHKK KHÔNG có phiếu V1-legacy (module sinh 2026-07-29) ⇒ KHÔNG port nhánh fallback
|
||||
// V1 của PE (`PeDetailTabs.tsx:434-436`) — đó là code chết ngay ngày đầu.
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { Fragment, useState, type ReactNode } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
ClipboardList, FileCheck, Lock, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
|
||||
@ -123,7 +123,11 @@ function Field({
|
||||
return (
|
||||
<div className={cn('min-w-0 text-sm', full && 'sm:col-span-2')}>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-brand-600">{label}</div>
|
||||
<div className={cn('mt-0.5 break-words text-sm', empty && !children ? 'text-slate-300' : 'font-medium text-brand-800', mono && !empty && 'font-mono')}>
|
||||
{/* [S175 · C3] VALUE về `text-slate-900`, LABEL giữ `text-brand-600`. Trước đây MỌI
|
||||
value đều tô brand ⇒ màu thương-hiệu mất hết sức nặng: khi cả trang đều là màu nhấn
|
||||
thì không còn gì là nhấn, và không chừa được màu cho thứ THẬT SỰ cần nổi (NCC trúng
|
||||
thầu, cảnh báo vượt ngân sách). PE giữ slate cho value đúng vì lý do này. */}
|
||||
<div className={cn('mt-0.5 break-words text-sm', empty && !children ? 'text-slate-300' : 'font-medium text-slate-900', mono && !empty && 'font-mono')}>
|
||||
{children ?? (empty ? '—' : value)}
|
||||
</div>
|
||||
</div>
|
||||
@ -218,11 +222,16 @@ export function KhkkDetailContent({
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
// [S175 · O4] +`dossierItemId`: gắn file vào ĐÚNG một căn cứ hồ sơ (shopdrawing, mẫu vật
|
||||
// liệu…) thay vì đổ chung một rổ. 🔴 0 BE MỚI — rail `dossierItemId` đã đủ 6 tầng từ Mig 69
|
||||
// (`KhkkAttachmentDto:241` ⟵ `POST /attachments` multipart `dossierItemId?`); trước đây FE
|
||||
// đơn giản là KHÔNG BAO GIỜ gửi trường đó lên, nên mọi file rơi về mức phiếu.
|
||||
const upload = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
mutationFn: async ({ file, dossierItemId }: { file: File; dossierItemId?: string }) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
fd.append('purpose', String(KhkkAttachmentPurpose.DossierScan))
|
||||
if (dossierItemId) fd.append('dossierItemId', dossierItemId)
|
||||
return api.post(`/contract-signing-plans/${id}/attachments`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
@ -253,11 +262,11 @@ export function KhkkDetailContent({
|
||||
}
|
||||
}
|
||||
|
||||
async function onPickFiles(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
async function onPickFiles(e: React.ChangeEvent<HTMLInputElement>, dossierItemId?: string) {
|
||||
const files = Array.from(e.target.files ?? [])
|
||||
e.target.value = ''
|
||||
for (const f of files) {
|
||||
try { await upload.mutateAsync(f) } catch { /* lỗi đã toast ở onError */ }
|
||||
try { await upload.mutateAsync({ file: f, dossierItemId }) } catch { /* lỗi đã toast ở onError */ }
|
||||
}
|
||||
}
|
||||
|
||||
@ -268,7 +277,21 @@ export function KhkkDetailContent({
|
||||
}
|
||||
|
||||
// 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.
|
||||
const planAttachments = plan.attachments.filter(a => a.purpose !== KhkkAttachmentPurpose.ApprovalAttachment)
|
||||
// [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
|
||||
// chỉ còn file mức PHIẾU. 🔴 Không chia thì file gắn căn cứ hiện ở CẢ HAI chỗ — người dùng
|
||||
// đếm 2 lần rồi tưởng đã tải nhầm lên hai bận.
|
||||
const dossierAttachments = new Map<string, KhkkAttachmentDto[]>()
|
||||
const planAttachments: KhkkAttachmentDto[] = []
|
||||
for (const a of plan.attachments) {
|
||||
if (a.purpose === KhkkAttachmentPurpose.ApprovalAttachment) continue
|
||||
if (a.dossierItemId) {
|
||||
const bucket = dossierAttachments.get(a.dossierItemId)
|
||||
if (bucket) bucket.push(a)
|
||||
else dossierAttachments.set(a.dossierItemId, [a])
|
||||
} else {
|
||||
planAttachments.push(a)
|
||||
}
|
||||
}
|
||||
|
||||
// 🧊 [S174 owner] Section 5 "Ý kiến cấp duyệt" ĐÃ GỠ khỏi panel giữa — anh: "bỏ luôn cái
|
||||
// này ra luôn". Sơ-đồ Bước/Cấp + chữ ký đã sống ở PANEL 3 (`KhkkWorkflowPanel`), để cả hai
|
||||
@ -389,10 +412,54 @@ export function KhkkDetailContent({
|
||||
{plan.lines.length === 0 ? (
|
||||
<div className="text-sm italic text-slate-400">Phiếu nguồn chưa có NCC trúng thầu.</div>
|
||||
) : (
|
||||
// [S175 · B2 · P0] `table-fixed` + `<colgroup>` + `min-w-[880px]`: bảng co giãn tự-do
|
||||
// trước đây để cột tiền nhảy bề rộng theo độ dài tên NCC ⇒ 3 cột số không thẳng hàng
|
||||
// giữa các phiếu. Chốt bề rộng rồi thì hẹp màn hình sẽ CUỘN NGANG (đúng ý đồ của
|
||||
// `overflow-x-auto` bọc ngoài) thay vì bóp chữ.
|
||||
<>
|
||||
{/* [S175 · O3 — ANH CHỐT] TỔNG đặt PHÍA TRÊN bảng, đúng 2 con số:
|
||||
① trục = CHỈ TỔNG TOÀN PHIẾU (anh bác phương án tổng-mỗi-NCC);
|
||||
② hiện 2 TỔNG RIÊNG cạnh nhau — Σ giá ĐỀ XUẤT ⟂ Σ giá DUYỆT.
|
||||
🔴 KHÔNG cộng gộp 2 cột thành một số "hiện hành": đề-xuất và duyệt là hai
|
||||
con số có ý nghĩa pháp-lý khác nhau (một cái chưa ai chốt, một cái đã qua
|
||||
choke-point `ApplyApprovedValuesOnFinalize`); trộn lại là bịa ra một số
|
||||
không tồn tại trong hồ-sơ nào.
|
||||
🔸 Dòng chưa có giá duyệt ⇒ `null`, `moneyOrDash` in em-dash mờ; tổng duyệt
|
||||
chỉ cộng phần ĐÃ có, và nói rõ còn bao nhiêu dòng chưa chốt. */}
|
||||
{(() => {
|
||||
const sumProposed = plan.lines.reduce((s, l) => s + (l.proposedAmount ?? 0), 0)
|
||||
const approvedLines = plan.lines.filter(l => l.approvedAmount != null)
|
||||
const sumApproved = approvedLines.reduce((s, l) => s + (l.approvedAmount ?? 0), 0)
|
||||
const pending = plan.lines.length - approvedLines.length
|
||||
return (
|
||||
<div className="mb-3 flex flex-wrap items-stretch gap-2">
|
||||
<div className="min-w-[13rem] flex-1 rounded-md border border-slate-200 bg-white px-3 py-2">
|
||||
<div className="label-eyebrow">Tổng giá đề xuất</div>
|
||||
<div className="mt-0.5 text-base font-semibold tabular-nums text-brand-800">
|
||||
{formatVnd(sumProposed)}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-500">{plan.lines.length} dòng</div>
|
||||
</div>
|
||||
<div className={cn(
|
||||
'min-w-[13rem] flex-1 rounded-md border px-3 py-2',
|
||||
pending === 0 ? 'border-emerald-300 bg-emerald-50' : 'border-slate-200 bg-white',
|
||||
)}>
|
||||
<div className="label-eyebrow">Tổng giá duyệt</div>
|
||||
<div className={cn(
|
||||
'mt-0.5 text-base font-semibold tabular-nums',
|
||||
pending === 0 ? 'text-emerald-700' : 'text-slate-400',
|
||||
)}>
|
||||
{approvedLines.length === 0 ? '—' : formatVnd(sumApproved)}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-500">
|
||||
{pending === 0
|
||||
? 'đã chốt đủ mọi dòng'
|
||||
: `còn ${pending}/${plan.lines.length} dòng chưa chốt giá`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{/* [S175 · B2 · P0] `table-fixed` + `<colgroup>` + `min-w-[880px]`: bảng co giãn
|
||||
tự-do trước đây để cột tiền nhảy bề rộng theo độ dài tên NCC ⇒ 3 cột số không
|
||||
thẳng hàng giữa các phiếu. Chốt bề rộng rồi thì hẹp màn hình sẽ CUỘN NGANG
|
||||
(đúng ý đồ của `overflow-x-auto` bọc ngoài) thay vì bóp chữ. */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[880px] table-fixed text-xs">
|
||||
<colgroup>
|
||||
@ -467,6 +534,7 @@ export function KhkkDetailContent({
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="mt-3 text-xs text-muted-foreground">
|
||||
Giá tham chiếu là SNAPSHOT tổng báo giá được chọn của NCC trên phiếu Duyệt NCC tại thời điểm lập kế hoạch.
|
||||
@ -515,8 +583,12 @@ export function KhkkDetailContent({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{plan.dossierItems.map((d: KhkkDossierItemDto) => (
|
||||
<tr key={d.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/60">
|
||||
{plan.dossierItems.map((d: KhkkDossierItemDto) => {
|
||||
// [S175 · O4] File gắn ĐÚNG căn cứ này (shopdrawing, mẫu vật liệu…).
|
||||
const files = dossierAttachments.get(d.id) ?? []
|
||||
return (
|
||||
<Fragment key={d.id}>
|
||||
<tr className={cn('border-slate-100 hover:bg-slate-50/60', files.length === 0 && 'border-b last:border-0')}>
|
||||
<td className="px-3 py-2 text-slate-600">{orDash(DOSSIER_KIND_LABELS[d.kind])}</td>
|
||||
<td className="px-3 py-2 font-medium text-brand-800">{d.name}</td>
|
||||
<td className="px-3 py-2">
|
||||
@ -532,6 +604,21 @@ export function KhkkDetailContent({
|
||||
{isDraft && (
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
{/* [S175 · O4] Đính kèm cho ĐÚNG căn cứ này. `<label>` bọc input
|
||||
ẩn thay vì `<button>`+ref: mỗi dòng cần một ô chọn file riêng,
|
||||
dùng ref thì phải nuôi N ref cho N dòng. */}
|
||||
<label
|
||||
title="Đính kèm file cho căn cứ này (shopdrawing, mẫu vật liệu…)"
|
||||
className="cursor-pointer rounded px-1.5 py-1 text-teal-600 hover:bg-teal-50"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => onPickFiles(e, d.id)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setItemForm({
|
||||
@ -555,7 +642,46 @@ export function KhkkDetailContent({
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{/* [S175 · O4] Dòng-con liệt kê file của căn cứ. Hiện CẢ KHI read-only —
|
||||
người duyệt cần xem được shopdrawing, chỉ không được thêm/xoá. */}
|
||||
{files.length > 0 && (
|
||||
<tr className="border-b border-slate-100 last:border-0">
|
||||
<td colSpan={isDraft ? 6 : 5} className="bg-slate-50/40 px-3 pb-2">
|
||||
<div className="flex flex-wrap gap-1.5 pl-3">
|
||||
{files.map((a) => (
|
||||
<span
|
||||
key={a.id}
|
||||
className="inline-flex items-center gap-1 rounded border border-slate-200 bg-white px-1.5 py-0.5 text-[11px]"
|
||||
>
|
||||
<Paperclip className="h-3 w-3 shrink-0 text-teal-500" aria-hidden />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => download(a)}
|
||||
title={`Tải xuống ${a.fileName} (${formatSize(a.fileSize)})`}
|
||||
className="max-w-[16rem] truncate text-slate-700 hover:text-brand-700 hover:underline"
|
||||
>
|
||||
{a.fileName}
|
||||
</button>
|
||||
<span className="shrink-0 text-[10px] text-slate-400">{formatSize(a.fileSize)}</span>
|
||||
{isDraft && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (window.confirm(`Xóa file "${a.fileName}"?`)) deleteAttachment.mutate(a.id) }}
|
||||
title="Xóa file"
|
||||
className="shrink-0 rounded text-red-500 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@ -178,6 +178,7 @@ export function KhkkListPanel({
|
||||
page,
|
||||
onPageChange,
|
||||
selectedFinalize,
|
||||
onOpenPe,
|
||||
}: {
|
||||
selectedId: string | null
|
||||
onSelect: (id: string) => void
|
||||
@ -193,6 +194,8 @@ export function KhkkListPanel({
|
||||
page: number
|
||||
onPageChange: (p: number) => void
|
||||
selectedFinalize?: { endedByLevelFinalize: boolean; finalizeStepName: string | null }
|
||||
/** [S175 · O6] Bấm card phiếu Duyệt NCC trên cây ⇒ host xem TẠI CHỖ (không rời màn). */
|
||||
onOpenPe?: (id: string) => void
|
||||
}) {
|
||||
const { canPe } = usePipelineStages()
|
||||
const term = search.trim()
|
||||
@ -316,7 +319,7 @@ export function KhkkListPanel({
|
||||
hideHeader
|
||||
hideSearch
|
||||
bodyMaxHClass="max-h-[28rem] lg:max-h-none"
|
||||
stage2Options={{ stage2Content, stage2Count, onOpenKhkk: onSelect }}
|
||||
stage2Options={{ stage2Content, stage2Count, onOpenKhkk: onSelect, onOpenPe }}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -24,7 +24,7 @@
|
||||
// con). Người chỉ có quyền PE mà không có KHKK sẽ ăn 403 rải khắp cột. ⇒ gate bằng ĐÚNG key
|
||||
// của endpoint, và khi thiếu quyền thì NÓI RA thay vì để cột trắng trông như "chưa có gì".
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { FileCheck, FileSignature, Lock } from 'lucide-react'
|
||||
import { FileCheck, FileSignature, FolderTree, Lock, Pencil } from 'lucide-react'
|
||||
import { usePermission } from '@/hooks/usePermission'
|
||||
import { api } from '@/lib/api'
|
||||
import { cn } from '@/lib/cn'
|
||||
@ -79,6 +79,7 @@ export function KhkkWorkListPanel({
|
||||
onPickPlan,
|
||||
selectedPeId,
|
||||
selectedPlanId,
|
||||
onEditPlan,
|
||||
}: {
|
||||
/** 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
|
||||
@ -87,6 +88,9 @@ export function KhkkWorkListPanel({
|
||||
onPickPlan: (planId: string) => void
|
||||
selectedPeId?: string | null
|
||||
selectedPlanId?: string | null
|
||||
/** [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ũ). */
|
||||
onEditPlan?: (planId: string) => void
|
||||
}) {
|
||||
const { can } = usePermission()
|
||||
// 🔴 ĐÚNG key policy của endpoint — KHÔNG dùng `Khkk_*` (đó là key MENU, tầng hiển thị).
|
||||
@ -114,15 +118,26 @@ export function KhkkWorkListPanel({
|
||||
// trong cache React Query (DTO trước K2 không có field này) — thiếu guard thì `.length` nổ.
|
||||
const awaiting = (candidates.data ?? []).filter((p) => (p.existingGroups ?? []).length === 0)
|
||||
|
||||
// Gom theo DỰ ÁN — đúng chữ "theo dự án" của anh. `Map` giữ thứ tự chèn nên dự án nào có
|
||||
// phiếu mới nhất (BE trả sẵn theo thứ tự) thì nổi lên trước.
|
||||
const byProject = new Map<string, KhkkPeCandidateDto[]>()
|
||||
// [S175 · O8] CÂY 2 CẤP **Dự án › Hạng mục** — anh chốt "cho cây thư mục dự án hạng mục
|
||||
// ra luôn nhé". Trước đây chỉ gom 1 cấp theo dự án, nên một dự án nhiều gói thầu đổ ra
|
||||
// một danh sách phẳng dài, không thấy được ranh giới hạng mục.
|
||||
// 🔴 0 ENDPOINT MỚI: `workItemName` ĐÃ có sẵn trong `KhkkPeCandidateDto` (`khkk.ts:423`)
|
||||
// ⇒ cấp 2 là gom lại trên dữ-liệu đang có, không phải gọi thêm cửa nào.
|
||||
// `Map` giữ thứ tự chèn ở CẢ HAI cấp nên dự án/hạng mục có phiếu mới nhất nổi lên trước
|
||||
// (BE đã trả sẵn theo thứ tự).
|
||||
const byProject = new Map<string, Map<string, KhkkPeCandidateDto[]>>()
|
||||
for (const pe of awaiting) {
|
||||
const key = pe.projectName ?? '(chưa gán dự án)'
|
||||
const bucket = byProject.get(key)
|
||||
const pKey = pe.projectName ?? '(chưa gán dự án)'
|
||||
const wKey = pe.workItemName ?? '(chưa gán hạng mục)'
|
||||
let byWorkItem = byProject.get(pKey)
|
||||
if (!byWorkItem) { byWorkItem = new Map(); byProject.set(pKey, byWorkItem) }
|
||||
const bucket = byWorkItem.get(wKey)
|
||||
if (bucket) bucket.push(pe)
|
||||
else byProject.set(key, [pe])
|
||||
else byWorkItem.set(wKey, [pe])
|
||||
}
|
||||
/** Đếm phiếu của cả một dự án (cộng mọi hạng mục con) — số ở header cấp 1. */
|
||||
const countProject = (byWorkItem: Map<string, KhkkPeCandidateDto[]>) =>
|
||||
[...byWorkItem.values()].reduce((s, arr) => s + arr.length, 0)
|
||||
|
||||
// [S174 owner] "chỗ thao tác chỉ hiển trả lại và nháp, từ chối thôi nhé" ⇒ DANH-SÁCH-TRẮNG
|
||||
// 3 trạng thái, KHÔNG phải loại-trừ. 🔴 Đổi từ loại-trừ sang danh-sách-trắng là CÓ CHỦ ĐÍCH:
|
||||
@ -175,43 +190,57 @@ export function KhkkWorkListPanel({
|
||||
Mọi phiếu đã duyệt đều đã có kế hoạch ký kết.
|
||||
</p>
|
||||
)}
|
||||
{[...byProject.entries()].map(([projectName, items]) => (
|
||||
{/* [S175 · O8] CẤP 1 = Dự án (sticky, `z-20`) · CẤP 2 = Hạng mục (sticky dưới nó,
|
||||
`z-10` + thụt lề + vạch trái). 🔴 Hai `z` khác nhau là BẮT BUỘC: cùng `z` thì
|
||||
header hạng mục trượt ĐÈ lên header dự án khi cuộn, mất mất cấp cha. */}
|
||||
{[...byProject.entries()].map(([projectName, byWorkItem]) => (
|
||||
<div key={projectName}>
|
||||
<div className="sticky top-0 z-10 flex items-center gap-1.5 border-b border-slate-100 bg-white/95 px-3 py-1 backdrop-blur">
|
||||
<span className="min-w-0 flex-1 truncate text-[10px] font-semibold uppercase tracking-wide text-slate-500">
|
||||
<div className="sticky top-0 z-20 flex items-center gap-1.5 border-b border-slate-200 bg-slate-50/95 px-3 py-1 backdrop-blur">
|
||||
<FolderTree className="h-3 w-3 shrink-0 text-slate-400" aria-hidden />
|
||||
<span className="min-w-0 flex-1 truncate text-[10px] font-semibold uppercase tracking-wide text-slate-600">
|
||||
{projectName}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-slate-500">{items.length}</span>
|
||||
<span className="shrink-0 text-[10px] tabular-nums text-slate-500">{countProject(byWorkItem)}</span>
|
||||
</div>
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{items.map((pe) => (
|
||||
<li key={pe.id}>
|
||||
<button
|
||||
type="button"
|
||||
// Bấm = nạp THẲNG phiếu này vào form tạo bên panel 2. Không điều hướng,
|
||||
// không mở lại màn khác — đúng "click vào cột này để tạo luôn KHKK".
|
||||
onClick={() => onPickPe(pe.id)}
|
||||
title="Lập kế hoạch ký kết cho phiếu này"
|
||||
className={cn(
|
||||
'block w-full px-3 py-2 text-left transition hover:bg-amber-50/60',
|
||||
selectedPeId === pe.id && 'bg-amber-50 ring-1 ring-inset ring-amber-300',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-[10px] text-brand-700">{pe.maPhieu ?? '—'}</span>
|
||||
<span className="text-[10px] text-slate-400">·</span>
|
||||
<span className="truncate text-[10px] text-slate-500">{formatDate(pe.createdAt)}</span>
|
||||
</div>
|
||||
<div className="truncate text-[12px] font-medium text-slate-800">{pe.tenGoiThau}</div>
|
||||
{pe.winnerSupplierNames.length > 0 && (
|
||||
<div className="mt-0.5 truncate text-[10px] text-emerald-700">
|
||||
✓ {pe.winnerSupplierNames.join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{[...byWorkItem.entries()].map(([workItemName, items]) => (
|
||||
<div key={workItemName}>
|
||||
<div className="sticky top-[1.375rem] z-10 flex items-center gap-1.5 border-b border-slate-100 bg-white/95 py-1 pl-6 pr-3 backdrop-blur">
|
||||
<span className="min-w-0 flex-1 truncate text-[10px] font-medium text-slate-500">
|
||||
{workItemName}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] tabular-nums text-slate-400">{items.length}</span>
|
||||
</div>
|
||||
<ul className="divide-y divide-slate-100 border-l-2 border-slate-100 pl-1">
|
||||
{items.map((pe) => (
|
||||
<li key={pe.id}>
|
||||
<button
|
||||
type="button"
|
||||
// Bấm = nạp THẲNG phiếu này vào form tạo bên panel 2. Không điều hướng,
|
||||
// không mở lại màn khác — đúng "click vào cột này để tạo luôn KHKK".
|
||||
onClick={() => onPickPe(pe.id)}
|
||||
title="Lập kế hoạch ký kết cho phiếu này"
|
||||
className={cn(
|
||||
'block w-full px-3 py-2 text-left transition hover:bg-amber-50/60',
|
||||
selectedPeId === pe.id && 'bg-amber-50 ring-1 ring-inset ring-amber-300',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-[10px] text-brand-700">{pe.maPhieu ?? '—'}</span>
|
||||
<span className="text-[10px] text-slate-400">·</span>
|
||||
<span className="truncate text-[10px] text-slate-500">{formatDate(pe.createdAt)}</span>
|
||||
</div>
|
||||
<div className="truncate text-[12px] font-medium text-slate-800">{pe.tenGoiThau}</div>
|
||||
{pe.winnerSupplierNames.length > 0 && (
|
||||
<div className="mt-0.5 truncate text-[10px] text-emerald-700">
|
||||
✓ {pe.winnerSupplierNames.join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</Tier>
|
||||
@ -248,7 +277,7 @@ export function KhkkWorkListPanel({
|
||||
)}
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{planItems.map((k) => (
|
||||
<li key={k.id}>
|
||||
<li key={k.id} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
// 🔴 CHỈ XEM tại chỗ — KHÔNG `navigate('/khkk/:id')`. Anh chốt: "cho hiện
|
||||
@ -258,6 +287,8 @@ export function KhkkWorkListPanel({
|
||||
className={cn(
|
||||
'block w-full px-3 py-2 text-left transition hover:bg-violet-50/60',
|
||||
selectedPlanId === k.id && 'bg-violet-50 ring-1 ring-inset ring-violet-300',
|
||||
// [S175 · O7] Chừa chỗ cho bút chì để tên gói thầu dài không chui xuống dưới nó.
|
||||
onEditPlan && 'pr-9',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
@ -280,6 +311,34 @@ export function KhkkWorkListPanel({
|
||||
{k.peTenGoiThau ?? '(chưa có tên gói thầu)'}
|
||||
</div>
|
||||
</button>
|
||||
{/* [S175 · O7] Bút chì — khuôn `PeListPanel.tsx:208-232`. LUÔN hiện (không
|
||||
chỉ khi hover) rồi MỜ ĐI khi không sửa được: nút chỉ-hiện-khi-hover thì
|
||||
trên máy cảm-ứng không ai tìm ra, còn ẩn hẳn khi phiếu khoá thì người
|
||||
dùng tưởng chức năng không tồn tại thay vì hiểu là phiếu đã đi duyệt.
|
||||
🔴 Nút NGOÀI thẻ <button> của card — lồng button trong button là HTML
|
||||
không hợp lệ, React sẽ cảnh báo và bấm sẽ kích cả hai. */}
|
||||
{onEditPlan && (() => {
|
||||
const editable = k.phase === KhkkPhase.DangSoanThao || k.phase === KhkkPhase.TraLai
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editable && onEditPlan(k.id)}
|
||||
disabled={!editable}
|
||||
title={editable
|
||||
? 'Sửa kế hoạch này'
|
||||
: 'Kế hoạch đã gửi duyệt / đã duyệt — không sửa được'}
|
||||
aria-label={editable ? `Sửa kế hoạch ${k.maKeHoach ?? ''}` : undefined}
|
||||
className={cn(
|
||||
'absolute right-2 top-2 rounded p-1.5 transition',
|
||||
editable
|
||||
? 'cursor-pointer text-violet-600 hover:bg-violet-100 hover:shadow-sm'
|
||||
: 'cursor-not-allowed text-slate-300',
|
||||
)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)
|
||||
})()}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@ -96,7 +96,9 @@ export function PipelineTreePanel({
|
||||
// [S171] Trang KHKK (GĐ2) tự vẽ thân folder GĐ2 = card phiếu của chính nó, và tự bắt
|
||||
// sự kiện mở phiếu (M-2: chọn vào panel 2 thay vì rời trang). Không truyền ⇒ cây giữ
|
||||
// NGUYÊN hành vi cũ (8 ngăn nhóm + navigate) ở trang Duyệt NCC / GĐ3 / GĐ4.
|
||||
stage2Options?: Pick<BuildStagesOptions, 'stage2Content' | 'stage2Count' | 'onOpenKhkk'>
|
||||
// [S175 · O6] +`onOpenPe`: leaf GĐ1 (phiếu Duyệt NCC) cũng cho host bắt tại chỗ, đối
|
||||
// xứng với `onOpenKhkk` của leaf GĐ2. Cả 2 đều OPT-IN nên 4 host không truyền giữ nguyên.
|
||||
stage2Options?: Pick<BuildStagesOptions, 'stage2Content' | 'stage2Count' | 'onOpenKhkk' | 'onOpenPe'>
|
||||
// [S168 GĐ3-D gate F-1] Host 3-panel (Panel-3 TỰ CUỘN) truyền class trần riêng cho THÂN
|
||||
// cây (vd `lg:max-h-none` — panel giữ đúng 1 thanh cuộn; max-h mặc định 100vh−16rem là
|
||||
// hiệu-chỉnh cho trang giấy-cuộn, vượt chỗ Panel-3 /inbox ~26px ⇒ 2 cuộn lồng nhau).
|
||||
|
||||
@ -183,6 +183,13 @@ export type BuildStagesOptions = {
|
||||
* cho mọi call-site khác — 0 hồi-quy trang Duyệt NCC / GĐ3.
|
||||
*/
|
||||
onOpenKhkk?: (id: string) => void
|
||||
/**
|
||||
* [S175 · O6] Mirror ĐỐI XỨNG của `onOpenKhkk` cho leaf GĐ1 (phiếu Duyệt NCC).
|
||||
* Anh chốt: *"bấm card PE trong cây ở màn KHKK phải GIỮ NGUYÊN màn KHKK — chỉ view là
|
||||
* được, không re-direct về phiếu gốc"*. KHÔNG truyền ⇒ giữ NGUYÊN
|
||||
* `navigate('/purchase-evaluations?id=…&expand=1')` ⇒ 5 host còn lại 0 đổi hành vi.
|
||||
*/
|
||||
onOpenPe?: (id: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@ -214,9 +221,13 @@ export function usePipelineStages() {
|
||||
secondary: p.tenGoiThau,
|
||||
badgeLabel: PeDisplayStatusLabel[display],
|
||||
badgeClass: PeDisplayStatusColor[display],
|
||||
// [S175 · O6] Host truyền callback ⇒ xem TẠI CHỖ; không truyền ⇒ navigate
|
||||
// như cũ. Ternary y khuôn leaf GĐ2 ở `:233` — 2 giai-đoạn cùng một luật.
|
||||
// `expand=1` để mở overlay chi tiết — deep-link từ trang khác
|
||||
// chạy được ở mọi bề rộng (panel giữa của trang PE ẩn dưới lg).
|
||||
onOpen: () => navigate(`/purchase-evaluations?id=${p.id}&expand=1`),
|
||||
onOpen: opts.onOpenPe
|
||||
? () => opts.onOpenPe!(p.id)
|
||||
: () => navigate(`/purchase-evaluations?id=${p.id}&expand=1`),
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
@ -29,7 +29,7 @@
|
||||
import { useState, type FormEvent, type ReactNode } from 'react'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { FileCheck, X } from 'lucide-react'
|
||||
import { FileCheck, Pencil, X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
@ -39,9 +39,12 @@ import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
|
||||
import { KhkkWorkListPanel } from '@/components/khkk/KhkkWorkListPanel'
|
||||
import { api } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import {
|
||||
KHKK_APPROVAL_GROUP_DEFAULT,
|
||||
KHKK_APPROVAL_GROUP_LABELS,
|
||||
KhkkPhase,
|
||||
khkkGroupMenuLabel,
|
||||
type CreateKhkkInput,
|
||||
type KhkkDetailDto,
|
||||
@ -81,6 +84,9 @@ export function KhkkCreatePage() {
|
||||
// [S174] Phiếu KHKK đang XEM ở panel 2. Có giá trị ⇒ panel 2 hiện phiếu (read-only) thay
|
||||
// cho form tạo. `null` ⇒ về form tạo. Đây là thứ thay cho việc `navigate('/khkk/:id')`.
|
||||
const [viewPlanId, setViewPlanId] = useState<string | null>(null)
|
||||
// [S175 · O7] Bấm bút chì = mở thẳng chế-độ SỬA; bấm thân card = chỉ xem như cũ.
|
||||
const [editPlan, setEditPlan] = useState(false)
|
||||
const { user } = useAuth()
|
||||
|
||||
// Cùng query key với `KhkkWorkListPanel` ⇒ dùng CHUNG cache, không gọi mạng 2 lần.
|
||||
const candidates = useQuery({
|
||||
@ -193,24 +199,54 @@ export function KhkkCreatePage() {
|
||||
<div className="grid flex-1 grid-cols-1 grid-rows-[minmax(0,14rem)_minmax(0,1fr)] overflow-hidden lg:grid-cols-[260px_1fr] lg:grid-rows-1 xl:grid-cols-[320px_1fr]">
|
||||
<KhkkWorkListPanel
|
||||
onPickPe={pickPe}
|
||||
onPickPlan={setViewPlanId}
|
||||
onPickPlan={(id) => { setViewPlanId(id); setEditPlan(false) }}
|
||||
selectedPeId={purchaseEvaluationId || null}
|
||||
selectedPlanId={viewPlanId}
|
||||
onEditPlan={(id) => { setViewPlanId(id); setEditPlan(true) }}
|
||||
/>
|
||||
|
||||
<main className="min-h-0 overflow-y-auto bg-slate-50 p-4 lg:p-6">
|
||||
{/* (a) Đang XEM một kế hoạch — read-only, có đường quay về form tạo. */}
|
||||
{viewPlanId && (
|
||||
{viewPlanId && (() => {
|
||||
// [S175 · O7] `readOnly` = KHÔNG bấm bút chì, HOẶC phiếu không đủ điều-kiện sửa.
|
||||
// 🔴 Guard y hệt `KhkkListPage.tsx:147-150` — cùng một câu hỏi "ai được sửa phiếu
|
||||
// này" thì phải cho cùng một câu trả lời ở mọi màn; lệch nhau là đẻ lỗ hổng đúng
|
||||
// kiểu gotcha #82 (một màn khoá, màn kia hở).
|
||||
// 🔸 Đây là rào HIỂN THỊ. Rào THẬT vẫn ở BE (`KeHoachKyKet.Update` + phase guard).
|
||||
const p = viewPlan.data
|
||||
const canEdit = !!p
|
||||
&& (p.phase === KhkkPhase.DangSoanThao || p.phase === KhkkPhase.TraLai)
|
||||
&& (!user?.id || p.drafterUserId === user.id)
|
||||
const readOnly = !editPlan || !canEdit
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-violet-200 bg-violet-50 px-4 py-2">
|
||||
<span className="text-[12px] font-medium text-violet-800">
|
||||
Đang xem kế hoạch — chỉ xem, không sửa ở màn Thao tác.
|
||||
<div className={cn(
|
||||
'flex flex-wrap items-center justify-between gap-2 rounded-lg border px-4 py-2',
|
||||
readOnly ? 'border-violet-200 bg-violet-50' : 'border-emerald-300 bg-emerald-50',
|
||||
)}>
|
||||
<span className={cn('text-[12px] font-medium', readOnly ? 'text-violet-800' : 'text-emerald-800')}>
|
||||
{readOnly
|
||||
? (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 && (
|
||||
<Button variant="outline" size="sm" onClick={() => setEditPlan(true)}>
|
||||
<Pencil className="mr-1.5 h-3.5 w-3.5" />
|
||||
Sửa
|
||||
</Button>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<Button variant="outline" size="sm" onClick={() => setEditPlan(false)}>
|
||||
Xong
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => navigate(`/khkk/${viewPlanId}`)}>
|
||||
Mở trang đầy đủ
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setViewPlanId(null)}>
|
||||
<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>
|
||||
@ -221,10 +257,11 @@ export function KhkkCreatePage() {
|
||||
<div className="text-sm text-red-600">Không tìm thấy phiếu.</div>
|
||||
)}
|
||||
{viewPlan.data && (
|
||||
<KhkkDetailContent plan={viewPlan.data} readOnly onChanged={() => viewPlan.refetch()} />
|
||||
<KhkkDetailContent plan={viewPlan.data} readOnly={readOnly} onChanged={() => viewPlan.refetch()} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* (b) Chưa chọn gì — nói rõ phải bấm ở đâu, thay vì để trống một mảng trắng. */}
|
||||
{!viewPlanId && !purchaseEvaluationId && (
|
||||
|
||||
@ -33,6 +33,10 @@ import { Button } from '@/components/ui/Button'
|
||||
import { KhkkListPanel } from '@/components/khkk/KhkkListPanel'
|
||||
import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
|
||||
import { KhkkWorkflowPanel } from '@/components/khkk/KhkkWorkflowPanel'
|
||||
// [S175 · O6] Xem phiếu Duyệt NCC tại chỗ — dùng LẠI đúng renderer của màn PE thay vì dựng
|
||||
// bản rút gọn thứ hai (2 bản = 2 sự thật, sửa 1 chỗ quên chỗ kia).
|
||||
import { PeDetailTabs } from '@/components/pe/PeDetailTabs'
|
||||
import { type PeDetailBundle } from '@/types/purchaseEvaluation'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { api } from '@/lib/api'
|
||||
import {
|
||||
@ -92,10 +96,30 @@ export function KhkkListPage() {
|
||||
enabled: !!selectedId,
|
||||
})
|
||||
|
||||
// [S175 · O6] Phiếu Duyệt NCC đang XEM TẠI CHỖ. Anh chốt: bấm card PE trên cây thì
|
||||
// GIỮ NGUYÊN màn KHKK, chỉ xem, KHÔNG re-direct về phiếu gốc.
|
||||
// 🔴 `pe` và `id` LOẠI TRỪ NHAU: panel 2 chỉ vẽ được một thứ, để cả hai thì lần chọn sau
|
||||
// không xoá được lần trước và người dùng kẹt ở phiếu cũ.
|
||||
const selectedPeId = searchParams.get('pe')
|
||||
const peDetail = useQuery({
|
||||
queryKey: ['pe-detail', selectedPeId],
|
||||
queryFn: async () => (await api.get<PeDetailBundle>(`/purchase-evaluations/${selectedPeId}`)).data,
|
||||
enabled: !!selectedPeId,
|
||||
})
|
||||
|
||||
function setId(id: string | null) {
|
||||
const next = new URLSearchParams(searchParams)
|
||||
if (id == null) next.delete('id')
|
||||
else next.set('id', id)
|
||||
next.delete('pe')
|
||||
setSearchParams(next)
|
||||
}
|
||||
|
||||
function setPeId(id: string | null) {
|
||||
const next = new URLSearchParams(searchParams)
|
||||
if (id == null) next.delete('pe')
|
||||
else next.set('pe', id)
|
||||
next.delete('id')
|
||||
setSearchParams(next)
|
||||
}
|
||||
|
||||
@ -108,6 +132,16 @@ export function KhkkListPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function selectPe(id: string) {
|
||||
// Cùng luật breakpoint với `selectPlan`: `<lg` KHÔNG có panel 2 nên xem tại chỗ là bất
|
||||
// khả — rơi về đúng đường cũ (trang Duyệt NCC + overlay) thay vì bấm mà không thấy gì.
|
||||
if (typeof window !== 'undefined' && window.matchMedia('(min-width: 1024px)').matches) {
|
||||
setPeId(id)
|
||||
} else {
|
||||
navigate(`/purchase-evaluations?id=${id}&expand=1`)
|
||||
}
|
||||
}
|
||||
|
||||
const plan = detail.data
|
||||
// [P2-7] `readOnly` do HOST quyết — KHÔNG cần dựng workspace riêng để có bản chỉ-xem.
|
||||
const readOnly =
|
||||
@ -155,12 +189,13 @@ export function KhkkListPage() {
|
||||
? { endedByLevelFinalize: plan.endedByLevelFinalize, finalizeStepName: plan.finalizeStepName }
|
||||
: undefined
|
||||
}
|
||||
onOpenPe={selectPe}
|
||||
/>
|
||||
|
||||
{/* Panel 2 — nội dung phiếu. [F-1] EmptyState là trạng-thái ĐẦU TIÊN người dùng thấy;
|
||||
thiếu nó thì host 3-panel mở ra là một mảng trắng ở giữa. */}
|
||||
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
|
||||
{!selectedId && (
|
||||
{!selectedId && !selectedPeId && (
|
||||
<EmptyState
|
||||
icon={FileCheck}
|
||||
title={deletedView ? 'Phiếu đã xóa — chỉ xem danh sách' : 'Chọn phiếu ở danh sách'}
|
||||
@ -169,6 +204,37 @@ export function KhkkListPage() {
|
||||
: 'Nội dung kế hoạch + dòng giá NCC + căn cứ hồ sơ + ý kiến cấp duyệt sẽ hiển thị ở đây.'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* [S175 · O6] Xem phiếu Duyệt NCC TẠI CHỖ — `readOnly` + `mode="workspace"` để
|
||||
chắc chắn không có đường sửa/duyệt nào lọt ra ở màn KHKK (anh chốt "chỉ view").
|
||||
Băng chỉ-đường ở trên vì panel 2 lúc này KHÔNG còn nói về kế hoạch ký kết nữa —
|
||||
không nói rõ thì người dùng tưởng mình đang xem KHKK. */}
|
||||
{selectedPeId && (
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2 rounded-md border border-brand-200 bg-brand-50 px-3 py-2 text-[12px] text-brand-800">
|
||||
<span>Đang xem <strong>phiếu Duyệt NCC</strong> (giai đoạn 1) — chỉ xem, không thao tác.</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPeId(null)}
|
||||
className="shrink-0 rounded border border-brand-300 bg-white px-2 py-0.5 font-semibold text-brand-700 transition hover:bg-brand-100"
|
||||
>
|
||||
← Về kế hoạch ký kết
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{selectedPeId && peDetail.isLoading && <div className="text-sm text-slate-500">Đang tải phiếu Duyệt NCC…</div>}
|
||||
{selectedPeId && peDetail.isError && (
|
||||
<div className="text-sm text-red-600">Không tải được phiếu Duyệt NCC (có thể bạn chưa có quyền xem).</div>
|
||||
)}
|
||||
{selectedPeId && peDetail.data && (
|
||||
<PeDetailTabs
|
||||
evaluation={peDetail.data}
|
||||
readOnly
|
||||
mode="workspace"
|
||||
onBack={() => setPeId(null)}
|
||||
onDelete={() => setPeId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedId && detail.isLoading && <div className="text-sm text-slate-500">Đang tải…</div>}
|
||||
{selectedId && !detail.isLoading && !plan && (
|
||||
<div className="text-sm text-red-600">Không tìm thấy phiếu.</div>
|
||||
@ -185,11 +251,15 @@ export function KhkkListPage() {
|
||||
này là CÓ CHỦ ĐÍCH. */}
|
||||
<aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block">
|
||||
{!selectedId && (
|
||||
<div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-400">
|
||||
<div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-500">
|
||||
<X className="mx-auto mb-2 h-5 w-5" />
|
||||
{deletedView
|
||||
? 'Phiếu đã xóa không còn quy trình duyệt.'
|
||||
: 'Quy trình duyệt sẽ hiện khi chọn phiếu.'}
|
||||
{/* [S175 · O6] Đang xem PE ⇒ NÓI RÕ quy trình duyệt của nó nằm ở màn khác,
|
||||
thay vì để nguyên câu "sẽ hiện khi chọn phiếu" (sai: phiếu ĐÃ chọn rồi). */}
|
||||
{selectedPeId
|
||||
? 'Đang xem phiếu Duyệt NCC — quy trình duyệt của phiếu này nằm ở màn Duyệt NCC.'
|
||||
: deletedView
|
||||
? 'Phiếu đã xóa không còn quy trình duyệt.'
|
||||
: 'Quy trình duyệt sẽ hiện khi chọn phiếu.'}
|
||||
</div>
|
||||
)}
|
||||
{selectedId && plan && (
|
||||
|
||||
Reference in New Issue
Block a user