[CLAUDE] Contract: KHKK 3-panel mirror Duyet NCC (GD2 het lech kien-truc) + 4 mitigation cuong-hanh
Dua man Ke hoach Ky ket HD ve dung lop kien-truc ma GD1 (PE) va GD3 (HD) dang
dung: 1 man 3-panel h-[calc(100vh-4rem)] grid [400px_1fr_360px], thay cho
list 2-panel + detail trang rieng co workflow xep chong. GD2 la mat-xich lech
duy nhat trong 4 giai-doan truoc dot nay.
BE (7/7):
- DetailDto +5 field: WorkflowVersion / EndedByLevelFinalize / FinalizeStepName
/ FinalizeLevelName / Approvals. 2 field Finalize* KHONG phai field append —
ContractSigningPlan chi co co bool, phai DAN XUAT (lam in-memory tu cay
workflow da nap, thay vi port 3 subquery cua PE).
- ContractSigningPlanApprovalDto de moi (truoc do 0 hit toan repo).
- StepDto +DepartmentName (nuoi chip phong panel 3 + header nhom Section 5).
- GET {id}/changelogs — policy KeHoachKyKet.Read.
- POST {id}/approval-attachments + enum +ApprovalAttachment=3 (append-only).
- approvals[] loc bo Decision=Pending de so (N) cung nghia voi (N) cua PE.
- KHONG chep khuon authz cua PurchaseEvaluationsController: file do la mot lo
gotcha #82 (class [Authorize] tran + changelogs khong policy).
FE (x2 app, SHA256 byte-identical 8/8):
- components/khkk/{KhkkListPanel,KhkkDetailContent,KhkkWorkflowPanel}.tsx;
KhkkWorkflowPanel doi tu pages/ sang components/.
- Panel 2: 5 Section danh so, Section 5 "Y kien cap duyet" duoi cung — khoi nay
DOI TU panel 3 sang panel 2 (owner khoanh do trong anh); luoi 2 cot + meta
"x/y cap da duyet · N NV tham gia" thay list phang 1 cot.
- Panel 3: banner ket-thuc-som, chip ten phong, badge Duyet thay CEO, lam mo
buoc sau finalize, khoi File dinh kem khi duyet, Lich su duyet, Lich su
thay doi. Chua chon phieu -> hop dashed y PE (QD owner).
4 mitigation cuong-hanh (owner chot panel 1 = PipelineTreePanel; chon HINH
khong phai chap-nhan VO):
- M-1 fallback !canPe -> danh-sach KHKK phang. Khong co no, user chi co quyen
KHKK (vd nguoi duyet nhom N5) MAT TRANG danh-sach du API van cho doc —
display-layer khoa authz-layer dang mo, mat con lai cua #82.
- M-2 onOpen thanh callback tu host; mac-dinh giu navigate cu => 0 hoi-quy
trang PE/GD3.
- B1 stage2Content di CAP voi stage2Count qua conditional-spread:
khong truyen => stage2 van nhan groups => trang PE GIU NGUYEN 8 ngan N1-N8.
- R-11 search/loc di server-side, KHONG loc-tren-cay; giu dong canh-bao
"Cay tai 200 phieu moi nhat".
Chong hoi-quy da do: build 3/3 PASS · acceptance tinh 20/20 x 2 app ·
mirror SHA256 8/8 · 0 hit path cam (components|pages)/(pe|contracts) ·
KHONG ghi filter/view/group vao URL (chong tai-sinh bug S155 sidebar sang
nham leaf) · giu set-state-in-render :97-101 (dung khuon React, refactor
thanh useEffect se tai-hien va-5).
Con no, khai thang:
- E-7 bang "cot nao chet, chuyen di dau" (8 cot + 6 KpiCard -> card 400px)
THIEU: lane FE chet o luot tra ve, ly-do bo cot mat theo. Phai soi diff
bang mat truoc UAT, neu khong dung ca M-7 mat-chuc-nang-im-lang.
- Test 643/644. 1 FLAKY Create_DifferentEvaluationType_DoesNotAffect_Other
(full-suite 2->1->1, chay rieng 0/0/0; PeWorkflowAdminTests tao
new SqliteDbFixture() rieng x6 test). Baseline tren HEAD sach CHUA do
(git worktree fail duong-dan Dropbox) => CHUA duoc noi "flake co san".
- 5 acceptance RUNTIME cho UAT: approvals co dong Tra lai + ten nguoi (DB dev
0 hang KHKK) · UAT fe-user:8080 an-danh · deep-link fe-admin:8082 (menu
KHKK bi an trong ADMIN_HIDDEN_KEYS) · negative-control go fallback canPe ·
khoi file tu-an khi rong.
- P3-12 4 return-mode = HOAN, khong phai buoc-khac: tien-de "KHKK 3 tram ngan"
do lai la SAI (DbInitializer:554 = 3 Buoc x <=2 Cap, seed tu ghi "sao tu
workflow Duyet NCC prod").
Spec + 6 artifact phan-bien: .claude/workflows/runs/2026-08-04-S171-khkk-ui-mirror-pe/
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
388
fe-admin/src/components/khkk/KhkkListPanel.tsx
Normal file
388
fe-admin/src/components/khkk/KhkkListPanel.tsx
Normal file
@ -0,0 +1,388 @@
|
||||
// [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 { FileCheck, Inbox, 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_APPROVAL_GROUPS,
|
||||
KHKK_PHASE_BADGE,
|
||||
KHKK_PHASE_LABELS,
|
||||
KhkkPhase,
|
||||
khkkGroupMenuLabel,
|
||||
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>
|
||||
{plan.approvalGroup != null && (
|
||||
<span className="rounded border border-violet-200 bg-violet-50 px-1 text-[9px] font-semibold 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,
|
||||
onGroupChange,
|
||||
deletedView,
|
||||
page,
|
||||
onPageChange,
|
||||
selectedFinalize,
|
||||
}: {
|
||||
selectedId: string | null
|
||||
onSelect: (id: string) => void
|
||||
search: string
|
||||
onSearchChange: (v: string) => void
|
||||
phase: KhkkPhaseValue | null
|
||||
onPhaseChange: (v: KhkkPhaseValue | null) => void
|
||||
group: number | null
|
||||
onGroupChange: (v: number | null) => void
|
||||
deletedView: boolean
|
||||
page: number
|
||||
onPageChange: (p: number) => void
|
||||
selectedFinalize?: { endedByLevelFinalize: boolean; finalizeStepName: string | null }
|
||||
}) {
|
||||
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-400">
|
||||
{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>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<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>
|
||||
<Select
|
||||
value={group ?? ''}
|
||||
aria-label="Lọc nhóm duyệt"
|
||||
onChange={e => onGroupChange(e.target.value === '' ? null : Number(e.target.value))}
|
||||
>
|
||||
<option value="">Tất cả nhóm</option>
|
||||
{KHKK_APPROVAL_GROUPS.map(n => (
|
||||
<option key={n} value={n}>{khkkGroupMenuLabel(n)}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
{flatMode && !deletedView && canPe && (
|
||||
<p className="text-[10px] italic text-slate-400">
|
||||
Đ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-400">
|
||||
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>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{!flatMode && (
|
||||
<PipelineTreePanel
|
||||
currentStage={2}
|
||||
className="border-0 shadow-none"
|
||||
bodyMaxHClass="max-h-[28rem] lg:max-h-none"
|
||||
stage2Options={{ stage2Content, stage2Count, onOpenKhkk: onSelect }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
disabled={page <= 1}
|
||||
className="rounded border border-slate-300 px-2 py-0.5 text-slate-600 hover:bg-slate-50 disabled:opacity-40"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
className="rounded border border-slate-300 px-2 py-0.5 text-slate-600 hover:bg-slate-50 disabled:opacity-40"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user