[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
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:
@ -21,7 +21,7 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
ClipboardList, FileCheck, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
|
||||
ClipboardList, FileCheck, Lock, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { PePipelineStrip } from '@/components/pe/PePipelineStrip'
|
||||
@ -48,6 +48,21 @@ function formatVnd(n: number | null | undefined): string {
|
||||
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 {
|
||||
return new Date(iso).toLocaleString('vi-VN')
|
||||
}
|
||||
@ -362,17 +377,32 @@ export function KhkkDetailContent({
|
||||
chipFg="var(--color-teal-700)"
|
||||
actions={
|
||||
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">
|
||||
🔒 Giá đã chốt tại thời điểm duyệt
|
||||
// [S175 · B2 · P0] emoji 🔒 → icon lucide: emoji render khác nhau theo font hệ
|
||||
// đ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>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{plan.lines.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">Phiếu nguồn chưa có NCC trúng thầu.</div>
|
||||
<div className="text-sm italic text-slate-400">Phiếu nguồn chưa có NCC trúng thầu.</div>
|
||||
) : (
|
||||
// [S175 · B2 · P0] `table-fixed` + `<colgroup>` + `min-w-[880px]`: bảng co giãn tự-do
|
||||
// trước đây để cột tiền nhảy bề rộng theo độ dài tên NCC ⇒ 3 cột số không thẳng hàng
|
||||
// giữa các phiếu. Chốt bề rộng rồi thì hẹp màn hình sẽ CUỘN NGANG (đúng ý đồ của
|
||||
// `overflow-x-auto` bọc ngoài) thay vì bóp chữ.
|
||||
<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">
|
||||
<tr>
|
||||
<th className="label-eyebrow px-3 py-2 text-left">Nhà cung cấp</th>
|
||||
@ -385,16 +415,16 @@ export function KhkkDetailContent({
|
||||
</thead>
|
||||
<tbody>
|
||||
{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">
|
||||
{l.supplierName ?? '—'}
|
||||
{orDash(l.supplierName)}
|
||||
{l.contractId && (
|
||||
<a href={`/contracts/${l.contractId}`} className="ml-2 text-[10px] font-medium text-brand-600 hover:underline" title="Dòng này đã được đưa vào hợp đồng">
|
||||
✓ HĐ
|
||||
</a>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-700">
|
||||
<td className="px-3 py-2 text-slate-700">
|
||||
{isDraft && !l.contractId ? (
|
||||
<>
|
||||
<select
|
||||
@ -426,12 +456,12 @@ export function KhkkDetailContent({
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : l.tenHangMuc ? l.tenHangMuc : <span className="text-slate-300">—</span>}
|
||||
) : orDash(l.tenHangMuc)}
|
||||
</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-brand-800">{formatVnd(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-xs text-slate-600">{l.note ?? '—'}</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">{moneyOrDash(l.proposedAmount)}</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-slate-600">{orDash(l.note)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@ -462,10 +492,18 @@ export function KhkkDetailContent({
|
||||
}
|
||||
>
|
||||
{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">
|
||||
<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">
|
||||
<tr>
|
||||
<th className="label-eyebrow px-3 py-2 text-left">Loại</th>
|
||||
@ -478,19 +516,19 @@ export function KhkkDetailContent({
|
||||
</thead>
|
||||
<tbody>
|
||||
{plan.dossierItems.map((d: KhkkDossierItemDto) => (
|
||||
<tr key={d.id} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-3 py-2 text-xs text-slate-600">{DOSSIER_KIND_LABELS[d.kind] ?? '—'}</td>
|
||||
<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-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">
|
||||
<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]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-600">
|
||||
{d.tvgsName ?? '—'}
|
||||
<td className="px-3 py-2 text-slate-600">
|
||||
{orDash(d.tvgsName)}
|
||||
{d.tvgsResultAt && <span className="ml-1 text-[11px] text-slate-400">({formatDateTime(d.tvgsResultAt)})</span>}
|
||||
</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 && (
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
@ -544,7 +582,7 @@ export function KhkkDetailContent({
|
||||
}
|
||||
>
|
||||
{planAttachments.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">Chưa có file đính kèm.</div>
|
||||
<div className="text-sm italic text-slate-400">Chưa có file đính kèm.</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{planAttachments.map((a) => (
|
||||
|
||||
@ -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
|
||||
// "Cây tải 200 phiếu mới nhất…" của cây KHÔNG bị nuốt.
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { FileCheck, Inbox, Search } from 'lucide-react'
|
||||
import { ChevronLeft, ChevronRight, FileCheck, Inbox, ListTree, Search } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
|
||||
@ -144,8 +144,12 @@ export function KhkkPlanCard({
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
|
||||
<span className="font-mono">{plan.maKeHoach ?? '(chưa cấp mã)'}</span>
|
||||
{/* [S175 · B3] Chip nhóm: `text-[9px]` là nấc thứ TƯ của thang chữ (13/11/10/9) —
|
||||
gom về 3 nấc chuẩn 13/11/10. Và `inline` + cỡ chữ khác dòng chứa ⇒ hộp chip cao
|
||||
khác chữ bên cạnh nên tụt baseline; `inline-flex items-center leading-none` ghim
|
||||
nó thẳng hàng thay vì dựa vào baseline của 2 cỡ chữ khác nhau. */}
|
||||
{plan.approvalGroup != null && (
|
||||
<span className="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}
|
||||
</span>
|
||||
)}
|
||||
@ -227,7 +231,7 @@ export function KhkkListPanel({
|
||||
const shown = group === null ? plans : plans.filter(k => k.approvalGroup === group)
|
||||
if (shown.length === 0) {
|
||||
return (
|
||||
<p className="px-3 py-2 text-[11px] italic text-slate-400">
|
||||
<p className="px-3 py-2 text-[11px] italic text-slate-500">
|
||||
{group === null ? 'Gói này chưa có kế hoạch ký kết.' : `Gói này chưa có phiếu nhóm N${group}.`}
|
||||
</p>
|
||||
)
|
||||
@ -275,23 +279,42 @@ export function KhkkListPanel({
|
||||
))}
|
||||
</Select>
|
||||
{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).
|
||||
</p>
|
||||
)}
|
||||
{!canPe && (
|
||||
<p className="text-[10px] italic text-slate-400">
|
||||
<p className="text-[10px] italic text-slate-500">
|
||||
Bạn chưa có quyền xem phiếu Duyệt NCC nên cây toàn trình không dựng được — danh
|
||||
sách kế hoạch ký kết vẫn liệt kê đầy đủ ở dưới.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* [S175 · B3] ĐẦU CỘT — trước đây chế-độ CÂY đi thẳng vào thân, không tiêu-đề cũng
|
||||
không số đếm ⇒ cột "cụt đầu": lệch cả với màn Thao tác (`KhkkWorkListPanel` có
|
||||
`Tier` header) LẪN với chính chế-độ phẳng (có dòng đếm ở chân). Mirror đúng khuôn
|
||||
`Tier` để 2 màn KHKK cùng một hình. */}
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-slate-200 bg-slate-50/80 px-3 py-2">
|
||||
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded bg-brand-100 text-brand-700" aria-hidden>
|
||||
{flatMode ? <FileCheck className="h-3 w-3" /> : <ListTree className="h-3 w-3" />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] font-bold uppercase tracking-wide text-slate-600">
|
||||
{deletedView ? 'Phiếu đã xóa' : flatMode ? 'Danh sách kế hoạch' : 'Cây toàn trình'}
|
||||
</span>
|
||||
{flatMode && !list.isLoading && !list.isError && (
|
||||
<span className="shrink-0 rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium tabular-nums text-slate-600">
|
||||
{total}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{!flatMode && (
|
||||
<PipelineTreePanel
|
||||
currentStage={2}
|
||||
hideHeader
|
||||
hideSearch
|
||||
bodyMaxHClass="max-h-[28rem] lg:max-h-none"
|
||||
stage2Options={{ stage2Content, stage2Count, onOpenKhkk: onSelect }}
|
||||
/>
|
||||
@ -350,22 +373,28 @@ export function KhkkListPanel({
|
||||
<FileCheck className="mr-1 inline h-3 w-3" />
|
||||
{total} kế hoạch — Trang {page} / {totalPages}
|
||||
</span>
|
||||
{/* [S175 · B3] Ký-tự trần `←`/`→` → icon: mũi tên dạng chữ đổi hình theo font hệ
|
||||
điều-hành và KHÔNG có tên cho trình đọc màn hình. Icon + `aria-label` sửa cả hai. */}
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
disabled={page <= 1}
|
||||
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
|
||||
type="button"
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
className="rounded border border-slate-300 px-2 py-0.5 text-slate-600 hover:bg-slate-50 disabled:opacity-40"
|
||||
aria-label="Trang sau"
|
||||
title="Trang sau"
|
||||
className="rounded border border-slate-300 p-1 text-slate-600 hover:bg-slate-50 disabled:opacity-40"
|
||||
>
|
||||
→
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -171,7 +171,7 @@ export function KhkkWorkListPanel({
|
||||
</div>
|
||||
)}
|
||||
{!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 đã có kế hoạch ký kết.
|
||||
</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">
|
||||
{projectName}
|
||||
</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>
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{items.map((pe) => (
|
||||
@ -241,7 +241,7 @@ export function KhkkWorkListPanel({
|
||||
</div>
|
||||
)}
|
||||
{!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ử lý. Phiếu đã duyệt không hiện ở đây — xem tại
|
||||
menu Kế hoạch ký kết HĐ.
|
||||
</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
|
||||
đ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 && (
|
||||
<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ý kết HĐ.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -33,7 +33,8 @@
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
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 { Dialog } from '@/components/ui/Dialog'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
@ -104,6 +105,8 @@ export function KhkkWorkflowPanel({
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
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 steps = plan.workflowSteps ?? []
|
||||
@ -174,15 +177,18 @@ export function KhkkWorkflowPanel({
|
||||
const approvalFiles: KhkkAttachmentDto[] =
|
||||
(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).
|
||||
const [showChangelog, setShowChangelog] = useState(false)
|
||||
// [BE-5] Lịch sử thay đổi.
|
||||
// 🔴 [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({
|
||||
queryKey: ['khkk-changelogs', plan.id],
|
||||
queryFn: async () =>
|
||||
(await api.get<KhkkChangelogDto[]>(`/contract-signing-plans/${plan.id}/changelogs`, {
|
||||
params: { take: 200 },
|
||||
})).data,
|
||||
enabled: showChangelog,
|
||||
})
|
||||
|
||||
const transition = useMutation({
|
||||
@ -267,41 +273,44 @@ export function KhkkWorkflowPanel({
|
||||
}
|
||||
|
||||
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} ───────────────────────────── */}
|
||||
<header className="flex flex-wrap items-center gap-2 border-b border-slate-100 px-5 py-3">
|
||||
<span
|
||||
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>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-900">Quy trình duyệt</h3>
|
||||
{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.workflowVersion != null && ` v${plan.workflowVersion}`}
|
||||
{plan.workflowName && <span className="font-sans"> · {plan.workflowName}</span>}
|
||||
</span>
|
||||
{plan.workflowVersion != null && ` v${String(plan.workflowVersion).padStart(2, '0')}`}
|
||||
{plan.workflowName && <> · <span className="font-sans">{plan.workflowName}</span></>}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-5">
|
||||
{/* ── 2. Banner kết-thúc-sớm ──────────────────────────────────────────── */}
|
||||
{plan.endedByLevelFinalize && (
|
||||
<div className="rounded border border-violet-300 bg-violet-50 px-3 py-2 text-[11px] text-violet-800">
|
||||
<div className="font-medium">
|
||||
⚑ Kết thúc tại {plan.finalizeStepName ?? 'cấp được cấu hình'}
|
||||
{plan.finalizeLevelName ? ` · ${plan.finalizeLevelName}` : ''}
|
||||
</div>
|
||||
<div className="mt-0.5">
|
||||
{isApproved
|
||||
? 'Kế hoạch đã duyệt xong ngay tại cấp này — không qua CEO / Ban Giám đốc.'
|
||||
: '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>
|
||||
</div>
|
||||
)}
|
||||
{/* ── 2. Banner kết-thúc-sớm ──────────────────────────────────────────── */}
|
||||
{/* [S175 · B1] violet → EMERALD khuôn PE (`:360-377`). PE dành violet RIÊNG cho
|
||||
"⚑ Duyệt thay CEO"; dùng violet cả ở đây là trộn 2 nghĩa vào một màu. */}
|
||||
{plan.endedByLevelFinalize && (
|
||||
<div className="rounded-md border border-emerald-300 bg-emerald-50 px-3 py-2 text-[12px] text-emerald-800">
|
||||
{isApproved ? (
|
||||
<span>
|
||||
✅ Kế hoạch đã kết thúc tại{' '}
|
||||
<span className="font-semibold">{plan.finalizeStepName ?? 'cấp duyệt'}</span>
|
||||
{plan.finalizeLevelName ? <> · {plan.finalizeLevelName}</> : null} — không qua CEO.
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
⚑ 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} là 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 ───────────────────────────────────────── */}
|
||||
{steps.length > 0 ? (
|
||||
@ -317,9 +326,10 @@ export function KhkkWorkflowPanel({
|
||||
{STATUS_ICON[st]}
|
||||
</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 && (
|
||||
<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}
|
||||
</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'}
|
||||
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',
|
||||
)}
|
||||
>
|
||||
<Check className="h-3 w-3" /> Duyệt
|
||||
✓ Duyệt
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@ -470,11 +480,11 @@ export function KhkkWorkflowPanel({
|
||||
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'}
|
||||
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',
|
||||
)}
|
||||
>
|
||||
<Undo2 className="h-3 w-3" /> Trả lại
|
||||
← Trả lại
|
||||
</button>
|
||||
{/* 🧊 [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
|
||||
@ -488,7 +498,8 @@ export function KhkkWorkflowPanel({
|
||||
<button
|
||||
type="button"
|
||||
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 “Đã xóa”)"
|
||||
className="inline-flex items-center gap-1 rounded border border-red-300 bg-white px-2 py-1 text-[11px] font-bold text-red-700 transition hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" /> Xóa phiếu
|
||||
</button>
|
||||
@ -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) ── */}
|
||||
{/* [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 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-700">
|
||||
<Paperclip className="h-3.5 w-3.5 text-teal-500" />
|
||||
File đính kèm khi duyệt
|
||||
<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">
|
||||
<div className="border-t border-slate-200 pt-4">
|
||||
<h3 className="text-sm font-semibold text-slate-900">📎 File đính kèm khi duyệt</h3>
|
||||
<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>
|
||||
<div className="mt-2 space-y-1">
|
||||
{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 className="min-w-0">
|
||||
<div className="truncate font-medium text-slate-700">{a.fileName}</div>
|
||||
<div className="text-slate-500">{formatSize(a.fileSize)} · {formatDateTime(a.createdAt)}</div>
|
||||
</div>
|
||||
<div key={a.id} className="flex items-center gap-1.5 rounded bg-slate-50 px-1.5 py-1 text-[11px]">
|
||||
<Paperclip className="h-3 w-3 shrink-0 text-slate-400" />
|
||||
<span className="min-w-0 flex-1 truncate text-slate-700" title={a.fileName}>{a.fileName}</span>
|
||||
<span className="shrink-0 text-[10px] text-slate-400">{formatSize(a.fileSize)}</span>
|
||||
{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
|
||||
type="button"
|
||||
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>
|
||||
</li>
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{previewAtt && (
|
||||
<AttachmentPreviewDialog
|
||||
open
|
||||
basePath="/contract-signing-plans"
|
||||
evaluationId={plan.id}
|
||||
attachmentId={previewAtt.id}
|
||||
fileName={previewAtt.fileName}
|
||||
onClose={() => setPreviewAtt(null)}
|
||||
/>
|
||||
)}
|
||||
</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
|
||||
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. */}
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-700">
|
||||
<Check className="h-3.5 w-3.5 text-emerald-500" />
|
||||
Lịch sử duyệt
|
||||
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500">
|
||||
{approvals.length}
|
||||
</span>
|
||||
</div>
|
||||
{/* [S175 · B1] Bỏ pill đếm → `(n)` trong ngoặc · thẻ `bg-slate-50/60 text-[11px]`
|
||||
→ `bg-white p-3 text-sm` · mốc-giờ ĐẨY PHẢI bằng `justify-between`.
|
||||
🔴 Badge 2-ngả → 3-NGẢ: trước đây MỌI thứ không phải `Reject` đều tô emerald
|
||||
⇒ "Trả lại" (amber ở PE) bị vẽ xanh như đã-duyệt — đọc ngược nghĩa.
|
||||
`toPhase` đã có sẵn (`khkk.ts:307`) nên phân được TraLai ⟂ TuChoi ⟂ Duyệt. */}
|
||||
<div className="border-t border-slate-200 pt-4">
|
||||
<h3 className="text-sm font-semibold text-slate-900">Lịch sử duyệt ({approvals.length})</h3>
|
||||
{approvals.length === 0 ? (
|
||||
<p className="mt-1.5 text-[11px] italic text-slate-400">Chưa có lượt duyệt nào.</p>
|
||||
) : (
|
||||
<ul className="mt-1.5 space-y-1.5">
|
||||
{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]">
|
||||
<div className="flex flex-wrap items-center gap-x-1.5 text-slate-700">
|
||||
<span className={cn(
|
||||
'rounded px-1.5 py-0.5 text-[9px] font-semibold',
|
||||
a.decision === KhkkApprovalDecision.Reject ? 'bg-amber-100 text-amber-800' : 'bg-emerald-100 text-emerald-800',
|
||||
)}>
|
||||
{DECISION_LABEL[a.decision] ?? 'Chuyển trạng thái'}
|
||||
</span>
|
||||
<span className="font-medium">{a.approvedByFullName ?? '(hệ thống)'}</span>
|
||||
<span className="text-slate-400">·</span>
|
||||
<span className="text-slate-500">{formatDateTime(a.approvedAt)}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-slate-600">
|
||||
{a.stepName ? `Bước ${a.stepOrder} — ${a.stepName}` : 'Cấp duyệt'}
|
||||
{a.levelOrder != null && ` · Cấp ${a.levelOrder}`}
|
||||
{a.levelName ? ` (${a.levelName})` : ''}
|
||||
<span className="text-slate-400">
|
||||
{' '}· {KHKK_PHASE_LABELS[a.fromPhase]} → {KHKK_PHASE_LABELS[a.toPhase]}
|
||||
</span>
|
||||
</div>
|
||||
{a.comment?.trim() && (
|
||||
<div className="mt-0.5 whitespace-pre-wrap text-slate-600">{a.comment}</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{approvals.map(a => {
|
||||
const isReject = a.toPhase === KhkkPhase.TuChoi
|
||||
const isReturn = a.toPhase === KhkkPhase.TraLai
|
||||
return (
|
||||
<li key={a.id} className="rounded border border-slate-200 bg-white p-3 text-sm">
|
||||
<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">
|
||||
<span className={cn(
|
||||
'rounded px-1.5 py-0.5 text-[10px] font-semibold',
|
||||
isReject ? 'bg-rose-100 text-rose-800'
|
||||
: isReturn ? 'bg-amber-100 text-amber-800'
|
||||
: 'bg-emerald-100 text-emerald-800',
|
||||
)}>
|
||||
{DECISION_LABEL[a.decision] ?? 'Chuyển trạng thái'}
|
||||
</span>
|
||||
<span className="font-medium">{a.approvedByFullName ?? '(hệ thống)'}</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-slate-500">{formatDateTime(a.approvedAt)}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] text-slate-600">
|
||||
{a.stepName ? `Bước ${a.stepOrder} — ${a.stepName}` : 'Cấp duyệt'}
|
||||
{a.levelOrder != null && ` · Cấp ${a.levelOrder}`}
|
||||
{a.levelName ? ` (${a.levelName})` : ''}
|
||||
<span className="text-slate-400">
|
||||
{' '}· {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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 7. Lịch sử thay đổi ────────────────────────────────────────────── */}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowChangelog(v => !v)}
|
||||
className="flex w-full items-center gap-1.5 text-xs font-semibold text-slate-700 hover:text-brand-700"
|
||||
>
|
||||
{/* [S175 · B1] BỎ GẬP — PE render thẳng (`PeDetailTabs.tsx:787-794`). Nội-dung
|
||||
đã 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ì. */}
|
||||
<div className="border-t border-slate-200 pt-4">
|
||||
<h3 className="flex items-center gap-1.5 text-sm font-semibold text-slate-900">
|
||||
<History className="h-3.5 w-3.5 text-slate-400" />
|
||||
Lịch sử thay đổi
|
||||
<span className="ml-auto text-[10px] font-normal text-slate-400">{showChangelog ? 'Ẩn' : 'Xem'}</span>
|
||||
</button>
|
||||
{showChangelog && (
|
||||
<div className="mt-1.5">
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
{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.data && changelogs.data.length === 0 && (
|
||||
@ -614,10 +647,8 @@ export function KhkkWorkflowPanel({
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{action !== null && (
|
||||
<Dialog
|
||||
@ -625,7 +656,7 @@ export function KhkkWorkflowPanel({
|
||||
onClose={() => setAction(null)}
|
||||
title={dialogTitle}
|
||||
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}>
|
||||
{transition.isPending ? 'Đang gửi...' : 'Xác nhận'}
|
||||
</Button>
|
||||
@ -663,22 +694,26 @@ export function KhkkWorkflowPanel({
|
||||
</label>
|
||||
</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>
|
||||
Ý 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>
|
||||
<Textarea
|
||||
value={comment}
|
||||
onChange={e => setComment(e.target.value)}
|
||||
rows={3}
|
||||
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
|
||||
`POST {id}/approval-attachments` (BE ép `purpose=ApprovalAttachment`). */}
|
||||
{action !== KhkkTransitionAction.Submit && (
|
||||
<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">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
@ -714,7 +749,7 @@ export function KhkkWorkflowPanel({
|
||||
|
||||
{action === KhkkTransitionAction.Approve && (
|
||||
<p className="mt-1 text-[11px] text-slate-500">
|
||||
Ý kiến sẽ được ghi vào mục “Ý kiến cấp duyệt” của kế hoạch (1 ô/Cấp — duyệt lại
|
||||
Ghi chú sẽ được ghi vào mục “Ý kiến cấp duyệt” của kế hoạch (1 ô/Cấp — duyệt lại
|
||||
thì ghi đè ô cũ).
|
||||
</p>
|
||||
)}
|
||||
@ -727,7 +762,7 @@ export function KhkkWorkflowPanel({
|
||||
onClose={() => setConfirmDelete(false)}
|
||||
title="🗑 Xóa kế hoạch ký kết"
|
||||
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}>
|
||||
{removePlan.isPending ? 'Đang xóa...' : 'Xóa phiếu'}
|
||||
</Button>
|
||||
@ -739,6 +774,6 @@ export function KhkkWorkflowPanel({
|
||||
</div>
|
||||
</Dialog>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -15,10 +15,15 @@ export function isPreviewable(fileName: string): boolean {
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
/** Id của bản-ghi CHỦ (phiếu PE, hoặc kế-hoạch KHKK khi truyền `basePath`). */
|
||||
evaluationId: string
|
||||
attachmentId: string
|
||||
fileName: string
|
||||
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).
|
||||
@ -29,6 +34,7 @@ type Props = {
|
||||
* cỡ viewport (Dialog dùng chung chỉ có sm/md/lg → tự render overlay riêng). */
|
||||
export function AttachmentPreviewDialog({
|
||||
open, evaluationId, attachmentId, fileName, onClose,
|
||||
basePath = '/purchase-evaluations',
|
||||
}: Props) {
|
||||
const [blobUrl, setBlobUrl] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
@ -58,7 +64,7 @@ export function AttachmentPreviewDialog({
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
api.get(`/purchase-evaluations/${evaluationId}/attachments/${attachmentId}/view`, {
|
||||
api.get(`${basePath}/${evaluationId}/attachments/${attachmentId}/view`, {
|
||||
responseType: 'blob',
|
||||
})
|
||||
.then(res => {
|
||||
@ -78,7 +84,7 @@ export function AttachmentPreviewDialog({
|
||||
if (currentUrl) window.URL.revokeObjectURL(currentUrl)
|
||||
setBlobUrl(null)
|
||||
}
|
||||
}, [open, evaluationId, attachmentId])
|
||||
}, [open, evaluationId, attachmentId, basePath])
|
||||
|
||||
const ready = blobUrl && !loading && !error
|
||||
|
||||
|
||||
@ -89,6 +89,7 @@ export function PipelineTreePanel({
|
||||
bodyMaxHClass,
|
||||
stage2Options,
|
||||
hideHeader,
|
||||
hideSearch,
|
||||
}: {
|
||||
currentStage: PipelineStageNo
|
||||
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
|
||||
// `<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
|
||||
// [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 [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. */}
|
||||
<div className={cn('min-h-0 flex-col', hideHeader || mobileOpen ? 'flex' : 'hidden', 'lg:flex')}>
|
||||
<div className="relative border-b border-slate-200 p-2">
|
||||
<Search className="pointer-events-none absolute left-4 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Lọc dự án / hạng mục / mã phiếu…"
|
||||
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>
|
||||
{!hideSearch && (
|
||||
<div className="relative border-b border-slate-200 p-2">
|
||||
<Search className="pointer-events-none absolute left-4 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Lọc dự án / hạng mục / mã phiếu…"
|
||||
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 className={cn('overflow-y-auto', bodyMaxHClass ?? 'max-h-[28rem] lg:max-h-[calc(100vh-16rem)]')}>
|
||||
{!canPe && (
|
||||
|
||||
@ -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
|
||||
// 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]`;
|
||||
// · Panel 1 = danh sách (`KhkkWorkListPanel`) + nút hành-động dính đáy;
|
||||
// · header 1 dòng + grid `lg:grid-cols-[260px_1fr] xl:grid-cols-[320px_1fr]` — ĐÚNG BYTE
|
||||
// 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 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*).
|
||||
|
||||
Reference in New Issue
Block a user