[CLAUDE] Contract: UAT S174 dot-B — be style PE sang KHKK (FE-only, 0-mig)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m37s

Nguon: spec runs/2026-08-05-S174-khkk-style-audit/spec-khkk-style-dong-bo-05-08-2026.md
(ensemble /fable-clone frontend-designer 5 lane). Owner S175: "KHKK theo khuon PE".
Mirror 7 CAP file, sha256 khop 7/7. Build XANH ca 2 app, 0 error TS.

=== B1 panel Quy trinh duyet (KhkkWorkflowPanel) ===
X2 GO card-accent -> panel TRAN khuon PE (:345).
  🔴 GO HAN CLASS, khong de border-0/shadow-none: .card-accent (index.css:112) nam NGOAI
  @layer nen thang utility Tailwind v4 (gotcha #66).
- Tieu de h3 tran + bo icon-chip; so hieu WF padStart(2,'0') => v01 (PE :350).
- Banner ket-thuc-som violet -> EMERALD (PE :360-377). PE danh violet RIENG cho
  "Duyet thay CEO"; dung violet ca 2 cho la tron 2 nghia vao mot mau.
- Chip PHONG vien-xam-nen-trang -> emerald dac (PE :406-410).
- Nut lucide -> glyph '✓ Duyet' / '← Tra lai' (PE :540); nut Xoa xam -> DO (PE :574).
- File dinh kem: +border-t pt-4 +h3 +nut <Eye> xem truoc. Route BE DA CO
  ContractSigningPlansController.cs:231 (/view, cung hinh PE :317).
  Nhan "Tai xuong" chuyen vao title= chu KHONG bo — giu chuoi do trong bundle
  de phep verify prod con control-duong soi duoc.
- Lich su duyet: bo pill dem -> (n) trong ngoac; the bg-slate-50/60 -> bg-white p-3 text-sm;
  moc-gio day phai bang justify-between.
  🔴 Badge 2-nga -> 3-NGA: truoc day MOI thu khong phai Reject deu to emerald => "Tra lai"
  (amber o PE) bi ve xanh nhu da-duyet, doc NGUOC nghia. toPhase da co san (khkk.ts:307).
- Lich su thay doi: BO GAP, render thang (PE PeDetailTabs.tsx:787-794).
  🔴 BAT BUOC bo luon `enabled: showChangelog` cua useQuery: giu lai thi khoi luon hien
  nhung query KHONG BAO GIO chay, va no hong duoi dang "Chua co thay doi nao duoc ghi."
  — tuc trong y het phieu chua tung sua, KHONG phai trong nhu loi. Doi lay +1 request/phieu,
  dung gia PE dang tra.
- Micro-copy: Huy / tuy chon; nhan O NHAP -> "Ghi chu" (PE :869).
  🔴 CHI doi ten TRUONG — chuoi "Y kien cap duyet" o dong tro-giup la TEN THAT cua muc dich
  den trong phieu (Section 5, Mig 26), doi no la tro nguoi dung toi mot muc khong ton tai.
- KHONG be chip "NS PRO"/"NS CCM" (2 co do mo quyen sua ngan sach per-goi-thau, khai niem
  KHKK khong co). GIU moc chu-ky KHKK (cho KHKK tot hon PE).

=== B2 noi dung phieu (KhkkDetailContent) — 6 P0 ===
- 2 bang: table-fixed + <colgroup> + min-w-[880px] trong overflow-x-auto. Bang co gian
  tu-do truoc day de cot tien nhay be rong theo do dai ten NCC => 3 cot so khong thang hang.
- O RONG -> em-dash MO (text-slate-300) gom vao helper orDash/moneyOrDash: truoc day em-dash
  thua-huong dung mau chu that => luot bang thi "—" trong y nhu mot GIA-TRI.
  moneyOrDash rieng vi formatVnd tra CHUOI "—" nen khong to mo duoc.
- empty-block -> text-sm italic text-slate-400 (3 cho).
- emoji 🔒 -> icon lucide <Lock>: emoji render khac nhau theo font he dieu-hanh va KHONG an
  currentColor nen luon lech tong voi chu amber ben canh.
- thong nhat text-xs ca hang bang; +hover:bg-slate-50/60 cho <tr>.

=== B3 panel danh sach (KhkkListPanel + KhkkWorkListPanel + PipelineTreePanel) ===
- DAU COT: che-do CAY truoc day di thang vao than, khong tieu-de/so dem => cot "cut dau",
  lech ca voi man Thao tac (Tier header) LAN voi chinh che-do phang (co dem o chan).
  Mirror khuon Tier.
- Gop 2 o tim chong nhau: +prop hideSearch cho PipelineTreePanel (MAC DINH giu nguyen —
  4/6 host kia khong truyen, 0 doi hanh vi). O trong cay KHONG bao gio dung toi vi o ngoai
  di server-side va go vao la chuyen han sang flatMode => cay bien mat.
- Thang chu 4 nac (13/11/10/9) -> 3 nac chuan 13/11/10.
- 🔴 WCAG: text-slate-400 = 2.56:1 tren nen trang, DUOI nguong AA 4.5:1 — nang len
  slate-500 (4.76:1) cho 7 dong MANG THONG TIN (6 note/empty-state + 1 so dem).
  GIU slate-400 cho 2 cho TRANG TRI (icon Search, dau cham phan cach) — WCAG khong doi
  tuong phan cho noi dung trang tri.
- Nut phan trang ky-tu tran ←/→ -> icon ChevronLeft/Right + aria-label: mui ten dang chu
  doi hinh theo font he dieu-hanh va KHONG co ten cho trinh doc man hinh.
- Chip nhom: inline + co chu khac dong chua => hop chip cao khac chu ben canh nen tut
  baseline; inline-flex items-center leading-none ghim thang hang.

=== B4 host (KhkkCreatePage) ===
- Grid 260/320 DA land tu dot A — chi con COMMENT stale ghi 300px, da va.
- 🔴 Va comment SAI SU THAT (L3 H-5): comment khai Panel 1 co "nut hanh-dong dinh day"
  nhung grep -c Button KhkkWorkListPanel = 0 (control-duong PeListPanel = 8). Do la mo ta
  CUA PE be nguyen sang => tu khai mot thu chua dung.

=== 2 component DUNG CHUNG — mo rong kieu MAC-DINH-GIU-NGUYEN ===
AttachmentPreviewDialog +basePath? (mac dinh /purchase-evaluations) => 6 call-site PE
KHONG phai sua dong nao (module da golive; bat sua het call-site cu = tu de rui-ro o cho
dang chay tot). PipelineTreePanel +hideSearch? y het. Da verify: 0 call-site PE bi dong.

=== CON NO ===
🔴 CHUA soi mat tren prod 375/1366/1440 — ca 5 lane deu NO-SCREENSHOT (dev-rig chan:
chi :8080 LISTENING, API :5443 khong co, trang sau ProtectedRoute — gotcha #3). Moi ket
luan la DOC MA, khong phai da nhin.
Dot C3 (Field to brand cho MOI value) + dot D (O3/O4/O6/O7/O8) chua lam. O3 con cho owner
xac nhan cach doc "sum tong" — do la tien.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-05 19:30:13 +07:00
parent c61e28f833
commit 43e1bd7bc2
14 changed files with 570 additions and 324 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, FileCheck, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users, ClipboardList, FileCheck, Lock, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { PePipelineStrip } from '@/components/pe/PePipelineStrip' import { PePipelineStrip } from '@/components/pe/PePipelineStrip'
@ -48,6 +48,21 @@ function formatVnd(n: number | null | undefined): string {
return n.toLocaleString('vi-VN') + ' đ' return n.toLocaleString('vi-VN') + ' đ'
} }
// [S175 · B2 · P0] Ô RỖNG phải MỜ HƠN dữ-liệu thật. Trước đây em-dash thừa-hưởng đúng
// màu chữ của ô (`text-slate-600/700`) ⇒ lướt bảng thì "—" trông y như một GIÁ-TRỊ.
// Gom về một chỗ để các ô rỗng không mỗi nơi một kiểu.
const DASH = <span className="text-slate-300"></span>
/** Chuỗi có thể rỗng → em-dash mờ. */
function orDash(v: string | null | undefined): ReactNode {
return v == null || v === '' ? DASH : v
}
/** Tiền: `formatVnd` trả CHUỖI "—" khi rỗng nên không tô mờ được — tách nhánh rỗng ra. */
function moneyOrDash(n: number | null | undefined): ReactNode {
return n == null ? DASH : formatVnd(n)
}
function formatDateTime(iso: string): string { function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString('vi-VN') return new Date(iso).toLocaleString('vi-VN')
} }
@ -362,17 +377,32 @@ export function KhkkDetailContent({
chipFg="var(--color-teal-700)" chipFg="var(--color-teal-700)"
actions={ actions={
plan.budgetFrozen ? ( plan.budgetFrozen ? (
<span className="inline-flex items-center rounded-md border border-amber-300 bg-amber-100 px-2 py-0.5 text-[11px] font-semibold normal-case tracking-normal text-amber-800"> // [S175 · B2 · P0] emoji 🔒 → icon lucide: emoji render khác nhau theo font hệ
🔒 Giá đã chốt tại thời điểm duyệt // điều-hành (Windows/macOS/Android ra 3 hình), và nó KHÔNG ăn `currentColor`
// nên luôn lệch tông với chữ amber bên cạnh.
<span className="inline-flex items-center gap-1 rounded-md border border-amber-300 bg-amber-100 px-2 py-0.5 text-[11px] font-semibold normal-case tracking-normal text-amber-800">
<Lock className="h-3 w-3" aria-hidden /> Giá đã chốt tại thời điểm duyệt
</span> </span>
) : undefined ) : undefined
} }
> >
{plan.lines.length === 0 ? ( {plan.lines.length === 0 ? (
<div className="text-sm text-muted-foreground">Phiếu nguồn chưa NCC trúng thầu.</div> <div className="text-sm italic text-slate-400">Phiếu nguồn chưa 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ữ.
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full min-w-[880px] table-fixed text-xs">
<colgroup>
<col className="w-[22%]" />
<col className="w-[22%]" />
<col className="w-[14%]" />
<col className="w-[14%]" />
<col className="w-[14%]" />
<col className="w-[14%]" />
</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">Nhà cung cấp</th>
@ -385,16 +415,16 @@ export function KhkkDetailContent({
</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"> <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"> <td className="px-3 py-2 font-medium text-brand-800">
{l.supplierName ?? '—'} {orDash(l.supplierName)}
{l.contractId && ( {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 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> </a>
)} )}
</td> </td>
<td className="px-3 py-2 text-xs text-slate-700"> <td className="px-3 py-2 text-slate-700">
{isDraft && !l.contractId ? ( {isDraft && !l.contractId ? (
<> <>
<select <select
@ -426,12 +456,12 @@ export function KhkkDetailContent({
</div> </div>
)} )}
</> </>
) : l.tenHangMuc ? l.tenHangMuc : <span className="text-slate-300"></span>} ) : orDash(l.tenHangMuc)}
</td> </td>
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{formatVnd(l.peReferenceAmount)}</td> <td className="px-3 py-2 text-right tabular-nums text-slate-600">{moneyOrDash(l.peReferenceAmount)}</td>
<td className="px-3 py-2 text-right tabular-nums text-brand-800">{formatVnd(l.proposedAmount)}</td> <td className="px-3 py-2 text-right tabular-nums text-brand-800">{moneyOrDash(l.proposedAmount)}</td>
<td className="px-3 py-2 text-right tabular-nums text-emerald-700">{formatVnd(l.approvedAmount)}</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-xs text-slate-600">{l.note ?? '—'}</td> <td className="px-3 py-2 text-slate-600">{orDash(l.note)}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@ -462,10 +492,18 @@ export function KhkkDetailContent({
} }
> >
{plan.dossierItems.length === 0 ? ( {plan.dossierItems.length === 0 ? (
<div className="text-sm text-muted-foreground">Chưa khai căn cứ nào.</div> <div className="text-sm italic text-slate-400">Chưa khai căn cứ nào.</div>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full min-w-[880px] table-fixed text-xs">
<colgroup>
<col className="w-[14%]" />
<col className={isDraft ? 'w-[26%]' : 'w-[30%]'} />
<col className="w-[14%]" />
<col className={isDraft ? 'w-[16%]' : 'w-[20%]'} />
<col className={isDraft ? 'w-[18%]' : 'w-[22%]'} />
{isDraft && <col className="w-[12%]" />}
</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">Loại</th> <th className="label-eyebrow px-3 py-2 text-left">Loại</th>
@ -478,19 +516,19 @@ export function KhkkDetailContent({
</thead> </thead>
<tbody> <tbody>
{plan.dossierItems.map((d: KhkkDossierItemDto) => ( {plan.dossierItems.map((d: KhkkDossierItemDto) => (
<tr key={d.id} className="border-b border-slate-100 last:border-0"> <tr key={d.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/60">
<td className="px-3 py-2 text-xs text-slate-600">{DOSSIER_KIND_LABELS[d.kind] ?? '—'}</td> <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 font-medium text-brand-800">{d.name}</td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<span className={cn('inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium', DOSSIER_STATUS_BADGE[d.status])}> <span className={cn('inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium', DOSSIER_STATUS_BADGE[d.status])}>
{DOSSIER_STATUS_LABELS[d.status]} {DOSSIER_STATUS_LABELS[d.status]}
</span> </span>
</td> </td>
<td className="px-3 py-2 text-xs text-slate-600"> <td className="px-3 py-2 text-slate-600">
{d.tvgsName ?? '—'} {orDash(d.tvgsName)}
{d.tvgsResultAt && <span className="ml-1 text-[11px] text-slate-400">({formatDateTime(d.tvgsResultAt)})</span>} {d.tvgsResultAt && <span className="ml-1 text-[11px] text-slate-400">({formatDateTime(d.tvgsResultAt)})</span>}
</td> </td>
<td className="px-3 py-2 text-xs text-slate-600">{d.note ?? '—'}</td> <td className="px-3 py-2 text-slate-600">{orDash(d.note)}</td>
{isDraft && ( {isDraft && (
<td className="px-3 py-2 text-right"> <td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1"> <div className="flex justify-end gap-1">
@ -544,7 +582,7 @@ export function KhkkDetailContent({
} }
> >
{planAttachments.length === 0 ? ( {planAttachments.length === 0 ? (
<div className="text-sm text-muted-foreground">Chưa file đính kèm.</div> <div className="text-sm italic text-slate-400">Chưa file đính kèm.</div>
) : ( ) : (
<ul className="space-y-2"> <ul className="space-y-2">
{planAttachments.map((a) => ( {planAttachments.map((a) => (

View File

@ -23,7 +23,7 @@
// (bật chế-độ danh-sách phẳng), luôn có đường tới phiếu cũ; và dòng cảnh-báo // (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. // "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 { useQuery } from '@tanstack/react-query'
import { FileCheck, Inbox, Search } from 'lucide-react' import { ChevronLeft, ChevronRight, FileCheck, Inbox, ListTree, Search } from 'lucide-react'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel' import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
@ -144,8 +144,12 @@ export function KhkkPlanCard({
</div> </div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500"> <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> <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 && ( {plan.approvalGroup != null && (
<span className="rounded border border-violet-200 bg-violet-50 px-1 text-[9px] font-semibold text-violet-700"> <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} N{plan.approvalGroup}
</span> </span>
)} )}
@ -227,7 +231,7 @@ export function KhkkListPanel({
const shown = group === null ? plans : plans.filter(k => k.approvalGroup === group) const shown = group === null ? plans : plans.filter(k => k.approvalGroup === group)
if (shown.length === 0) { if (shown.length === 0) {
return ( return (
<p className="px-3 py-2 text-[11px] italic text-slate-400"> <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}.`} {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> </p>
) )
@ -275,23 +279,42 @@ export function KhkkListPanel({
))} ))}
</Select> </Select>
{flatMode && !deletedView && canPe && ( {flatMode && !deletedView && canPe && (
<p className="text-[10px] italic text-slate-400"> <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). Đ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> </p>
)} )}
{!canPe && ( {!canPe && (
<p className="text-[10px] italic text-slate-400"> <p className="text-[10px] italic text-slate-500">
Bạn chưa quyền xem phiếu Duyệt NCC nên cây toàn trình không dựng đưc danh Bạn chưa 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ết vẫn liệt đy đ dưới. sách kế hoạch kết vẫn liệt đy đ dưới.
</p> </p>
)} )}
</div> </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"> <div className="flex-1 overflow-y-auto">
{!flatMode && ( {!flatMode && (
<PipelineTreePanel <PipelineTreePanel
currentStage={2} currentStage={2}
hideHeader hideHeader
hideSearch
bodyMaxHClass="max-h-[28rem] lg:max-h-none" bodyMaxHClass="max-h-[28rem] lg:max-h-none"
stage2Options={{ stage2Content, stage2Count, onOpenKhkk: onSelect }} stage2Options={{ stage2Content, stage2Count, onOpenKhkk: onSelect }}
/> />
@ -350,22 +373,28 @@ export function KhkkListPanel({
<FileCheck className="mr-1 inline h-3 w-3" /> <FileCheck className="mr-1 inline h-3 w-3" />
{total} kế hoạch Trang {page} / {totalPages} {total} kế hoạch Trang {page} / {totalPages}
</span> </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"> <div className="flex gap-1">
<button <button
type="button" type="button"
onClick={() => onPageChange(Math.max(1, page - 1))} onClick={() => onPageChange(Math.max(1, page - 1))}
disabled={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" 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>
<button <button
type="button" type="button"
onClick={() => onPageChange(page + 1)} onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages} disabled={page >= totalPages}
className="rounded border border-slate-300 px-2 py-0.5 text-slate-600 hover:bg-slate-50 disabled:opacity-40" 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> </button>
</div> </div>
</div> </div>

View File

@ -171,7 +171,7 @@ export function KhkkWorkListPanel({
</div> </div>
)} )}
{!candidates.isLoading && !candidates.isError && awaiting.length === 0 && ( {!candidates.isLoading && !candidates.isError && awaiting.length === 0 && (
<p className="p-3 text-[11px] italic text-slate-400"> <p className="p-3 text-[11px] italic text-slate-500">
Mọi phiếu đã duyệt đu đã kế hoạch kết. Mọi phiếu đã duyệt đu đã kế hoạch kết.
</p> </p>
)} )}
@ -181,7 +181,7 @@ export function KhkkWorkListPanel({
<span className="min-w-0 flex-1 truncate text-[10px] font-semibold uppercase tracking-wide text-slate-500"> <span className="min-w-0 flex-1 truncate text-[10px] font-semibold uppercase tracking-wide text-slate-500">
{projectName} {projectName}
</span> </span>
<span className="shrink-0 text-[10px] text-slate-400">{items.length}</span> <span className="shrink-0 text-[10px] text-slate-500">{items.length}</span>
</div> </div>
<ul className="divide-y divide-slate-100"> <ul className="divide-y divide-slate-100">
{items.map((pe) => ( {items.map((pe) => (
@ -241,7 +241,7 @@ export function KhkkWorkListPanel({
</div> </div>
)} )}
{!plans.isLoading && !plans.isError && planItems.length === 0 && ( {!plans.isLoading && !plans.isError && planItems.length === 0 && (
<p className="p-3 text-[11px] italic text-slate-400"> <p className="p-3 text-[11px] italic text-slate-500">
Không còn kế hoạch nào đang xử . Phiếu đã duyệt không hiện đây xem tại Không còn kế hoạch nào đang xử . Phiếu đã duyệt không hiện đây xem tại
menu Kế hoạch kết . menu Kế hoạch kết .
</p> </p>
@ -287,7 +287,7 @@ export function KhkkWorkListPanel({
sẽ đọc thành "đang cắt bớt" trong khi thật ra là "đang lọc bỏ". Nói đúng việc sẽ đọc thành "đang cắt bớt" trong khi thật ra là "đang lọc bỏ". Nói đúng việc
đang làm, và chỉ nói khi thật sự có cái bị giấu. */} đang làm, và chỉ nói khi thật sự có cái bị giấu. */}
{plans.data && plans.data.items.length > planItems.length && ( {plans.data && plans.data.items.length > planItems.length && (
<p className="border-t border-slate-100 px-3 py-1.5 text-[10px] italic text-slate-400"> <p className="border-t border-slate-100 px-3 py-1.5 text-[10px] italic text-slate-500">
Đã n kế hoạch trạng thái Đã duyệt xem đ menu Kế hoạch kết . Đã n kế hoạch trạng thái Đã duyệt xem đ menu Kế hoạch kết .
</p> </p>
)} )}

View File

@ -33,7 +33,8 @@
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 { Check, History, Paperclip, Send, Trash2, Undo2, Workflow } from 'lucide-react' import { Download, Eye, History, Paperclip, Send, Trash2 } from 'lucide-react'
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'
import { Label } from '@/components/ui/Label' import { Label } from '@/components/ui/Label'
@ -104,6 +105,8 @@ export function KhkkWorkflowPanel({
const [pendingFiles, setPendingFiles] = useState<File[]>([]) const [pendingFiles, setPendingFiles] = useState<File[]>([])
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const [confirmDelete, setConfirmDelete] = useState(false) const [confirmDelete, setConfirmDelete] = useState(false)
// [S175 · B1] File đang xem-trước inline (khuôn PE `PeWorkflowPanel.tsx:60`).
const [previewAtt, setPreviewAtt] = useState<KhkkAttachmentDto | null>(null)
const phase = plan.phase as KhkkPhaseValue const phase = plan.phase as KhkkPhaseValue
const steps = plan.workflowSteps ?? [] const steps = plan.workflowSteps ?? []
@ -174,15 +177,18 @@ export function KhkkWorkflowPanel({
const approvalFiles: KhkkAttachmentDto[] = const approvalFiles: KhkkAttachmentDto[] =
(plan.attachments ?? []).filter(a => a.purpose === KhkkAttachmentPurpose.ApprovalAttachment) (plan.attachments ?? []).filter(a => a.purpose === KhkkAttachmentPurpose.ApprovalAttachment)
// [BE-5] Lịch sử thay đổi — chỉ tải khi người dùng mở khối (không tốn 1 request/phiếu). // [BE-5] Lịch sử thay đổi.
const [showChangelog, setShowChangelog] = useState(false) // 🔴 [S175 · B1] Khối này TRƯỚC ĐÂY gập lại và query `enabled: showChangelog` (lazy —
// tiết-kiệm 1 request/phiếu). Đợt này bỏ gập theo khuôn PE ⇒ **BẮT BUỘC bỏ luôn cờ
// `enabled`**: giữ lại thì khối luôn hiện nhưng query KHÔNG BAO GIỜ chạy, và nó hỏng
// dưới dạng "Chưa có thay đổi nào được ghi." — tức trông y hệt phiếu chưa từng sửa,
// KHÔNG phải trông như lỗi. Đổi lấy: +1 request mỗi lần mở phiếu, đúng giá PE đang trả.
const changelogs = useQuery({ const changelogs = useQuery({
queryKey: ['khkk-changelogs', plan.id], queryKey: ['khkk-changelogs', plan.id],
queryFn: async () => queryFn: async () =>
(await api.get<KhkkChangelogDto[]>(`/contract-signing-plans/${plan.id}/changelogs`, { (await api.get<KhkkChangelogDto[]>(`/contract-signing-plans/${plan.id}/changelogs`, {
params: { take: 200 }, params: { take: 200 },
})).data, })).data,
enabled: showChangelog,
}) })
const transition = useMutation({ const transition = useMutation({
@ -267,41 +273,44 @@ export function KhkkWorkflowPanel({
} }
return ( return (
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-emerald-500)' }}> // [S175 · B1 · X2] Panel TRẦN khuôn PE (`PeWorkflowPanel.tsx:345`). Trước đây bọc
// `card-accent` ⇒ thẻ-trong-thẻ, lệch đập vào mắt trước mọi mục owner liệt kê.
// 🔴 GỠ HẲN CLASS, KHÔNG đè `border-0`/`shadow-none`: `.card-accent` (`index.css:112`)
// nằm NGOÀI `@layer` nên thắng utility Tailwind v4 — đè là vô hiệu (gotcha #66, đã cắn thật).
<div className="space-y-4">
{/* ── 1. Header + meta {code} v{NN} · {name} ───────────────────────────── */} {/* ── 1. Header + meta {code} v{NN} · {name} ───────────────────────────── */}
<header className="flex flex-wrap items-center gap-2 border-b border-slate-100 px-5 py-3"> <div>
<span <h3 className="text-sm font-semibold text-slate-900">Quy trình duyệt</h3>
className="icon-chip"
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: 'var(--color-emerald-50)', ['--chip-fg' as string]: 'var(--color-emerald-600)' }}
aria-hidden
>
<Workflow className="h-4 w-4" />
</span>
<h3 className="text-sm font-semibold tracking-tight text-emerald-700">Quy trình duyệt</h3>
{plan.workflowCode && ( {plan.workflowCode && (
<span className="font-mono text-[11px] text-slate-500"> <p className="mt-0.5 font-mono text-[11px] text-slate-500">
{plan.workflowCode} {plan.workflowCode}
{plan.workflowVersion != null && ` v${plan.workflowVersion}`} {plan.workflowVersion != null && ` v${String(plan.workflowVersion).padStart(2, '0')}`}
{plan.workflowName && <span className="font-sans"> · {plan.workflowName}</span>} {plan.workflowName && <> · <span className="font-sans">{plan.workflowName}</span></>}
</span> </p>
)} )}
</header> </div>
<div className="space-y-4 p-5"> {/* ── 2. Banner kết-thúc-sớm ──────────────────────────────────────────── */}
{/* ── 2. Banner kết-thúc-sớm ──────────────────────────────────────────── */} {/* [S175 · B1] violet → EMERALD khuôn PE (`:360-377`). PE dành violet RIÊNG cho
{plan.endedByLevelFinalize && ( "⚑ Duyệt thay CEO"; dùng violet cả ở đây là trộn 2 nghĩa vào một màu. */}
<div className="rounded border border-violet-300 bg-violet-50 px-3 py-2 text-[11px] text-violet-800"> {plan.endedByLevelFinalize && (
<div className="font-medium"> <div className="rounded-md border border-emerald-300 bg-emerald-50 px-3 py-2 text-[12px] text-emerald-800">
Kết thúc tại {plan.finalizeStepName ?? 'cấp được cấu hình'} {isApproved ? (
{plan.finalizeLevelName ? ` · ${plan.finalizeLevelName}` : ''} <span>
</div> Kế hoạch đã kết thúc tại{' '}
<div className="mt-0.5"> <span className="font-semibold">{plan.finalizeStepName ?? 'cấp duyệt'}</span>
{isApproved {plan.finalizeLevelName ? <> · {plan.finalizeLevelName}</> : null} không qua CEO.
? 'Kế hoạch đã duyệt xong ngay tại cấp này — không qua CEO / Ban Giám đốc.' </span>
: 'Cấp này được cấu hình duyệt là KẾT THÚC — nếu người duyệt tích ô kết thúc thì kế hoạch không qua CEO.'} ) : (
</div> <span>
</div> Quy trình rút gọn: duyệt đến{' '}
)} <span className="font-semibold">{plan.finalizeStepName ?? 'cấp này'}</span>
{plan.finalizeLevelName ? <> · {plan.finalizeLevelName}</> : null} KẾT THÚC,
không trình CEO. Các cấp trước vẫn duyệt như thường.
</span>
)}
</div>
)}
{/* ── 3. Sơ đồ Bước → Cấp → NV ───────────────────────────────────────── */} {/* ── 3. Sơ đồ Bước → Cấp → NV ───────────────────────────────────────── */}
{steps.length > 0 ? ( {steps.length > 0 ? (
@ -317,9 +326,10 @@ export function KhkkWorkflowPanel({
{STATUS_ICON[st]} {STATUS_ICON[st]}
</span> </span>
<span className="text-slate-800">Bước {step.order} {step.name}</span> <span className="text-slate-800">Bước {step.order} {step.name}</span>
{/* Chip PHÒNG của Bước (BE-4 join `Departments`). */} {/* Chip PHÒNG của Bước (BE-4 join `Departments`).
[S175 · B1] viền-xám-nền-trắng → EMERALD đặc khuôn PE (`:406-410`). */}
{step.departmentName && ( {step.departmentName && (
<span className="rounded border border-slate-300 bg-white px-1.5 py-0.5 text-[10px] font-medium text-slate-600"> <span className="rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700">
{step.departmentName} {step.departmentName}
</span> </span>
)} )}
@ -458,11 +468,11 @@ export function KhkkWorkflowPanel({
}} }}
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Duyệt cấp hiện tại'} title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Duyệt cấp hiện tại'}
className={cn( className={cn(
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition', 'rounded border px-2 py-1 text-[11px] font-bold transition',
blockedByLevel ? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400' : 'border-emerald-300 text-emerald-700 hover:bg-emerald-50', blockedByLevel ? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400' : 'border-emerald-300 text-emerald-700 hover:bg-emerald-50',
)} )}
> >
<Check className="h-3 w-3" /> Duyệt Duyệt
</button> </button>
<button <button
type="button" type="button"
@ -470,11 +480,11 @@ export function KhkkWorkflowPanel({
onClick={() => { if (!blockedByLevel) { setComment(''); setPendingFiles([]); setAction(KhkkTransitionAction.Return) } }} onClick={() => { if (!blockedByLevel) { setComment(''); setPendingFiles([]); setAction(KhkkTransitionAction.Return) } }}
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Trả lại người soạn sửa'} title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Trả lại người soạn sửa'}
className={cn( className={cn(
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition', 'rounded border px-2 py-1 text-[11px] font-bold transition',
blockedByLevel ? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400' : 'border-amber-300 text-amber-700 hover:bg-amber-50', blockedByLevel ? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400' : 'border-amber-300 text-amber-700 hover:bg-amber-50',
)} )}
> >
<Undo2 className="h-3 w-3" /> Trả lại Trả lại
</button> </button>
{/* 🧊 [S174 owner] Nút "TỪ CHỐI" ĐÃ GỠ — anh chốt "vậy thì bỏ luôn từ chối {/* 🧊 [S174 owner] Nút "TỪ CHỐI" ĐÃ GỠ — anh chốt "vậy thì bỏ luôn từ chối
~ tương tự PE". Duyệt NCC có code từ-chối nhưng THỰC TẾ không hiện ~ tương tự PE". Duyệt NCC có code từ-chối nhưng THỰC TẾ không hiện
@ -488,7 +498,8 @@ export function KhkkWorkflowPanel({
<button <button
type="button" type="button"
onClick={() => setConfirmDelete(true)} onClick={() => setConfirmDelete(true)}
className="inline-flex items-center gap-1 rounded border border-slate-300 px-2 py-1 text-[11px] font-bold text-slate-600 transition hover:border-red-300 hover:bg-red-50 hover:text-red-700" 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 <Trash2 className="h-3 w-3" /> Xóa phiếu
</button> </button>
@ -498,32 +509,52 @@ export function KhkkWorkflowPanel({
)} )}
{/* ── 5. 📎 File đính kèm khi duyệt — TỰ ẨN khi rỗng (khuôn PE :928-967) ── */} {/* ── 5. 📎 File đính kèm khi duyệt — TỰ ẨN khi rỗng (khuôn PE :928-967) ── */}
{/* [S175 · B1] +`border-t pt-4` +`<h3 text-sm>` +nút `<Eye>` xem trước.
Route BE ĐÃ CÓ `ContractSigningPlansController.cs:231` (`/view`, cùng hình với
PE `:317`) ⇒ chỉ cần truyền `basePath` cho dialog dùng chung.
🔴 Nhãn "Tải xuống" chuyển vào `title=` chứ KHÔNG bỏ: giữ đúng chuỗi đó trong
bundle để phép verify prod còn control-dương soi được. */}
{approvalFiles.length > 0 && ( {approvalFiles.length > 0 && (
<div> <div className="border-t border-slate-200 pt-4">
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-700"> <h3 className="text-sm font-semibold text-slate-900">📎 File đính kèm khi duyệt</h3>
<Paperclip className="h-3.5 w-3.5 text-teal-500" /> <p className="mt-0.5 text-[11px] text-slate-500">File do người duyệt tải lên trong quá trình duyệt.</p>
File đính kèm khi duyệt <div className="mt-2 space-y-1">
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500">
{approvalFiles.length}
</span>
</div>
<ul className="mt-1.5 space-y-1.5">
{approvalFiles.map(a => ( {approvalFiles.map(a => (
<li key={a.id} className="flex items-center justify-between gap-2 rounded border border-slate-200 bg-slate-50/60 px-2.5 py-1.5 text-[11px]"> <div key={a.id} className="flex items-center gap-1.5 rounded bg-slate-50 px-1.5 py-1 text-[11px]">
<div className="min-w-0"> <Paperclip className="h-3 w-3 shrink-0 text-slate-400" />
<div className="truncate font-medium text-slate-700">{a.fileName}</div> <span className="min-w-0 flex-1 truncate text-slate-700" title={a.fileName}>{a.fileName}</span>
<div className="text-slate-500">{formatSize(a.fileSize)} · {formatDateTime(a.createdAt)}</div> <span className="shrink-0 text-[10px] text-slate-400">{formatSize(a.fileSize)}</span>
</div> {isPreviewable(a.fileName) && (
<button
type="button"
onClick={() => setPreviewAtt(a)}
className="shrink-0 rounded px-1 text-violet-600 hover:bg-violet-50"
title="Xem trước"
>
<Eye className="h-3 w-3" />
</button>
)}
<button <button
type="button" type="button"
onClick={() => downloadAttachment(a)} onClick={() => downloadAttachment(a)}
className="shrink-0 text-[11px] font-medium text-brand-600 hover:underline" className="shrink-0 rounded px-1 text-brand-600 hover:bg-brand-50"
title="Tải xuống"
> >
Tải xuống <Download className="h-3 w-3" />
</button> </button>
</li> </div>
))} ))}
</ul> </div>
{previewAtt && (
<AttachmentPreviewDialog
open
basePath="/contract-signing-plans"
evaluationId={plan.id}
attachmentId={previewAtt.id}
fileName={previewAtt.fileName}
onClose={() => setPreviewAtt(null)}
/>
)}
</div> </div>
)} )}
@ -531,61 +562,63 @@ export function KhkkWorkflowPanel({
{/* [B4/D-11] BE ĐÃ LỌC hàng `decision = Pending` (hành-vi GỬI duyệt) ⇒ số (N) ở đây {/* [B4/D-11] BE ĐÃ LỌC hàng `decision = Pending` (hành-vi GỬI duyệt) ⇒ số (N) ở đây
CÙNG NGHĨA với (N) của Duyệt NCC. FE KHÔNG dedupe lại; sự kiện "Gửi duyệt" vẫn CÙNG NGHĨA với (N) của Duyệt NCC. FE KHÔNG dedupe lại; sự kiện "Gửi duyệt" vẫn
còn nguyên ở "Lịch sử thay đổi" bên dưới. */} còn nguyên ở "Lịch sử thay đổi" bên dưới. */}
<div> {/* [S175 · B1] Bỏ pill đếm → `(n)` trong ngoặc · thẻ `bg-slate-50/60 text-[11px]`
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-700"> → `bg-white p-3 text-sm` · mốc-giờ ĐẨY PHẢI bằng `justify-between`.
<Check className="h-3.5 w-3.5 text-emerald-500" /> 🔴 Badge 2-ngả → 3-NGẢ: trước đây MỌI thứ không phải `Reject` đều tô emerald
Lịch sử duyệt ⇒ "Trả lại" (amber ở PE) bị vẽ xanh như đã-duyệt — đọc ngược nghĩa.
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500"> `toPhase` đã có sẵn (`khkk.ts:307`) nên phân được TraLai ⟂ TuChoi ⟂ Duyệt. */}
{approvals.length} <div className="border-t border-slate-200 pt-4">
</span> <h3 className="text-sm font-semibold text-slate-900">Lịch sử duyệt ({approvals.length})</h3>
</div>
{approvals.length === 0 ? ( {approvals.length === 0 ? (
<p className="mt-1.5 text-[11px] italic text-slate-400">Chưa lượt duyệt nào.</p> <p className="mt-1.5 text-[11px] italic text-slate-400">Chưa lượt duyệt nào.</p>
) : ( ) : (
<ul className="mt-1.5 space-y-1.5"> <ul className="mt-2 space-y-1.5">
{approvals.map(a => ( {approvals.map(a => {
<li key={a.id} className="rounded border border-slate-200 bg-slate-50/60 px-2.5 py-1.5 text-[11px]"> const isReject = a.toPhase === KhkkPhase.TuChoi
<div className="flex flex-wrap items-center gap-x-1.5 text-slate-700"> const isReturn = a.toPhase === KhkkPhase.TraLai
<span className={cn( return (
'rounded px-1.5 py-0.5 text-[9px] font-semibold', <li key={a.id} className="rounded border border-slate-200 bg-white p-3 text-sm">
a.decision === KhkkApprovalDecision.Reject ? 'bg-amber-100 text-amber-800' : 'bg-emerald-100 text-emerald-800', <div className="flex flex-wrap items-center justify-between gap-x-2 gap-y-1 text-slate-700">
)}> <div className="flex flex-wrap items-center gap-x-1.5">
{DECISION_LABEL[a.decision] ?? 'Chuyển trạng thái'} <span className={cn(
</span> 'rounded px-1.5 py-0.5 text-[10px] font-semibold',
<span className="font-medium">{a.approvedByFullName ?? '(hệ thống)'}</span> isReject ? 'bg-rose-100 text-rose-800'
<span className="text-slate-400">·</span> : isReturn ? 'bg-amber-100 text-amber-800'
<span className="text-slate-500">{formatDateTime(a.approvedAt)}</span> : 'bg-emerald-100 text-emerald-800',
</div> )}>
<div className="mt-0.5 text-slate-600"> {DECISION_LABEL[a.decision] ?? 'Chuyển trạng thái'}
{a.stepName ? `Bước ${a.stepOrder}${a.stepName}` : 'Cấp duyệt'} </span>
{a.levelOrder != null && ` · Cấp ${a.levelOrder}`} <span className="font-medium">{a.approvedByFullName ?? '(hệ thống)'}</span>
{a.levelName ? ` (${a.levelName})` : ''} </div>
<span className="text-slate-400"> <span className="text-[11px] text-slate-500">{formatDateTime(a.approvedAt)}</span>
{' '}· {KHKK_PHASE_LABELS[a.fromPhase]} {KHKK_PHASE_LABELS[a.toPhase]} </div>
</span> <div className="mt-1 text-[12px] text-slate-600">
</div> {a.stepName ? `Bước ${a.stepOrder}${a.stepName}` : 'Cấp duyệt'}
{a.comment?.trim() && ( {a.levelOrder != null && ` · Cấp ${a.levelOrder}`}
<div className="mt-0.5 whitespace-pre-wrap text-slate-600">{a.comment}</div> {a.levelName ? ` (${a.levelName})` : ''}
)} <span className="text-slate-400">
</li> {' '}· {KHKK_PHASE_LABELS[a.fromPhase]} {KHKK_PHASE_LABELS[a.toPhase]}
))} </span>
</div>
{a.comment?.trim() && (
<div className="mt-1 whitespace-pre-wrap text-[12px] text-slate-600">{a.comment}</div>
)}
</li>
)
})}
</ul> </ul>
)} )}
</div> </div>
{/* ── 7. Lịch sử thay đổi ────────────────────────────────────────────── */} {/* ── 7. Lịch sử thay đổi ────────────────────────────────────────────── */}
<div> {/* [S175 · B1] BỎ GẬP — PE render thẳng (`PeDetailTabs.tsx:787-794`). Nội-dung
<button đã tải sẵn qua query; giấu sau 1 cú bấm chỉ thêm ma-sát, không tiết-kiệm gì. */}
type="button" <div className="border-t border-slate-200 pt-4">
onClick={() => setShowChangelog(v => !v)} <h3 className="flex items-center gap-1.5 text-sm font-semibold text-slate-900">
className="flex w-full items-center gap-1.5 text-xs font-semibold text-slate-700 hover:text-brand-700"
>
<History className="h-3.5 w-3.5 text-slate-400" /> <History className="h-3.5 w-3.5 text-slate-400" />
Lịch sử thay đi Lịch sử thay đi
<span className="ml-auto text-[10px] font-normal text-slate-400">{showChangelog ? 'Ẩn' : 'Xem'}</span> </h3>
</button> <div className="mt-2">
{showChangelog && (
<div className="mt-1.5">
{changelogs.isLoading && <p className="text-[11px] text-slate-400">Đang tải</p>} {changelogs.isLoading && <p className="text-[11px] text-slate-400">Đang tải</p>}
{changelogs.isError && <p className="text-[11px] text-red-600">Không tải đưc lịch sử thay đi.</p>} {changelogs.isError && <p className="text-[11px] text-red-600">Không tải đưc lịch sử thay đi.</p>}
{changelogs.data && changelogs.data.length === 0 && ( {changelogs.data && changelogs.data.length === 0 && (
@ -614,10 +647,8 @@ export function KhkkWorkflowPanel({
</li> </li>
))} ))}
</ul> </ul>
</div> </div>
)}
</div> </div>
</div>
{action !== null && ( {action !== null && (
<Dialog <Dialog
@ -625,7 +656,7 @@ export function KhkkWorkflowPanel({
onClose={() => setAction(null)} onClose={() => setAction(null)}
title={dialogTitle} title={dialogTitle}
footer={<> footer={<>
<Button variant="outline" onClick={() => setAction(null)}>Huỷ</Button> <Button variant="outline" onClick={() => setAction(null)}>Hủy</Button>
<Button onClick={() => transition.mutate(action)} disabled={confirmDisabled}> <Button onClick={() => transition.mutate(action)} disabled={confirmDisabled}>
{transition.isPending ? 'Đang gửi...' : 'Xác nhận'} {transition.isPending ? 'Đang gửi...' : 'Xác nhận'}
</Button> </Button>
@ -663,22 +694,26 @@ export function KhkkWorkflowPanel({
</label> </label>
</div> </div>
)} )}
{/* [S175 · B1 micro-copy] Nhãn Ô NHẬP theo PE (`PeWorkflowPanel.tsx:869` "Ghi chú
(tùy chọn)"). 🔴 CHỈ đổi tên TRƯỜNG — chuỗi "Ý kiến cấp duyệt" ở dòng trợ-giúp
bên dưới là TÊN THẬT của mục đích đến trong phiếu (Section 5, Mig 26), đổi nó
là trỏ người dùng tới một mục không tồn tại. */}
<Label> <Label>
Ý kiến{commentRequired ? <span className="text-red-500"> *</span> : ' (tuỳ chọn)'} Ghi chú{commentRequired ? <span className="text-red-500"> *</span> : ' (tùy chọn)'}
</Label> </Label>
<Textarea <Textarea
value={comment} value={comment}
onChange={e => setComment(e.target.value)} onChange={e => setComment(e.target.value)}
rows={3} rows={3}
maxLength={2000} maxLength={2000}
placeholder={commentRequired ? 'Nêu lý do trả lại / từ chối...' : 'Ý kiến của bạn (ghi vào phiếu)...'} placeholder={commentRequired ? 'Nêu lý do trả lại / từ chối...' : 'Ghi chú của bạn (ghi vào phiếu)...'}
/> />
{/* [QĐ-3 / BE-7] File đính kèm khi duyệt — upload THẬT qua đường riêng {/* [QĐ-3 / BE-7] File đính kèm khi duyệt — upload THẬT qua đường riêng
`POST {id}/approval-attachments` (BE ép `purpose=ApprovalAttachment`). */} `POST {id}/approval-attachments` (BE ép `purpose=ApprovalAttachment`). */}
{action !== KhkkTransitionAction.Submit && ( {action !== KhkkTransitionAction.Submit && (
<div className="mt-3"> <div className="mt-3">
<Label className="text-xs">File đính kèm (tuỳ chọn)</Label> <Label className="text-xs">File đính kèm (tùy chọn)</Label>
<div className="mt-1 flex flex-wrap items-center gap-2"> <div className="mt-1 flex flex-wrap items-center gap-2">
<input <input
ref={fileInputRef} ref={fileInputRef}
@ -714,7 +749,7 @@ export function KhkkWorkflowPanel({
{action === KhkkTransitionAction.Approve && ( {action === KhkkTransitionAction.Approve && (
<p className="mt-1 text-[11px] text-slate-500"> <p className="mt-1 text-[11px] text-slate-500">
Ý kiến sẽ đưc ghi vào mục &ldquo;Ý kiến cấp duyệt&rdquo; của kế hoạch (1 ô/Cấp duyệt lại Ghi chú sẽ đưc ghi vào mục &ldquo;Ý kiến cấp duyệt&rdquo; của kế hoạch (1 ô/Cấp duyệt lại
thì ghi đè ô ). thì ghi đè ô ).
</p> </p>
)} )}
@ -727,7 +762,7 @@ export function KhkkWorkflowPanel({
onClose={() => setConfirmDelete(false)} onClose={() => setConfirmDelete(false)}
title="🗑 Xóa kế hoạch ký kết" title="🗑 Xóa kế hoạch ký kết"
footer={<> footer={<>
<Button variant="outline" onClick={() => setConfirmDelete(false)}>Huỷ</Button> <Button variant="outline" onClick={() => setConfirmDelete(false)}>Hủy</Button>
<Button onClick={() => removePlan.mutate()} disabled={removePlan.isPending}> <Button onClick={() => removePlan.mutate()} disabled={removePlan.isPending}>
{removePlan.isPending ? 'Đang xóa...' : 'Xóa phiếu'} {removePlan.isPending ? 'Đang xóa...' : 'Xóa phiếu'}
</Button> </Button>
@ -739,6 +774,6 @@ export function KhkkWorkflowPanel({
</div> </div>
</Dialog> </Dialog>
)} )}
</section> </div>
) )
} }

View File

@ -15,10 +15,15 @@ export function isPreviewable(fileName: string): boolean {
type Props = { type Props = {
open: boolean open: boolean
/** Id của bản-ghi CHỦ (phiếu PE, hoặc kế-hoạch KHKK khi truyền `basePath`). */
evaluationId: string evaluationId: string
attachmentId: string attachmentId: string
fileName: string fileName: string
onClose: () => void onClose: () => void
/** [S175 B1] Prefix route của module chủ. Mặc-định `/purchase-evaluations` ⇒ **6 call-site
* PE hiện có KHÔNG phải sửa dòng nào** (module đã golive — mở trục mới mà bắt sửa hết
* call-site cũ là tự đẻ rủi-ro ở chỗ đang chạy tốt). KHKK truyền `/contract-signing-plans`. */
basePath?: string
} }
/** Preview file inline qua BE endpoint `/view` (Content-Disposition: inline). /** Preview file inline qua BE endpoint `/view` (Content-Disposition: inline).
@ -29,6 +34,7 @@ type Props = {
* cỡ viewport (Dialog dùng chung chỉ có sm/md/lg → tự render overlay riêng). */ * cỡ viewport (Dialog dùng chung chỉ có sm/md/lg → tự render overlay riêng). */
export function AttachmentPreviewDialog({ export function AttachmentPreviewDialog({
open, evaluationId, attachmentId, fileName, onClose, open, evaluationId, attachmentId, fileName, onClose,
basePath = '/purchase-evaluations',
}: Props) { }: Props) {
const [blobUrl, setBlobUrl] = useState<string | null>(null) const [blobUrl, setBlobUrl] = useState<string | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@ -58,7 +64,7 @@ export function AttachmentPreviewDialog({
setLoading(true) setLoading(true)
setError(null) setError(null)
api.get(`/purchase-evaluations/${evaluationId}/attachments/${attachmentId}/view`, { api.get(`${basePath}/${evaluationId}/attachments/${attachmentId}/view`, {
responseType: 'blob', responseType: 'blob',
}) })
.then(res => { .then(res => {
@ -78,7 +84,7 @@ export function AttachmentPreviewDialog({
if (currentUrl) window.URL.revokeObjectURL(currentUrl) if (currentUrl) window.URL.revokeObjectURL(currentUrl)
setBlobUrl(null) setBlobUrl(null)
} }
}, [open, evaluationId, attachmentId]) }, [open, evaluationId, attachmentId, basePath])
const ready = blobUrl && !loading && !error const ready = blobUrl && !loading && !error

View File

@ -89,6 +89,7 @@ export function PipelineTreePanel({
bodyMaxHClass, bodyMaxHClass,
stage2Options, stage2Options,
hideHeader, hideHeader,
hideSearch,
}: { }: {
currentStage: PipelineStageNo currentStage: PipelineStageNo
className?: string className?: string
@ -107,6 +108,12 @@ export function PipelineTreePanel({
// 🔴 Nút "Hiện/Ẩn cây" (mobile) sống TRONG thanh này và là đường DUY NHẤT mở thân cây khi // 🔴 Nút "Hiện/Ẩn cây" (mobile) sống TRONG thanh này và là đường DUY NHẤT mở thân cây khi
// `<lg`. Vì vậy ẩn thanh ⇒ PHẢI ép thân cây hiện luôn, nếu không mobile mất trắng cây. // `<lg`. Vì vậy ẩn thanh ⇒ PHẢI ép thân cây hiện luôn, nếu không mobile mất trắng cây.
hideHeader?: boolean hideHeader?: boolean
// [S175 · B3] Ẩn ô lọc RIÊNG của cây. CHỈ dùng ở host đã có ô tìm của chính nó ngay trên
// (KHKK GĐ2) — ở đó 2 ô tìm xếp chồng nhau mà nghĩa lại khác: ô ngoài đi SERVER-SIDE và
// gõ vào là chuyển hẳn sang danh-sách phẳng (`flatMode`), nên ô trong cây KHÔNG bao giờ
// dùng tới — người dùng gõ phát nào là cây biến mất phát đó.
// 6 host còn lại KHÔNG truyền ⇒ giữ nguyên ô lọc cây như cũ.
hideSearch?: boolean
}) { }) {
const { canPe, buildStages } = usePipelineStages() const { canPe, buildStages } = usePipelineStages()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
@ -232,16 +239,18 @@ export function PipelineTreePanel({
{/* [S174] `hideHeader` ⇒ không còn nút toggle ⇒ ép `flex` để `<lg` vẫn thấy cây. */} {/* [S174] `hideHeader` ⇒ không còn nút toggle ⇒ ép `flex` để `<lg` vẫn thấy cây. */}
<div className={cn('min-h-0 flex-col', hideHeader || mobileOpen ? 'flex' : 'hidden', 'lg:flex')}> <div className={cn('min-h-0 flex-col', hideHeader || mobileOpen ? 'flex' : 'hidden', 'lg:flex')}>
<div className="relative border-b border-slate-200 p-2"> {!hideSearch && (
<Search className="pointer-events-none absolute left-4 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" /> <div className="relative border-b border-slate-200 p-2">
<input <Search className="pointer-events-none absolute left-4 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
value={search} <input
onChange={e => setSearch(e.target.value)} value={search}
placeholder="Lọc dự án / hạng mục / mã phiếu…" onChange={e => setSearch(e.target.value)}
aria-label="Lọc cây toàn trình" placeholder="Lọc dự án / hạng mục / mã phiếu…"
className="h-8 w-full rounded-md border border-slate-200 bg-white pl-7 pr-2 text-[12px] text-slate-700 placeholder:text-slate-400 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30" aria-label="Lọc cây toàn trình"
/> className="h-8 w-full rounded-md border border-slate-200 bg-white pl-7 pr-2 text-[12px] text-slate-700 placeholder:text-slate-400 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
</div> />
</div>
)}
<div className={cn('overflow-y-auto', bodyMaxHClass ?? 'max-h-[28rem] lg:max-h-[calc(100vh-16rem)]')}> <div className={cn('overflow-y-auto', bodyMaxHClass ?? 'max-h-[28rem] lg:max-h-[calc(100vh-16rem)]')}>
{!canPe && ( {!canPe && (

View File

@ -3,8 +3,14 @@
// //
// [S174 owner] ĐỔI TỪ TRANG-GIẤY-CUỘN SANG HOST 2-PANEL — anh chốt: **"làm giống giao diện // [S174 owner] ĐỔI TỪ TRANG-GIẤY-CUỘN SANG HOST 2-PANEL — anh chốt: **"làm giống giao diện
// thao tác của Duyệt NCC"** (`pages/pe/PurchaseEvaluationWorkspacePage.tsx`). Khuôn bê về: // thao tác của Duyệt NCC"** (`pages/pe/PurchaseEvaluationWorkspacePage.tsx`). Khuôn bê về:
// · header 1 dòng + grid `lg:grid-cols-[300px_1fr]`; // · header 1 dòng + grid `lg:grid-cols-[260px_1fr] xl:grid-cols-[320px_1fr]` — ĐÚNG BYTE
// · Panel 1 = danh sách (`KhkkWorkListPanel`) + nút hành-động dính đáy; // khuôn PE (`PurchaseEvaluationWorkspacePage.tsx:80`). 🧊 Comment cũ ghi `300px` là số
// của bản nháp đầu, KHÔNG bao giờ khớp code sau khi đợt A land 260/320 (S175 vá).
// · Panel 1 = danh sách (`KhkkWorkListPanel`) — 2 tầng, MỖI tầng có header dính ĐỈNH
// (`sticky top-0`) gom theo dự án. 🧊 Comment cũ ghi "nút hành-động dính đáy" là MÔ TẢ
// CỦA PE, không phải của đây: `grep -c Button KhkkWorkListPanel` = **0** (control-dương
// `PeListPanel` = 8). Bê nguyên câu mô tả từ khuôn nguồn sang là tự khai một thứ chưa
// dựng — S175 vá theo L3 H-5.
// · Panel 2 = EmptyState | form tạo | phiếu đang XEM. // · Panel 2 = EmptyState | form tạo | phiếu đang XEM.
// 🔴 Panel 1 chia 2 TẦNG theo đúng ảnh anh khoanh: trên = "Cột danh sách Duyệt NCC" // 🔴 Panel 1 chia 2 TẦNG theo đúng ảnh anh khoanh: trên = "Cột danh sách Duyệt NCC"
// (bấm ⇒ *tạo luôn KHKK*), dưới = "Cột KHKK đã tạo" (bấm ⇒ *chỉ xem*). // (bấm ⇒ *tạo luôn KHKK*), dưới = "Cột KHKK đã tạo" (bấm ⇒ *chỉ xem*).

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, FileCheck, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users, ClipboardList, FileCheck, Lock, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { PePipelineStrip } from '@/components/pe/PePipelineStrip' import { PePipelineStrip } from '@/components/pe/PePipelineStrip'
@ -48,6 +48,21 @@ function formatVnd(n: number | null | undefined): string {
return n.toLocaleString('vi-VN') + ' đ' return n.toLocaleString('vi-VN') + ' đ'
} }
// [S175 · B2 · P0] Ô RỖNG phải MỜ HƠN dữ-liệu thật. Trước đây em-dash thừa-hưởng đúng
// màu chữ của ô (`text-slate-600/700`) ⇒ lướt bảng thì "—" trông y như một GIÁ-TRỊ.
// Gom về một chỗ để các ô rỗng không mỗi nơi một kiểu.
const DASH = <span className="text-slate-300"></span>
/** Chuỗi có thể rỗng → em-dash mờ. */
function orDash(v: string | null | undefined): ReactNode {
return v == null || v === '' ? DASH : v
}
/** Tiền: `formatVnd` trả CHUỖI "—" khi rỗng nên không tô mờ được — tách nhánh rỗng ra. */
function moneyOrDash(n: number | null | undefined): ReactNode {
return n == null ? DASH : formatVnd(n)
}
function formatDateTime(iso: string): string { function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString('vi-VN') return new Date(iso).toLocaleString('vi-VN')
} }
@ -362,17 +377,32 @@ export function KhkkDetailContent({
chipFg="var(--color-teal-700)" chipFg="var(--color-teal-700)"
actions={ actions={
plan.budgetFrozen ? ( plan.budgetFrozen ? (
<span className="inline-flex items-center rounded-md border border-amber-300 bg-amber-100 px-2 py-0.5 text-[11px] font-semibold normal-case tracking-normal text-amber-800"> // [S175 · B2 · P0] emoji 🔒 → icon lucide: emoji render khác nhau theo font hệ
🔒 Giá đã chốt tại thời điểm duyệt // điều-hành (Windows/macOS/Android ra 3 hình), và nó KHÔNG ăn `currentColor`
// nên luôn lệch tông với chữ amber bên cạnh.
<span className="inline-flex items-center gap-1 rounded-md border border-amber-300 bg-amber-100 px-2 py-0.5 text-[11px] font-semibold normal-case tracking-normal text-amber-800">
<Lock className="h-3 w-3" aria-hidden /> Giá đã chốt tại thời điểm duyệt
</span> </span>
) : undefined ) : undefined
} }
> >
{plan.lines.length === 0 ? ( {plan.lines.length === 0 ? (
<div className="text-sm text-muted-foreground">Phiếu nguồn chưa NCC trúng thầu.</div> <div className="text-sm italic text-slate-400">Phiếu nguồn chưa 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ữ.
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full min-w-[880px] table-fixed text-xs">
<colgroup>
<col className="w-[22%]" />
<col className="w-[22%]" />
<col className="w-[14%]" />
<col className="w-[14%]" />
<col className="w-[14%]" />
<col className="w-[14%]" />
</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">Nhà cung cấp</th>
@ -385,16 +415,16 @@ export function KhkkDetailContent({
</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"> <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"> <td className="px-3 py-2 font-medium text-brand-800">
{l.supplierName ?? '—'} {orDash(l.supplierName)}
{l.contractId && ( {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 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> </a>
)} )}
</td> </td>
<td className="px-3 py-2 text-xs text-slate-700"> <td className="px-3 py-2 text-slate-700">
{isDraft && !l.contractId ? ( {isDraft && !l.contractId ? (
<> <>
<select <select
@ -426,12 +456,12 @@ export function KhkkDetailContent({
</div> </div>
)} )}
</> </>
) : l.tenHangMuc ? l.tenHangMuc : <span className="text-slate-300"></span>} ) : orDash(l.tenHangMuc)}
</td> </td>
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{formatVnd(l.peReferenceAmount)}</td> <td className="px-3 py-2 text-right tabular-nums text-slate-600">{moneyOrDash(l.peReferenceAmount)}</td>
<td className="px-3 py-2 text-right tabular-nums text-brand-800">{formatVnd(l.proposedAmount)}</td> <td className="px-3 py-2 text-right tabular-nums text-brand-800">{moneyOrDash(l.proposedAmount)}</td>
<td className="px-3 py-2 text-right tabular-nums text-emerald-700">{formatVnd(l.approvedAmount)}</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-xs text-slate-600">{l.note ?? '—'}</td> <td className="px-3 py-2 text-slate-600">{orDash(l.note)}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@ -462,10 +492,18 @@ export function KhkkDetailContent({
} }
> >
{plan.dossierItems.length === 0 ? ( {plan.dossierItems.length === 0 ? (
<div className="text-sm text-muted-foreground">Chưa khai căn cứ nào.</div> <div className="text-sm italic text-slate-400">Chưa khai căn cứ nào.</div>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full min-w-[880px] table-fixed text-xs">
<colgroup>
<col className="w-[14%]" />
<col className={isDraft ? 'w-[26%]' : 'w-[30%]'} />
<col className="w-[14%]" />
<col className={isDraft ? 'w-[16%]' : 'w-[20%]'} />
<col className={isDraft ? 'w-[18%]' : 'w-[22%]'} />
{isDraft && <col className="w-[12%]" />}
</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">Loại</th> <th className="label-eyebrow px-3 py-2 text-left">Loại</th>
@ -478,19 +516,19 @@ export function KhkkDetailContent({
</thead> </thead>
<tbody> <tbody>
{plan.dossierItems.map((d: KhkkDossierItemDto) => ( {plan.dossierItems.map((d: KhkkDossierItemDto) => (
<tr key={d.id} className="border-b border-slate-100 last:border-0"> <tr key={d.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/60">
<td className="px-3 py-2 text-xs text-slate-600">{DOSSIER_KIND_LABELS[d.kind] ?? '—'}</td> <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 font-medium text-brand-800">{d.name}</td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<span className={cn('inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium', DOSSIER_STATUS_BADGE[d.status])}> <span className={cn('inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium', DOSSIER_STATUS_BADGE[d.status])}>
{DOSSIER_STATUS_LABELS[d.status]} {DOSSIER_STATUS_LABELS[d.status]}
</span> </span>
</td> </td>
<td className="px-3 py-2 text-xs text-slate-600"> <td className="px-3 py-2 text-slate-600">
{d.tvgsName ?? '—'} {orDash(d.tvgsName)}
{d.tvgsResultAt && <span className="ml-1 text-[11px] text-slate-400">({formatDateTime(d.tvgsResultAt)})</span>} {d.tvgsResultAt && <span className="ml-1 text-[11px] text-slate-400">({formatDateTime(d.tvgsResultAt)})</span>}
</td> </td>
<td className="px-3 py-2 text-xs text-slate-600">{d.note ?? '—'}</td> <td className="px-3 py-2 text-slate-600">{orDash(d.note)}</td>
{isDraft && ( {isDraft && (
<td className="px-3 py-2 text-right"> <td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1"> <div className="flex justify-end gap-1">
@ -544,7 +582,7 @@ export function KhkkDetailContent({
} }
> >
{planAttachments.length === 0 ? ( {planAttachments.length === 0 ? (
<div className="text-sm text-muted-foreground">Chưa file đính kèm.</div> <div className="text-sm italic text-slate-400">Chưa file đính kèm.</div>
) : ( ) : (
<ul className="space-y-2"> <ul className="space-y-2">
{planAttachments.map((a) => ( {planAttachments.map((a) => (

View File

@ -23,7 +23,7 @@
// (bật chế-độ danh-sách phẳng), luôn có đường tới phiếu cũ; và dòng cảnh-báo // (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. // "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 { useQuery } from '@tanstack/react-query'
import { FileCheck, Inbox, Search } from 'lucide-react' import { ChevronLeft, ChevronRight, FileCheck, Inbox, ListTree, Search } from 'lucide-react'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel' import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
@ -144,8 +144,12 @@ export function KhkkPlanCard({
</div> </div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500"> <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> <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 && ( {plan.approvalGroup != null && (
<span className="rounded border border-violet-200 bg-violet-50 px-1 text-[9px] font-semibold text-violet-700"> <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} N{plan.approvalGroup}
</span> </span>
)} )}
@ -227,7 +231,7 @@ export function KhkkListPanel({
const shown = group === null ? plans : plans.filter(k => k.approvalGroup === group) const shown = group === null ? plans : plans.filter(k => k.approvalGroup === group)
if (shown.length === 0) { if (shown.length === 0) {
return ( return (
<p className="px-3 py-2 text-[11px] italic text-slate-400"> <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}.`} {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> </p>
) )
@ -275,23 +279,42 @@ export function KhkkListPanel({
))} ))}
</Select> </Select>
{flatMode && !deletedView && canPe && ( {flatMode && !deletedView && canPe && (
<p className="text-[10px] italic text-slate-400"> <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). Đ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> </p>
)} )}
{!canPe && ( {!canPe && (
<p className="text-[10px] italic text-slate-400"> <p className="text-[10px] italic text-slate-500">
Bạn chưa quyền xem phiếu Duyệt NCC nên cây toàn trình không dựng đưc danh Bạn chưa 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ết vẫn liệt đy đ dưới. sách kế hoạch kết vẫn liệt đy đ dưới.
</p> </p>
)} )}
</div> </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"> <div className="flex-1 overflow-y-auto">
{!flatMode && ( {!flatMode && (
<PipelineTreePanel <PipelineTreePanel
currentStage={2} currentStage={2}
hideHeader hideHeader
hideSearch
bodyMaxHClass="max-h-[28rem] lg:max-h-none" bodyMaxHClass="max-h-[28rem] lg:max-h-none"
stage2Options={{ stage2Content, stage2Count, onOpenKhkk: onSelect }} stage2Options={{ stage2Content, stage2Count, onOpenKhkk: onSelect }}
/> />
@ -350,22 +373,28 @@ export function KhkkListPanel({
<FileCheck className="mr-1 inline h-3 w-3" /> <FileCheck className="mr-1 inline h-3 w-3" />
{total} kế hoạch Trang {page} / {totalPages} {total} kế hoạch Trang {page} / {totalPages}
</span> </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"> <div className="flex gap-1">
<button <button
type="button" type="button"
onClick={() => onPageChange(Math.max(1, page - 1))} onClick={() => onPageChange(Math.max(1, page - 1))}
disabled={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" 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>
<button <button
type="button" type="button"
onClick={() => onPageChange(page + 1)} onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages} disabled={page >= totalPages}
className="rounded border border-slate-300 px-2 py-0.5 text-slate-600 hover:bg-slate-50 disabled:opacity-40" 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> </button>
</div> </div>
</div> </div>

View File

@ -171,7 +171,7 @@ export function KhkkWorkListPanel({
</div> </div>
)} )}
{!candidates.isLoading && !candidates.isError && awaiting.length === 0 && ( {!candidates.isLoading && !candidates.isError && awaiting.length === 0 && (
<p className="p-3 text-[11px] italic text-slate-400"> <p className="p-3 text-[11px] italic text-slate-500">
Mọi phiếu đã duyệt đu đã kế hoạch kết. Mọi phiếu đã duyệt đu đã kế hoạch kết.
</p> </p>
)} )}
@ -181,7 +181,7 @@ export function KhkkWorkListPanel({
<span className="min-w-0 flex-1 truncate text-[10px] font-semibold uppercase tracking-wide text-slate-500"> <span className="min-w-0 flex-1 truncate text-[10px] font-semibold uppercase tracking-wide text-slate-500">
{projectName} {projectName}
</span> </span>
<span className="shrink-0 text-[10px] text-slate-400">{items.length}</span> <span className="shrink-0 text-[10px] text-slate-500">{items.length}</span>
</div> </div>
<ul className="divide-y divide-slate-100"> <ul className="divide-y divide-slate-100">
{items.map((pe) => ( {items.map((pe) => (
@ -241,7 +241,7 @@ export function KhkkWorkListPanel({
</div> </div>
)} )}
{!plans.isLoading && !plans.isError && planItems.length === 0 && ( {!plans.isLoading && !plans.isError && planItems.length === 0 && (
<p className="p-3 text-[11px] italic text-slate-400"> <p className="p-3 text-[11px] italic text-slate-500">
Không còn kế hoạch nào đang xử . Phiếu đã duyệt không hiện đây xem tại Không còn kế hoạch nào đang xử . Phiếu đã duyệt không hiện đây xem tại
menu Kế hoạch kết . menu Kế hoạch kết .
</p> </p>
@ -287,7 +287,7 @@ export function KhkkWorkListPanel({
sẽ đọc thành "đang cắt bớt" trong khi thật ra là "đang lọc bỏ". Nói đúng việc sẽ đọc thành "đang cắt bớt" trong khi thật ra là "đang lọc bỏ". Nói đúng việc
đang làm, và chỉ nói khi thật sự có cái bị giấu. */} đang làm, và chỉ nói khi thật sự có cái bị giấu. */}
{plans.data && plans.data.items.length > planItems.length && ( {plans.data && plans.data.items.length > planItems.length && (
<p className="border-t border-slate-100 px-3 py-1.5 text-[10px] italic text-slate-400"> <p className="border-t border-slate-100 px-3 py-1.5 text-[10px] italic text-slate-500">
Đã n kế hoạch trạng thái Đã duyệt xem đ menu Kế hoạch kết . Đã n kế hoạch trạng thái Đã duyệt xem đ menu Kế hoạch kết .
</p> </p>
)} )}

View File

@ -33,7 +33,8 @@
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 { Check, History, Paperclip, Send, Trash2, Undo2, Workflow } from 'lucide-react' import { Download, Eye, History, Paperclip, Send, Trash2 } from 'lucide-react'
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'
import { Label } from '@/components/ui/Label' import { Label } from '@/components/ui/Label'
@ -104,6 +105,8 @@ export function KhkkWorkflowPanel({
const [pendingFiles, setPendingFiles] = useState<File[]>([]) const [pendingFiles, setPendingFiles] = useState<File[]>([])
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const [confirmDelete, setConfirmDelete] = useState(false) const [confirmDelete, setConfirmDelete] = useState(false)
// [S175 · B1] File đang xem-trước inline (khuôn PE `PeWorkflowPanel.tsx:60`).
const [previewAtt, setPreviewAtt] = useState<KhkkAttachmentDto | null>(null)
const phase = plan.phase as KhkkPhaseValue const phase = plan.phase as KhkkPhaseValue
const steps = plan.workflowSteps ?? [] const steps = plan.workflowSteps ?? []
@ -174,15 +177,18 @@ export function KhkkWorkflowPanel({
const approvalFiles: KhkkAttachmentDto[] = const approvalFiles: KhkkAttachmentDto[] =
(plan.attachments ?? []).filter(a => a.purpose === KhkkAttachmentPurpose.ApprovalAttachment) (plan.attachments ?? []).filter(a => a.purpose === KhkkAttachmentPurpose.ApprovalAttachment)
// [BE-5] Lịch sử thay đổi — chỉ tải khi người dùng mở khối (không tốn 1 request/phiếu). // [BE-5] Lịch sử thay đổi.
const [showChangelog, setShowChangelog] = useState(false) // 🔴 [S175 · B1] Khối này TRƯỚC ĐÂY gập lại và query `enabled: showChangelog` (lazy —
// tiết-kiệm 1 request/phiếu). Đợt này bỏ gập theo khuôn PE ⇒ **BẮT BUỘC bỏ luôn cờ
// `enabled`**: giữ lại thì khối luôn hiện nhưng query KHÔNG BAO GIỜ chạy, và nó hỏng
// dưới dạng "Chưa có thay đổi nào được ghi." — tức trông y hệt phiếu chưa từng sửa,
// KHÔNG phải trông như lỗi. Đổi lấy: +1 request mỗi lần mở phiếu, đúng giá PE đang trả.
const changelogs = useQuery({ const changelogs = useQuery({
queryKey: ['khkk-changelogs', plan.id], queryKey: ['khkk-changelogs', plan.id],
queryFn: async () => queryFn: async () =>
(await api.get<KhkkChangelogDto[]>(`/contract-signing-plans/${plan.id}/changelogs`, { (await api.get<KhkkChangelogDto[]>(`/contract-signing-plans/${plan.id}/changelogs`, {
params: { take: 200 }, params: { take: 200 },
})).data, })).data,
enabled: showChangelog,
}) })
const transition = useMutation({ const transition = useMutation({
@ -267,41 +273,44 @@ export function KhkkWorkflowPanel({
} }
return ( return (
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-emerald-500)' }}> // [S175 · B1 · X2] Panel TRẦN khuôn PE (`PeWorkflowPanel.tsx:345`). Trước đây bọc
// `card-accent` ⇒ thẻ-trong-thẻ, lệch đập vào mắt trước mọi mục owner liệt kê.
// 🔴 GỠ HẲN CLASS, KHÔNG đè `border-0`/`shadow-none`: `.card-accent` (`index.css:112`)
// nằm NGOÀI `@layer` nên thắng utility Tailwind v4 — đè là vô hiệu (gotcha #66, đã cắn thật).
<div className="space-y-4">
{/* ── 1. Header + meta {code} v{NN} · {name} ───────────────────────────── */} {/* ── 1. Header + meta {code} v{NN} · {name} ───────────────────────────── */}
<header className="flex flex-wrap items-center gap-2 border-b border-slate-100 px-5 py-3"> <div>
<span <h3 className="text-sm font-semibold text-slate-900">Quy trình duyệt</h3>
className="icon-chip"
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: 'var(--color-emerald-50)', ['--chip-fg' as string]: 'var(--color-emerald-600)' }}
aria-hidden
>
<Workflow className="h-4 w-4" />
</span>
<h3 className="text-sm font-semibold tracking-tight text-emerald-700">Quy trình duyệt</h3>
{plan.workflowCode && ( {plan.workflowCode && (
<span className="font-mono text-[11px] text-slate-500"> <p className="mt-0.5 font-mono text-[11px] text-slate-500">
{plan.workflowCode} {plan.workflowCode}
{plan.workflowVersion != null && ` v${plan.workflowVersion}`} {plan.workflowVersion != null && ` v${String(plan.workflowVersion).padStart(2, '0')}`}
{plan.workflowName && <span className="font-sans"> · {plan.workflowName}</span>} {plan.workflowName && <> · <span className="font-sans">{plan.workflowName}</span></>}
</span> </p>
)} )}
</header> </div>
<div className="space-y-4 p-5"> {/* ── 2. Banner kết-thúc-sớm ──────────────────────────────────────────── */}
{/* ── 2. Banner kết-thúc-sớm ──────────────────────────────────────────── */} {/* [S175 · B1] violet → EMERALD khuôn PE (`:360-377`). PE dành violet RIÊNG cho
{plan.endedByLevelFinalize && ( "⚑ Duyệt thay CEO"; dùng violet cả ở đây là trộn 2 nghĩa vào một màu. */}
<div className="rounded border border-violet-300 bg-violet-50 px-3 py-2 text-[11px] text-violet-800"> {plan.endedByLevelFinalize && (
<div className="font-medium"> <div className="rounded-md border border-emerald-300 bg-emerald-50 px-3 py-2 text-[12px] text-emerald-800">
Kết thúc tại {plan.finalizeStepName ?? 'cấp được cấu hình'} {isApproved ? (
{plan.finalizeLevelName ? ` · ${plan.finalizeLevelName}` : ''} <span>
</div> Kế hoạch đã kết thúc tại{' '}
<div className="mt-0.5"> <span className="font-semibold">{plan.finalizeStepName ?? 'cấp duyệt'}</span>
{isApproved {plan.finalizeLevelName ? <> · {plan.finalizeLevelName}</> : null} không qua CEO.
? 'Kế hoạch đã duyệt xong ngay tại cấp này — không qua CEO / Ban Giám đốc.' </span>
: 'Cấp này được cấu hình duyệt là KẾT THÚC — nếu người duyệt tích ô kết thúc thì kế hoạch không qua CEO.'} ) : (
</div> <span>
</div> Quy trình rút gọn: duyệt đến{' '}
)} <span className="font-semibold">{plan.finalizeStepName ?? 'cấp này'}</span>
{plan.finalizeLevelName ? <> · {plan.finalizeLevelName}</> : null} KẾT THÚC,
không trình CEO. Các cấp trước vẫn duyệt như thường.
</span>
)}
</div>
)}
{/* ── 3. Sơ đồ Bước → Cấp → NV ───────────────────────────────────────── */} {/* ── 3. Sơ đồ Bước → Cấp → NV ───────────────────────────────────────── */}
{steps.length > 0 ? ( {steps.length > 0 ? (
@ -317,9 +326,10 @@ export function KhkkWorkflowPanel({
{STATUS_ICON[st]} {STATUS_ICON[st]}
</span> </span>
<span className="text-slate-800">Bước {step.order} {step.name}</span> <span className="text-slate-800">Bước {step.order} {step.name}</span>
{/* Chip PHÒNG của Bước (BE-4 join `Departments`). */} {/* Chip PHÒNG của Bước (BE-4 join `Departments`).
[S175 · B1] viền-xám-nền-trắng → EMERALD đặc khuôn PE (`:406-410`). */}
{step.departmentName && ( {step.departmentName && (
<span className="rounded border border-slate-300 bg-white px-1.5 py-0.5 text-[10px] font-medium text-slate-600"> <span className="rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700">
{step.departmentName} {step.departmentName}
</span> </span>
)} )}
@ -458,11 +468,11 @@ export function KhkkWorkflowPanel({
}} }}
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Duyệt cấp hiện tại'} title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Duyệt cấp hiện tại'}
className={cn( className={cn(
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition', 'rounded border px-2 py-1 text-[11px] font-bold transition',
blockedByLevel ? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400' : 'border-emerald-300 text-emerald-700 hover:bg-emerald-50', blockedByLevel ? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400' : 'border-emerald-300 text-emerald-700 hover:bg-emerald-50',
)} )}
> >
<Check className="h-3 w-3" /> Duyệt Duyệt
</button> </button>
<button <button
type="button" type="button"
@ -470,11 +480,11 @@ export function KhkkWorkflowPanel({
onClick={() => { if (!blockedByLevel) { setComment(''); setPendingFiles([]); setAction(KhkkTransitionAction.Return) } }} onClick={() => { if (!blockedByLevel) { setComment(''); setPendingFiles([]); setAction(KhkkTransitionAction.Return) } }}
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Trả lại người soạn sửa'} title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Trả lại người soạn sửa'}
className={cn( className={cn(
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition', 'rounded border px-2 py-1 text-[11px] font-bold transition',
blockedByLevel ? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400' : 'border-amber-300 text-amber-700 hover:bg-amber-50', blockedByLevel ? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400' : 'border-amber-300 text-amber-700 hover:bg-amber-50',
)} )}
> >
<Undo2 className="h-3 w-3" /> Trả lại Trả lại
</button> </button>
{/* 🧊 [S174 owner] Nút "TỪ CHỐI" ĐÃ GỠ — anh chốt "vậy thì bỏ luôn từ chối {/* 🧊 [S174 owner] Nút "TỪ CHỐI" ĐÃ GỠ — anh chốt "vậy thì bỏ luôn từ chối
~ tương tự PE". Duyệt NCC có code từ-chối nhưng THỰC TẾ không hiện ~ tương tự PE". Duyệt NCC có code từ-chối nhưng THỰC TẾ không hiện
@ -488,7 +498,8 @@ export function KhkkWorkflowPanel({
<button <button
type="button" type="button"
onClick={() => setConfirmDelete(true)} onClick={() => setConfirmDelete(true)}
className="inline-flex items-center gap-1 rounded border border-slate-300 px-2 py-1 text-[11px] font-bold text-slate-600 transition hover:border-red-300 hover:bg-red-50 hover:text-red-700" 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 <Trash2 className="h-3 w-3" /> Xóa phiếu
</button> </button>
@ -498,32 +509,52 @@ export function KhkkWorkflowPanel({
)} )}
{/* ── 5. 📎 File đính kèm khi duyệt — TỰ ẨN khi rỗng (khuôn PE :928-967) ── */} {/* ── 5. 📎 File đính kèm khi duyệt — TỰ ẨN khi rỗng (khuôn PE :928-967) ── */}
{/* [S175 · B1] +`border-t pt-4` +`<h3 text-sm>` +nút `<Eye>` xem trước.
Route BE ĐÃ CÓ `ContractSigningPlansController.cs:231` (`/view`, cùng hình với
PE `:317`) ⇒ chỉ cần truyền `basePath` cho dialog dùng chung.
🔴 Nhãn "Tải xuống" chuyển vào `title=` chứ KHÔNG bỏ: giữ đúng chuỗi đó trong
bundle để phép verify prod còn control-dương soi được. */}
{approvalFiles.length > 0 && ( {approvalFiles.length > 0 && (
<div> <div className="border-t border-slate-200 pt-4">
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-700"> <h3 className="text-sm font-semibold text-slate-900">📎 File đính kèm khi duyệt</h3>
<Paperclip className="h-3.5 w-3.5 text-teal-500" /> <p className="mt-0.5 text-[11px] text-slate-500">File do người duyệt tải lên trong quá trình duyệt.</p>
File đính kèm khi duyệt <div className="mt-2 space-y-1">
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500">
{approvalFiles.length}
</span>
</div>
<ul className="mt-1.5 space-y-1.5">
{approvalFiles.map(a => ( {approvalFiles.map(a => (
<li key={a.id} className="flex items-center justify-between gap-2 rounded border border-slate-200 bg-slate-50/60 px-2.5 py-1.5 text-[11px]"> <div key={a.id} className="flex items-center gap-1.5 rounded bg-slate-50 px-1.5 py-1 text-[11px]">
<div className="min-w-0"> <Paperclip className="h-3 w-3 shrink-0 text-slate-400" />
<div className="truncate font-medium text-slate-700">{a.fileName}</div> <span className="min-w-0 flex-1 truncate text-slate-700" title={a.fileName}>{a.fileName}</span>
<div className="text-slate-500">{formatSize(a.fileSize)} · {formatDateTime(a.createdAt)}</div> <span className="shrink-0 text-[10px] text-slate-400">{formatSize(a.fileSize)}</span>
</div> {isPreviewable(a.fileName) && (
<button
type="button"
onClick={() => setPreviewAtt(a)}
className="shrink-0 rounded px-1 text-violet-600 hover:bg-violet-50"
title="Xem trước"
>
<Eye className="h-3 w-3" />
</button>
)}
<button <button
type="button" type="button"
onClick={() => downloadAttachment(a)} onClick={() => downloadAttachment(a)}
className="shrink-0 text-[11px] font-medium text-brand-600 hover:underline" className="shrink-0 rounded px-1 text-brand-600 hover:bg-brand-50"
title="Tải xuống"
> >
Tải xuống <Download className="h-3 w-3" />
</button> </button>
</li> </div>
))} ))}
</ul> </div>
{previewAtt && (
<AttachmentPreviewDialog
open
basePath="/contract-signing-plans"
evaluationId={plan.id}
attachmentId={previewAtt.id}
fileName={previewAtt.fileName}
onClose={() => setPreviewAtt(null)}
/>
)}
</div> </div>
)} )}
@ -531,61 +562,63 @@ export function KhkkWorkflowPanel({
{/* [B4/D-11] BE ĐÃ LỌC hàng `decision = Pending` (hành-vi GỬI duyệt) ⇒ số (N) ở đây {/* [B4/D-11] BE ĐÃ LỌC hàng `decision = Pending` (hành-vi GỬI duyệt) ⇒ số (N) ở đây
CÙNG NGHĨA với (N) của Duyệt NCC. FE KHÔNG dedupe lại; sự kiện "Gửi duyệt" vẫn CÙNG NGHĨA với (N) của Duyệt NCC. FE KHÔNG dedupe lại; sự kiện "Gửi duyệt" vẫn
còn nguyên ở "Lịch sử thay đổi" bên dưới. */} còn nguyên ở "Lịch sử thay đổi" bên dưới. */}
<div> {/* [S175 · B1] Bỏ pill đếm → `(n)` trong ngoặc · thẻ `bg-slate-50/60 text-[11px]`
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-700"> → `bg-white p-3 text-sm` · mốc-giờ ĐẨY PHẢI bằng `justify-between`.
<Check className="h-3.5 w-3.5 text-emerald-500" /> 🔴 Badge 2-ngả → 3-NGẢ: trước đây MỌI thứ không phải `Reject` đều tô emerald
Lịch sử duyệt ⇒ "Trả lại" (amber ở PE) bị vẽ xanh như đã-duyệt — đọc ngược nghĩa.
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500"> `toPhase` đã có sẵn (`khkk.ts:307`) nên phân được TraLai ⟂ TuChoi ⟂ Duyệt. */}
{approvals.length} <div className="border-t border-slate-200 pt-4">
</span> <h3 className="text-sm font-semibold text-slate-900">Lịch sử duyệt ({approvals.length})</h3>
</div>
{approvals.length === 0 ? ( {approvals.length === 0 ? (
<p className="mt-1.5 text-[11px] italic text-slate-400">Chưa lượt duyệt nào.</p> <p className="mt-1.5 text-[11px] italic text-slate-400">Chưa lượt duyệt nào.</p>
) : ( ) : (
<ul className="mt-1.5 space-y-1.5"> <ul className="mt-2 space-y-1.5">
{approvals.map(a => ( {approvals.map(a => {
<li key={a.id} className="rounded border border-slate-200 bg-slate-50/60 px-2.5 py-1.5 text-[11px]"> const isReject = a.toPhase === KhkkPhase.TuChoi
<div className="flex flex-wrap items-center gap-x-1.5 text-slate-700"> const isReturn = a.toPhase === KhkkPhase.TraLai
<span className={cn( return (
'rounded px-1.5 py-0.5 text-[9px] font-semibold', <li key={a.id} className="rounded border border-slate-200 bg-white p-3 text-sm">
a.decision === KhkkApprovalDecision.Reject ? 'bg-amber-100 text-amber-800' : 'bg-emerald-100 text-emerald-800', <div className="flex flex-wrap items-center justify-between gap-x-2 gap-y-1 text-slate-700">
)}> <div className="flex flex-wrap items-center gap-x-1.5">
{DECISION_LABEL[a.decision] ?? 'Chuyển trạng thái'} <span className={cn(
</span> 'rounded px-1.5 py-0.5 text-[10px] font-semibold',
<span className="font-medium">{a.approvedByFullName ?? '(hệ thống)'}</span> isReject ? 'bg-rose-100 text-rose-800'
<span className="text-slate-400">·</span> : isReturn ? 'bg-amber-100 text-amber-800'
<span className="text-slate-500">{formatDateTime(a.approvedAt)}</span> : 'bg-emerald-100 text-emerald-800',
</div> )}>
<div className="mt-0.5 text-slate-600"> {DECISION_LABEL[a.decision] ?? 'Chuyển trạng thái'}
{a.stepName ? `Bước ${a.stepOrder}${a.stepName}` : 'Cấp duyệt'} </span>
{a.levelOrder != null && ` · Cấp ${a.levelOrder}`} <span className="font-medium">{a.approvedByFullName ?? '(hệ thống)'}</span>
{a.levelName ? ` (${a.levelName})` : ''} </div>
<span className="text-slate-400"> <span className="text-[11px] text-slate-500">{formatDateTime(a.approvedAt)}</span>
{' '}· {KHKK_PHASE_LABELS[a.fromPhase]} {KHKK_PHASE_LABELS[a.toPhase]} </div>
</span> <div className="mt-1 text-[12px] text-slate-600">
</div> {a.stepName ? `Bước ${a.stepOrder}${a.stepName}` : 'Cấp duyệt'}
{a.comment?.trim() && ( {a.levelOrder != null && ` · Cấp ${a.levelOrder}`}
<div className="mt-0.5 whitespace-pre-wrap text-slate-600">{a.comment}</div> {a.levelName ? ` (${a.levelName})` : ''}
)} <span className="text-slate-400">
</li> {' '}· {KHKK_PHASE_LABELS[a.fromPhase]} {KHKK_PHASE_LABELS[a.toPhase]}
))} </span>
</div>
{a.comment?.trim() && (
<div className="mt-1 whitespace-pre-wrap text-[12px] text-slate-600">{a.comment}</div>
)}
</li>
)
})}
</ul> </ul>
)} )}
</div> </div>
{/* ── 7. Lịch sử thay đổi ────────────────────────────────────────────── */} {/* ── 7. Lịch sử thay đổi ────────────────────────────────────────────── */}
<div> {/* [S175 · B1] BỎ GẬP — PE render thẳng (`PeDetailTabs.tsx:787-794`). Nội-dung
<button đã tải sẵn qua query; giấu sau 1 cú bấm chỉ thêm ma-sát, không tiết-kiệm gì. */}
type="button" <div className="border-t border-slate-200 pt-4">
onClick={() => setShowChangelog(v => !v)} <h3 className="flex items-center gap-1.5 text-sm font-semibold text-slate-900">
className="flex w-full items-center gap-1.5 text-xs font-semibold text-slate-700 hover:text-brand-700"
>
<History className="h-3.5 w-3.5 text-slate-400" /> <History className="h-3.5 w-3.5 text-slate-400" />
Lịch sử thay đi Lịch sử thay đi
<span className="ml-auto text-[10px] font-normal text-slate-400">{showChangelog ? 'Ẩn' : 'Xem'}</span> </h3>
</button> <div className="mt-2">
{showChangelog && (
<div className="mt-1.5">
{changelogs.isLoading && <p className="text-[11px] text-slate-400">Đang tải</p>} {changelogs.isLoading && <p className="text-[11px] text-slate-400">Đang tải</p>}
{changelogs.isError && <p className="text-[11px] text-red-600">Không tải đưc lịch sử thay đi.</p>} {changelogs.isError && <p className="text-[11px] text-red-600">Không tải đưc lịch sử thay đi.</p>}
{changelogs.data && changelogs.data.length === 0 && ( {changelogs.data && changelogs.data.length === 0 && (
@ -614,10 +647,8 @@ export function KhkkWorkflowPanel({
</li> </li>
))} ))}
</ul> </ul>
</div> </div>
)}
</div> </div>
</div>
{action !== null && ( {action !== null && (
<Dialog <Dialog
@ -625,7 +656,7 @@ export function KhkkWorkflowPanel({
onClose={() => setAction(null)} onClose={() => setAction(null)}
title={dialogTitle} title={dialogTitle}
footer={<> footer={<>
<Button variant="outline" onClick={() => setAction(null)}>Huỷ</Button> <Button variant="outline" onClick={() => setAction(null)}>Hủy</Button>
<Button onClick={() => transition.mutate(action)} disabled={confirmDisabled}> <Button onClick={() => transition.mutate(action)} disabled={confirmDisabled}>
{transition.isPending ? 'Đang gửi...' : 'Xác nhận'} {transition.isPending ? 'Đang gửi...' : 'Xác nhận'}
</Button> </Button>
@ -663,22 +694,26 @@ export function KhkkWorkflowPanel({
</label> </label>
</div> </div>
)} )}
{/* [S175 · B1 micro-copy] Nhãn Ô NHẬP theo PE (`PeWorkflowPanel.tsx:869` "Ghi chú
(tùy chọn)"). 🔴 CHỈ đổi tên TRƯỜNG — chuỗi "Ý kiến cấp duyệt" ở dòng trợ-giúp
bên dưới là TÊN THẬT của mục đích đến trong phiếu (Section 5, Mig 26), đổi nó
là trỏ người dùng tới một mục không tồn tại. */}
<Label> <Label>
Ý kiến{commentRequired ? <span className="text-red-500"> *</span> : ' (tuỳ chọn)'} Ghi chú{commentRequired ? <span className="text-red-500"> *</span> : ' (tùy chọn)'}
</Label> </Label>
<Textarea <Textarea
value={comment} value={comment}
onChange={e => setComment(e.target.value)} onChange={e => setComment(e.target.value)}
rows={3} rows={3}
maxLength={2000} maxLength={2000}
placeholder={commentRequired ? 'Nêu lý do trả lại / từ chối...' : 'Ý kiến của bạn (ghi vào phiếu)...'} placeholder={commentRequired ? 'Nêu lý do trả lại / từ chối...' : 'Ghi chú của bạn (ghi vào phiếu)...'}
/> />
{/* [QĐ-3 / BE-7] File đính kèm khi duyệt — upload THẬT qua đường riêng {/* [QĐ-3 / BE-7] File đính kèm khi duyệt — upload THẬT qua đường riêng
`POST {id}/approval-attachments` (BE ép `purpose=ApprovalAttachment`). */} `POST {id}/approval-attachments` (BE ép `purpose=ApprovalAttachment`). */}
{action !== KhkkTransitionAction.Submit && ( {action !== KhkkTransitionAction.Submit && (
<div className="mt-3"> <div className="mt-3">
<Label className="text-xs">File đính kèm (tuỳ chọn)</Label> <Label className="text-xs">File đính kèm (tùy chọn)</Label>
<div className="mt-1 flex flex-wrap items-center gap-2"> <div className="mt-1 flex flex-wrap items-center gap-2">
<input <input
ref={fileInputRef} ref={fileInputRef}
@ -714,7 +749,7 @@ export function KhkkWorkflowPanel({
{action === KhkkTransitionAction.Approve && ( {action === KhkkTransitionAction.Approve && (
<p className="mt-1 text-[11px] text-slate-500"> <p className="mt-1 text-[11px] text-slate-500">
Ý kiến sẽ đưc ghi vào mục &ldquo;Ý kiến cấp duyệt&rdquo; của kế hoạch (1 ô/Cấp duyệt lại Ghi chú sẽ đưc ghi vào mục &ldquo;Ý kiến cấp duyệt&rdquo; của kế hoạch (1 ô/Cấp duyệt lại
thì ghi đè ô ). thì ghi đè ô ).
</p> </p>
)} )}
@ -727,7 +762,7 @@ export function KhkkWorkflowPanel({
onClose={() => setConfirmDelete(false)} onClose={() => setConfirmDelete(false)}
title="🗑 Xóa kế hoạch ký kết" title="🗑 Xóa kế hoạch ký kết"
footer={<> footer={<>
<Button variant="outline" onClick={() => setConfirmDelete(false)}>Huỷ</Button> <Button variant="outline" onClick={() => setConfirmDelete(false)}>Hủy</Button>
<Button onClick={() => removePlan.mutate()} disabled={removePlan.isPending}> <Button onClick={() => removePlan.mutate()} disabled={removePlan.isPending}>
{removePlan.isPending ? 'Đang xóa...' : 'Xóa phiếu'} {removePlan.isPending ? 'Đang xóa...' : 'Xóa phiếu'}
</Button> </Button>
@ -739,6 +774,6 @@ export function KhkkWorkflowPanel({
</div> </div>
</Dialog> </Dialog>
)} )}
</section> </div>
) )
} }

View File

@ -15,10 +15,15 @@ export function isPreviewable(fileName: string): boolean {
type Props = { type Props = {
open: boolean open: boolean
/** Id của bản-ghi CHỦ (phiếu PE, hoặc kế-hoạch KHKK khi truyền `basePath`). */
evaluationId: string evaluationId: string
attachmentId: string attachmentId: string
fileName: string fileName: string
onClose: () => void onClose: () => void
/** [S175 B1] Prefix route của module chủ. Mặc-định `/purchase-evaluations` ⇒ **6 call-site
* PE hiện có KHÔNG phải sửa dòng nào** (module đã golive — mở trục mới mà bắt sửa hết
* call-site cũ là tự đẻ rủi-ro ở chỗ đang chạy tốt). KHKK truyền `/contract-signing-plans`. */
basePath?: string
} }
/** Preview file inline qua BE endpoint `/view` (Content-Disposition: inline). /** Preview file inline qua BE endpoint `/view` (Content-Disposition: inline).
@ -29,6 +34,7 @@ type Props = {
* cỡ viewport (Dialog dùng chung chỉ có sm/md/lg → tự render overlay riêng). */ * cỡ viewport (Dialog dùng chung chỉ có sm/md/lg → tự render overlay riêng). */
export function AttachmentPreviewDialog({ export function AttachmentPreviewDialog({
open, evaluationId, attachmentId, fileName, onClose, open, evaluationId, attachmentId, fileName, onClose,
basePath = '/purchase-evaluations',
}: Props) { }: Props) {
const [blobUrl, setBlobUrl] = useState<string | null>(null) const [blobUrl, setBlobUrl] = useState<string | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@ -58,7 +64,7 @@ export function AttachmentPreviewDialog({
setLoading(true) setLoading(true)
setError(null) setError(null)
api.get(`/purchase-evaluations/${evaluationId}/attachments/${attachmentId}/view`, { api.get(`${basePath}/${evaluationId}/attachments/${attachmentId}/view`, {
responseType: 'blob', responseType: 'blob',
}) })
.then(res => { .then(res => {
@ -78,7 +84,7 @@ export function AttachmentPreviewDialog({
if (currentUrl) window.URL.revokeObjectURL(currentUrl) if (currentUrl) window.URL.revokeObjectURL(currentUrl)
setBlobUrl(null) setBlobUrl(null)
} }
}, [open, evaluationId, attachmentId]) }, [open, evaluationId, attachmentId, basePath])
const ready = blobUrl && !loading && !error const ready = blobUrl && !loading && !error

View File

@ -89,6 +89,7 @@ export function PipelineTreePanel({
bodyMaxHClass, bodyMaxHClass,
stage2Options, stage2Options,
hideHeader, hideHeader,
hideSearch,
}: { }: {
currentStage: PipelineStageNo currentStage: PipelineStageNo
className?: string className?: string
@ -107,6 +108,12 @@ export function PipelineTreePanel({
// 🔴 Nút "Hiện/Ẩn cây" (mobile) sống TRONG thanh này và là đường DUY NHẤT mở thân cây khi // 🔴 Nút "Hiện/Ẩn cây" (mobile) sống TRONG thanh này và là đường DUY NHẤT mở thân cây khi
// `<lg`. Vì vậy ẩn thanh ⇒ PHẢI ép thân cây hiện luôn, nếu không mobile mất trắng cây. // `<lg`. Vì vậy ẩn thanh ⇒ PHẢI ép thân cây hiện luôn, nếu không mobile mất trắng cây.
hideHeader?: boolean hideHeader?: boolean
// [S175 · B3] Ẩn ô lọc RIÊNG của cây. CHỈ dùng ở host đã có ô tìm của chính nó ngay trên
// (KHKK GĐ2) — ở đó 2 ô tìm xếp chồng nhau mà nghĩa lại khác: ô ngoài đi SERVER-SIDE và
// gõ vào là chuyển hẳn sang danh-sách phẳng (`flatMode`), nên ô trong cây KHÔNG bao giờ
// dùng tới — người dùng gõ phát nào là cây biến mất phát đó.
// 6 host còn lại KHÔNG truyền ⇒ giữ nguyên ô lọc cây như cũ.
hideSearch?: boolean
}) { }) {
const { canPe, buildStages } = usePipelineStages() const { canPe, buildStages } = usePipelineStages()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
@ -232,16 +239,18 @@ export function PipelineTreePanel({
{/* [S174] `hideHeader` ⇒ không còn nút toggle ⇒ ép `flex` để `<lg` vẫn thấy cây. */} {/* [S174] `hideHeader` ⇒ không còn nút toggle ⇒ ép `flex` để `<lg` vẫn thấy cây. */}
<div className={cn('min-h-0 flex-col', hideHeader || mobileOpen ? 'flex' : 'hidden', 'lg:flex')}> <div className={cn('min-h-0 flex-col', hideHeader || mobileOpen ? 'flex' : 'hidden', 'lg:flex')}>
<div className="relative border-b border-slate-200 p-2"> {!hideSearch && (
<Search className="pointer-events-none absolute left-4 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" /> <div className="relative border-b border-slate-200 p-2">
<input <Search className="pointer-events-none absolute left-4 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
value={search} <input
onChange={e => setSearch(e.target.value)} value={search}
placeholder="Lọc dự án / hạng mục / mã phiếu…" onChange={e => setSearch(e.target.value)}
aria-label="Lọc cây toàn trình" placeholder="Lọc dự án / hạng mục / mã phiếu…"
className="h-8 w-full rounded-md border border-slate-200 bg-white pl-7 pr-2 text-[12px] text-slate-700 placeholder:text-slate-400 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30" aria-label="Lọc cây toàn trình"
/> className="h-8 w-full rounded-md border border-slate-200 bg-white pl-7 pr-2 text-[12px] text-slate-700 placeholder:text-slate-400 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
</div> />
</div>
)}
<div className={cn('overflow-y-auto', bodyMaxHClass ?? 'max-h-[28rem] lg:max-h-[calc(100vh-16rem)]')}> <div className={cn('overflow-y-auto', bodyMaxHClass ?? 'max-h-[28rem] lg:max-h-[calc(100vh-16rem)]')}>
{!canPe && ( {!canPe && (

View File

@ -3,8 +3,14 @@
// //
// [S174 owner] ĐỔI TỪ TRANG-GIẤY-CUỘN SANG HOST 2-PANEL — anh chốt: **"làm giống giao diện // [S174 owner] ĐỔI TỪ TRANG-GIẤY-CUỘN SANG HOST 2-PANEL — anh chốt: **"làm giống giao diện
// thao tác của Duyệt NCC"** (`pages/pe/PurchaseEvaluationWorkspacePage.tsx`). Khuôn bê về: // thao tác của Duyệt NCC"** (`pages/pe/PurchaseEvaluationWorkspacePage.tsx`). Khuôn bê về:
// · header 1 dòng + grid `lg:grid-cols-[300px_1fr]`; // · header 1 dòng + grid `lg:grid-cols-[260px_1fr] xl:grid-cols-[320px_1fr]` — ĐÚNG BYTE
// · Panel 1 = danh sách (`KhkkWorkListPanel`) + nút hành-động dính đáy; // khuôn PE (`PurchaseEvaluationWorkspacePage.tsx:80`). 🧊 Comment cũ ghi `300px` là số
// của bản nháp đầu, KHÔNG bao giờ khớp code sau khi đợt A land 260/320 (S175 vá).
// · Panel 1 = danh sách (`KhkkWorkListPanel`) — 2 tầng, MỖI tầng có header dính ĐỈNH
// (`sticky top-0`) gom theo dự án. 🧊 Comment cũ ghi "nút hành-động dính đáy" là MÔ TẢ
// CỦA PE, không phải của đây: `grep -c Button KhkkWorkListPanel` = **0** (control-dương
// `PeListPanel` = 8). Bê nguyên câu mô tả từ khuôn nguồn sang là tự khai một thứ chưa
// dựng — S175 vá theo L3 H-5.
// · Panel 2 = EmptyState | form tạo | phiếu đang XEM. // · Panel 2 = EmptyState | form tạo | phiếu đang XEM.
// 🔴 Panel 1 chia 2 TẦNG theo đúng ảnh anh khoanh: trên = "Cột danh sách Duyệt NCC" // 🔴 Panel 1 chia 2 TẦNG theo đúng ảnh anh khoanh: trên = "Cột danh sách Duyệt NCC"
// (bấm ⇒ *tạo luôn KHKK*), dưới = "Cột KHKK đã tạo" (bấm ⇒ *chỉ xem*). // (bấm ⇒ *tạo luôn KHKK*), dưới = "Cột KHKK đã tạo" (bấm ⇒ *chỉ xem*).