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>
408 lines
18 KiB
TypeScript
408 lines
18 KiB
TypeScript
// [S171 KHKK 3-panel] PANEL 1 của màn "Kế hoạch ký kết HĐ" (GĐ2) — mirror bố-cục
|
|
// Duyệt NCC (GĐ1) / Hợp đồng (GĐ3): khối filter trên cùng + thân danh sách cuộn riêng.
|
|
// File MIRROR SHA256 identical với fe-admin counterpart.
|
|
//
|
|
// 🔴 KHỐI Ý-KIẾN / SƠ-ĐỒ DUYỆT là bản thứ 3 của cùng một hình (PE `PeDetailTabs.tsx:705-774`
|
|
// · HĐ `components/contracts/WorkflowHistoryPanel.tsx`). Gộp được khi và chỉ khi BE 3 module
|
|
// thống-nhất shape `status` precompute. Trước đó, gộp = đẻ `if (module === …)`.
|
|
//
|
|
// ══ OWNER CHỐT 2026-08-04 (QĐ-1): panel 1 = CÂY TOÀN TRÌNH `PipelineTreePanel currentStage={2}`.
|
|
// Chọn HÌNH ≠ chấp-nhận VỠ ⇒ 4 mitigation dưới đây là BẮT BUỘC, không phải tuỳ chọn:
|
|
// M-1 `canPe` (`usePipelineStages.ts:83`) khoá cây bằng 3 key của Duyệt NCC. Người CHỈ có
|
|
// quyền KHKK (vd người duyệt nhóm N5) sẽ thấy cây trống trong khi API
|
|
// `GET /contract-signing-plans` (policy `KeHoachKyKet.Read`) VẪN cho họ đọc — đúng mặt
|
|
// còn lại của gotcha #82. ⇒ `!canPe` FALLBACK sang DANH-SÁCH PHẲNG cùng ô này.
|
|
// Cây là lớp LÀM GIÀU, KHÔNG phải điều-kiện để thấy phiếu.
|
|
// M-2 Leaf/card KHKK trên cây KHÔNG `navigate('/khkk/:id')` nữa mà gọi `onSelect` của host
|
|
// (`usePipelineStages.ts` `onOpenKhkk`) — không thì bấm 1 cái là văng khỏi màn 3-panel.
|
|
// B1 Truyền `stage2Content` PHẢI kèm `stage2Count` (`PipelineStageFolders.tsx:95-97`).
|
|
// Trade-off khai thẳng: trong cây của trang này, folder GĐ2 hiện CARD PHIẾU thay cho
|
|
// 8 ngăn N1-N8; lối vào nhóm giữ ở `Select` nhóm ngay bên trên.
|
|
// R-11 Cây chỉ tải 200 phiếu PE mới nhất (`PipelineTreePanel.tsx:22`) và KHÔNG có "trang 2".
|
|
// ⇒ ô tìm + `Select` trạng-thái đi SERVER-SIDE thẳng `GET /contract-signing-plans`
|
|
// (bật chế-độ danh-sách phẳng), luôn có đường tới phiếu cũ; và dòng cảnh-báo
|
|
// "Cây tải 200 phiếu mới nhất…" của cây KHÔNG bị nuốt.
|
|
import { useQuery } from '@tanstack/react-query'
|
|
import { ChevronLeft, ChevronRight, FileCheck, Inbox, ListTree, Search } from 'lucide-react'
|
|
import { Input } from '@/components/ui/Input'
|
|
import { Select } from '@/components/ui/Select'
|
|
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
|
|
import { usePipelineStages } from '@/hooks/usePipelineStages'
|
|
import { api } from '@/lib/api'
|
|
import { cn } from '@/lib/cn'
|
|
import {
|
|
KHKK_PHASE_BADGE,
|
|
KHKK_PHASE_LABELS,
|
|
KhkkPhase,
|
|
type KhkkListItemDto,
|
|
type KhkkPhaseValue,
|
|
type PagedResult,
|
|
} from '@/types/khkk'
|
|
|
|
const PAGE_SIZE = 20
|
|
|
|
function formatVnd(n: number | null | undefined): string {
|
|
if (n === null || n === undefined) return '—'
|
|
return n.toLocaleString('vi-VN') + ' đ'
|
|
}
|
|
|
|
function formatDateTime(iso: string): string {
|
|
return new Date(iso).toLocaleString('vi-VN', {
|
|
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
|
})
|
|
}
|
|
|
|
/**
|
|
* [F-2] Chip "⚑ Kết thúc · {Bước}" — khuôn `components/pe/PeFinalizeChip.tsx` (S89/S97),
|
|
* dựng lại ở đây thay vì import vì nguồn cờ khác module (KHKK đọc
|
|
* `endedByLevelFinalize` + `finalizeStepName`, PE đọc `endsBeforeCeo`).
|
|
*/
|
|
export function KhkkFinalizeChip({
|
|
endedByLevelFinalize,
|
|
finalizeStepName,
|
|
phase,
|
|
}: {
|
|
endedByLevelFinalize: boolean
|
|
finalizeStepName: string | null
|
|
phase?: number
|
|
}) {
|
|
if (!endedByLevelFinalize) {
|
|
if (phase === KhkkPhase.DaDuyet) {
|
|
return (
|
|
<span
|
|
className="inline-flex shrink-0 items-center rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-800"
|
|
title="Kế hoạch đi HẾT quy trình (không kết thúc sớm ở cấp dưới)"
|
|
>
|
|
✅ Duyệt hết quy trình
|
|
</span>
|
|
)
|
|
}
|
|
return null
|
|
}
|
|
return (
|
|
<span
|
|
className="inline-flex shrink-0 items-center rounded bg-violet-100 px-1.5 py-0.5 text-[10px] font-semibold text-violet-800"
|
|
title={`Quy trình kết thúc tại ${finalizeStepName ?? 'cấp này'} — không trình tiếp cấp sau`}
|
|
>
|
|
{finalizeStepName ? `⚑ Kết thúc · ${finalizeStepName}` : '⚑ Kết thúc sớm'}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Card 3 hàng — thay cho bảng 8 cột + 6 KpiCard của bản cũ (bảng "cột nào chết, chuyển
|
|
* đi đâu" nằm ở `sub-implementer-frontend-s171.md` §4).
|
|
* [M-8] Σ tiền in MỘT số, suy từ `phase === DaDuyet` (đúng như BE suy `budgetFrozen`) —
|
|
* KHÔNG thêm field vào `KhkkListItemDto`, vì `budgetFrozen` chỉ có ở DetailDto.
|
|
*/
|
|
export function KhkkPlanCard({
|
|
plan,
|
|
selected,
|
|
onSelect,
|
|
finalize,
|
|
}: {
|
|
plan: KhkkListItemDto
|
|
selected: boolean
|
|
onSelect: (id: string) => void
|
|
/** Chỉ có khi phiếu ĐANG được chọn (detail đã tải) — list payload không mang cờ này. */
|
|
finalize?: { endedByLevelFinalize: boolean; finalizeStepName: string | null }
|
|
}) {
|
|
const frozen = plan.phase === KhkkPhase.DaDuyet
|
|
const amount = frozen ? (plan.totalApprovedAmount ?? plan.totalProposedAmount) : plan.totalProposedAmount
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => onSelect(plan.id)}
|
|
className={cn(
|
|
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500/60',
|
|
selected && 'bg-brand-50 ring-1 ring-inset ring-brand-200',
|
|
)}
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-center gap-1">
|
|
{finalize && (
|
|
<KhkkFinalizeChip
|
|
endedByLevelFinalize={finalize.endedByLevelFinalize}
|
|
finalizeStepName={finalize.finalizeStepName}
|
|
phase={plan.phase}
|
|
/>
|
|
)}
|
|
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-slate-900">
|
|
{plan.peTenGoiThau ?? '(chưa có tên gói thầu)'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<span
|
|
className={cn(
|
|
'shrink-0 rounded-md border px-1.5 py-0.5 text-[10px] font-medium',
|
|
KHKK_PHASE_BADGE[plan.phase],
|
|
)}
|
|
>
|
|
{KHKK_PHASE_LABELS[plan.phase]}
|
|
</span>
|
|
</div>
|
|
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
|
|
<span className="font-mono">{plan.maKeHoach ?? '(chưa cấp mã)'}</span>
|
|
{/* [S175 · B3] Chip nhóm: `text-[9px]` là nấc thứ TƯ của thang chữ (13/11/10/9) —
|
|
gom về 3 nấc chuẩn 13/11/10. Và `inline` + cỡ chữ khác dòng chứa ⇒ hộp chip cao
|
|
khác chữ bên cạnh nên tụt baseline; `inline-flex items-center leading-none` ghim
|
|
nó thẳng hàng thay vì dựa vào baseline của 2 cỡ chữ khác nhau. */}
|
|
{plan.approvalGroup != null && (
|
|
<span className="inline-flex shrink-0 items-center rounded border border-violet-200 bg-violet-50 px-1 py-0.5 text-[10px] font-semibold leading-none text-violet-700">
|
|
N{plan.approvalGroup}
|
|
</span>
|
|
)}
|
|
<span>·</span>
|
|
<span className="truncate">{formatDateTime(plan.createdAt)}</span>
|
|
</div>
|
|
<div className="mt-0.5 flex items-center justify-between gap-2 text-[11px] text-slate-500">
|
|
<span className="min-w-0 truncate">👤 {plan.drafterFullName ?? '—'}</span>
|
|
<span className={cn('shrink-0 tabular-nums', frozen ? 'text-emerald-700' : 'text-brand-800')}>
|
|
{formatVnd(amount)}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
export function KhkkListPanel({
|
|
selectedId,
|
|
onSelect,
|
|
search,
|
|
onSearchChange,
|
|
phase,
|
|
onPhaseChange,
|
|
group,
|
|
deletedView,
|
|
page,
|
|
onPageChange,
|
|
selectedFinalize,
|
|
onOpenPe,
|
|
}: {
|
|
selectedId: string | null
|
|
onSelect: (id: string) => void
|
|
search: string
|
|
onSearchChange: (v: string) => void
|
|
phase: KhkkPhaseValue | null
|
|
onPhaseChange: (v: KhkkPhaseValue | null) => void
|
|
// [S174 owner] CHỈ còn đọc — `Select` nhóm đã gỡ khỏi UI. Nhóm nay đến DUY NHẤT từ URL
|
|
// (`?group=n`, tức 8 leaf nhóm ở menu trái). Máy lọc giữ NGUYÊN (query + `stage2Content`);
|
|
// chỉ bỏ đường đổi nhóm bằng tay — vào từ leaf nhóm rồi thì ô đó là thừa.
|
|
group: number | null
|
|
deletedView: boolean
|
|
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()
|
|
|
|
// [R-11] Lọc đi SERVER-SIDE, KHÔNG lọc trên cây: có từ khoá / có trạng-thái / xem thùng
|
|
// rác ⇒ chuyển sang danh-sách phẳng có phân-trang thật (`total` từ server) nên phiếu nằm
|
|
// ngoài 200 phiếu mới nhất VẪN tới được.
|
|
// [M-1] `!canPe` ⇒ cũng dùng đúng danh-sách phẳng này — cây khoá không được phép làm
|
|
// người dùng mất trắng phiếu của chính họ.
|
|
const flatMode = !canPe || deletedView || term.length > 0 || phase !== null
|
|
|
|
const list = useQuery({
|
|
queryKey: ['khkk-list', { group, phase, search: term, page, deletedView }],
|
|
queryFn: async () => {
|
|
const url = deletedView ? '/contract-signing-plans/deleted' : '/contract-signing-plans'
|
|
return (await api.get<PagedResult<KhkkListItemDto>>(url, {
|
|
params: {
|
|
phase: deletedView ? undefined : (phase ?? undefined),
|
|
group: group ?? undefined,
|
|
search: term || undefined,
|
|
page,
|
|
pageSize: PAGE_SIZE,
|
|
},
|
|
})).data
|
|
},
|
|
enabled: flatMode,
|
|
})
|
|
|
|
const items = list.data?.items ?? []
|
|
const total = list.data?.total ?? 0
|
|
const totalPages = list.data?.totalPages ?? 1
|
|
|
|
// [B1] Thân folder GĐ2 của cây = CARD phiếu của chính trang này; `Select` nhóm lọc luôn
|
|
// trên cây (client-side, an toàn: đây chỉ là chia lại đúng tập phiếu cây đã có).
|
|
const stage2Content = (plans: KhkkListItemDto[]) => {
|
|
const shown = group === null ? plans : plans.filter(k => k.approvalGroup === group)
|
|
if (shown.length === 0) {
|
|
return (
|
|
<p className="px-3 py-2 text-[11px] italic text-slate-500">
|
|
{group === null ? 'Gói này chưa có kế hoạch ký kết.' : `Gói này chưa có phiếu nhóm N${group}.`}
|
|
</p>
|
|
)
|
|
}
|
|
return (
|
|
<ul className="divide-y divide-slate-100">
|
|
{shown.map(k => (
|
|
<li key={k.id}>
|
|
<KhkkPlanCard
|
|
plan={k}
|
|
selected={selectedId === k.id}
|
|
onSelect={onSelect}
|
|
finalize={selectedId === k.id ? selectedFinalize : undefined}
|
|
/>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)
|
|
}
|
|
const stage2Count = (plans: KhkkListItemDto[]) =>
|
|
(group === null ? plans : plans.filter(k => k.approvalGroup === group)).length
|
|
|
|
return (
|
|
<aside className="flex flex-col overflow-hidden border-r border-slate-200 bg-white">
|
|
{/* Khối filter — CHỖ ĐẶT giống PE/GĐ3 (trên cùng panel 1). `Select` nhóm N1-N8 là
|
|
chỗ KHKK khác nguồn dữ-liệu chứ không khác bố-trí (P1-4). */}
|
|
<div className="space-y-2 border-b border-slate-200 p-3">
|
|
<div className="relative">
|
|
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
|
|
<Input
|
|
value={search}
|
|
onChange={e => onSearchChange(e.target.value)}
|
|
placeholder="Tìm mã kế hoạch / mã phiếu / gói thầu…"
|
|
className="pl-8"
|
|
/>
|
|
</div>
|
|
<Select
|
|
value={phase ?? ''}
|
|
aria-label="Lọc trạng thái"
|
|
onChange={e => onPhaseChange(e.target.value === '' ? null : (Number(e.target.value) as KhkkPhaseValue))}
|
|
>
|
|
<option value="">Tất cả trạng thái</option>
|
|
{(Object.keys(KHKK_PHASE_LABELS) as unknown as number[]).map(p => (
|
|
<option key={p} value={p}>{KHKK_PHASE_LABELS[Number(p) as KhkkPhaseValue]}</option>
|
|
))}
|
|
</Select>
|
|
{flatMode && !deletedView && canPe && (
|
|
<p className="text-[10px] italic text-slate-500">
|
|
Đang lọc — danh sách lấy thẳng từ máy chủ (không giới hạn 200 phiếu của cây).
|
|
</p>
|
|
)}
|
|
{!canPe && (
|
|
<p className="text-[10px] italic text-slate-500">
|
|
Bạn chưa có quyền xem phiếu Duyệt NCC nên cây toàn trình không dựng được — danh
|
|
sách kế hoạch ký kết vẫn liệt kê đầy đủ ở dưới.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* [S175 · B3] ĐẦU CỘT — trước đây chế-độ CÂY đi thẳng vào thân, không tiêu-đề cũng
|
|
không số đếm ⇒ cột "cụt đầu": lệch cả với màn Thao tác (`KhkkWorkListPanel` có
|
|
`Tier` header) LẪN với chính chế-độ phẳng (có dòng đếm ở chân). Mirror đúng khuôn
|
|
`Tier` để 2 màn KHKK cùng một hình. */}
|
|
<div className="flex shrink-0 items-center gap-2 border-b border-slate-200 bg-slate-50/80 px-3 py-2">
|
|
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded bg-brand-100 text-brand-700" aria-hidden>
|
|
{flatMode ? <FileCheck className="h-3 w-3" /> : <ListTree className="h-3 w-3" />}
|
|
</span>
|
|
<span className="min-w-0 flex-1 truncate text-[11px] font-bold uppercase tracking-wide text-slate-600">
|
|
{deletedView ? 'Phiếu đã xóa' : flatMode ? 'Danh sách kế hoạch' : 'Cây toàn trình'}
|
|
</span>
|
|
{flatMode && !list.isLoading && !list.isError && (
|
|
<span className="shrink-0 rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium tabular-nums text-slate-600">
|
|
{total}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto">
|
|
{!flatMode && (
|
|
<PipelineTreePanel
|
|
currentStage={2}
|
|
hideHeader
|
|
hideSearch
|
|
bodyMaxHClass="max-h-[28rem] lg:max-h-none"
|
|
stage2Options={{ stage2Content, stage2Count, onOpenKhkk: onSelect, onOpenPe }}
|
|
/>
|
|
)}
|
|
|
|
{flatMode && list.isLoading && (
|
|
<div className="space-y-2 p-3">
|
|
{Array.from({ length: 8 }).map((_, i) => (
|
|
<div key={i} className="h-16 animate-pulse rounded-md bg-slate-100 motion-reduce:animate-none" />
|
|
))}
|
|
</div>
|
|
)}
|
|
{flatMode && list.isError && (
|
|
<div className="space-y-2 px-3 py-4">
|
|
<p className="text-[12px] text-slate-500">Không tải được danh sách kế hoạch ký kết.</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => list.refetch()}
|
|
className="rounded-md border border-slate-300 px-2 py-1 text-[11px] font-semibold text-slate-600 transition hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700"
|
|
>
|
|
Thử lại
|
|
</button>
|
|
</div>
|
|
)}
|
|
{flatMode && !list.isLoading && !list.isError && items.length === 0 && (
|
|
<div className="p-6 text-center text-sm text-slate-500">
|
|
<span
|
|
className="icon-chip mx-auto mb-2 flex"
|
|
style={{ ['--chip-bg' as string]: '#f1f5f9', ['--chip-fg' as string]: '#94a3b8' }}
|
|
aria-hidden
|
|
>
|
|
<Inbox className="h-4 w-4" />
|
|
</span>
|
|
{deletedView ? 'Không có phiếu đã xóa.' : 'Chưa có kế hoạch ký kết nào khớp bộ lọc.'}
|
|
</div>
|
|
)}
|
|
{flatMode && items.length > 0 && (
|
|
<ul className="divide-y divide-slate-100">
|
|
{items.map(k => (
|
|
<li key={k.id} className={cn(deletedView && 'opacity-70')}>
|
|
<KhkkPlanCard
|
|
plan={k}
|
|
selected={selectedId === k.id}
|
|
onSelect={deletedView ? () => { /* phiếu đã xóa: chỉ liệt kê, không mở */ } : onSelect}
|
|
finalize={selectedId === k.id ? selectedFinalize : undefined}
|
|
/>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
|
|
{flatMode && totalPages > 1 && (
|
|
<div className="flex shrink-0 items-center justify-between border-t border-slate-200 px-3 py-2 text-[11px]">
|
|
<span className="text-slate-500">
|
|
<FileCheck className="mr-1 inline h-3 w-3" />
|
|
{total} kế hoạch — Trang {page} / {totalPages}
|
|
</span>
|
|
{/* [S175 · B3] Ký-tự trần `←`/`→` → icon: mũi tên dạng chữ đổi hình theo font hệ
|
|
điều-hành và KHÔNG có tên cho trình đọc màn hình. Icon + `aria-label` sửa cả hai. */}
|
|
<div className="flex gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => onPageChange(Math.max(1, page - 1))}
|
|
disabled={page <= 1}
|
|
aria-label="Trang trước"
|
|
title="Trang trước"
|
|
className="rounded border border-slate-300 p-1 text-slate-600 hover:bg-slate-50 disabled:opacity-40"
|
|
>
|
|
<ChevronLeft className="h-3.5 w-3.5" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => onPageChange(page + 1)}
|
|
disabled={page >= totalPages}
|
|
aria-label="Trang sau"
|
|
title="Trang sau"
|
|
className="rounded border border-slate-300 p-1 text-slate-600 hover:bg-slate-50 disabled:opacity-40"
|
|
>
|
|
<ChevronRight className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</aside>
|
|
)
|
|
}
|