[CLAUDE] Contract: UAT S174 tinh-gon UI KHKK + cot 2 tang KHKK o workspace PE (FE-only x2 app, 0-mig)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m51s

Anh chot qua 6 anh khoanh do + 4 cau tra loi. 7 chinh, mirror SHA256 identical 8/8 cap.

Man DANH SACH KHKK (KhkkListPage / KhkkListPanel)
- Giu DUY NHAT header trang "Ke hoach ky ket HD / Giai doan 2".
- Bo o loc "Tat ca nhom": may loc GIU NGUYEN (query + stage2Content), nhom nay den
  DUY NHAT tu URL ?group=n (8 leaf menu trai). Go luon prop onGroupChange (noUnusedLocals).
- Bo thanh "Cay toan trinh - N goi" qua prop MOI `hideHeader` opt-in tren PipelineTreePanel.
  6 host con lai (PE / GD3 / GD4 / Inbox) KHONG truyen => hinh cu nguyen ven.
  Nut "Hien/An cay" (mobile) song TRONG thanh vua an => hideHeader ep than cay `flex`,
  neu khong `<lg` mat trang cai cay.

Man CHI TIET KHKK (KhkkDetailPage / KhkkDetailContent)
- Go khoi "Quy trinh duyet" (so-do Buoc/Cap + HANH DONG + 2 lich su) — day la BAN SAO
  THU HAI, ban chinh o Panel-3 man danh sach. Anh: "bo het, no se hien thi o Menu duyet".
  KHAI HE QUA: `<lg` dieu huong thang vao trang nay => mobile hien khong con Gui duyet /
  Xoa phieu. Mo lai = boc `lg:hidden`.
- Section 5 "Y kien cap duyet" doi sang DUNG style Duyet NCC (PeDetailTabs.tsx:705-774):
  card `rounded-lg p-3` + h4 ten cap + chip trang thai + y kien text-sm + chan ngay
  border-t; thay o phang 1 dong text-[11px]. GIU o "Chua ky" (PE khong co) — vang o thi
  nguoi doc khong biet con thieu chu ky cua ai.

Man TAO KHKK (KhkkCreatePage)
- Doi sang khuon "Tao phieu Duyet NCC moi" (PeWorkspaceCreateView): 1 card + divide-y,
  thanh dau ke LUONG, thanh cuoi hint do liet ke DICH DANH truong con thieu thay vi chi
  lam xam nut. Nut "Luu nhap" -> "Tao ke hoach".
- Ve "tao xong sang chi tiet" VON DA DUNG tu S161 (onSuccess -> navigate /khkk/:id);
  dot nay KHONG dung luong do, chi doi hinh.

Man DUYET NCC — THAO TAC (PurchaseEvaluationWorkspacePage + PeKhkkSidePanel MOI)
- Them cot 3: 1 cot 2 TANG 50/50 — tren = PE da duyet CHUA lap KHKK gom theo DU AN,
  duoi = KHKK da tao. Ti le 50/50 bang `min-h-0 flex-1` ca hai tang.
- 0 ENDPOINT MOI: dung lai approved-pe-awaiting-plan + contract-signing-plans.
  "Chua co KHKK" = existingGroups RONG — tu K2/S164 BE KHONG con loc ho, loc o FE.
- GATE #85: ca 2 cua gac bang policy `KeHoachKyKet.Read` trong khi day la man PE =>
  gate bang DUNG key policy cua endpoint (KHONG OR key menu Khkk_*), thieu quyen thi
  NOI RA thay vi de cot trang (cot trang doc thanh "chua co gi").
- Cot chi moc tu `xl`; o `lg` bo cuc giu DUNG 2 panel nhu cu.

Con lai (chua lam, da khai voi anh): bam PE o tang tren moi mo /khkk/create chu chua
chon san dung phieu do — can them ?peId= vao man tao.

Verify: build 2 app PASS; mirror SHA256 8/8 cap KHOP; byte-level grep bundle xac nhan
chuoi moi co mat ca 2 app (khong tin dong "built" — bai gotcha #77).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-05 15:04:08 +07:00
parent 15367683de
commit 14a917f783
16 changed files with 1024 additions and 470 deletions

View File

@ -21,7 +21,7 @@
import { useState, type ReactNode } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
ClipboardList, FileCheck, MessageSquare, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
Check, ClipboardList, FileCheck, MessageSquare, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
} from 'lucide-react'
import { toast } from 'sonner'
import { PePipelineStrip } from '@/components/pe/PePipelineStrip'
@ -589,61 +589,85 @@ export function KhkkDetailContent({
Phiếu chưa pin quy trình duyệt chưa cấp nào đ ghi ý kiến.
</div>
) : (
<div className="space-y-4">
<div className="space-y-3">
{steps.map((step) => {
const signedCount = step.levels.filter(lv =>
opinions.some(o => o.approvalWorkflowLevelId === lv.id)).length
return (
<div key={step.id} className="rounded-lg border border-slate-200">
<div className="flex flex-wrap items-center gap-2 border-b border-slate-100 bg-slate-50/70 px-3 py-2">
<span className="text-xs font-semibold text-slate-800">Bước {step.order} {step.name}</span>
<div key={step.id} className="rounded-lg border border-slate-200 bg-slate-50/40">
<div className="flex flex-wrap items-center gap-2 border-b border-slate-200 bg-slate-50/80 px-3 py-2">
<span className="text-[12px] font-bold uppercase tracking-wide text-slate-700">
Bước {step.order} {step.name}
</span>
{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>
)}
<span className="ml-auto text-[10px] text-slate-500">
<span
className="ml-auto text-[10px] text-slate-500"
title={`Tổng ${step.levels.length} NV tham gia (mỗi cấp chỉ cần 1 NV ký — OR-of-N)`}
>
{signedCount}/{step.levels.length} cấp đã duyệt · {step.levels.length} NV tham gia
</span>
</div>
<div className="grid grid-cols-1 gap-2 p-3 md:grid-cols-2">
{step.levels.length === 0 && (
<p className="text-[11px] italic text-slate-400">Bước này chưa cấu hình cấp duyệt.</p>
)}
{step.levels.map((lv) => {
const signed = opinions.find(o => o.approvalWorkflowLevelId === lv.id)
return (
<div
key={lv.id}
className={cn(
'rounded-md border px-2.5 py-2 text-[11px]',
signed ? 'border-emerald-200 bg-emerald-50/40' : 'border-dashed border-slate-200 bg-white',
)}
>
<div className="flex flex-wrap items-center gap-x-1.5 text-slate-700">
<span className="font-medium">{lv.name || `Cấp ${lv.order}`}</span>
<span className="text-slate-400">·</span>
<span>{lv.approverFullName ?? '(chưa cấu hình)'}</span>
{signed && (
<>
<span className="text-slate-400">·</span>
<span className="text-slate-500">{formatDateTime(signed.signedAt)}</span>
{signed.approverUserId && signed.approverUserId !== signed.signedByUserId && (
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[9px] font-semibold text-amber-800">
duyệt thay ({signed.signedByFullName})
<div className="p-3">
{step.levels.length === 0 ? (
<div className="text-[12px] italic text-slate-400"> Bước này chưa cấu hình cấp duyệt.</div>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{step.levels.map((lv) => {
const signed = opinions.find(o => o.approvalWorkflowLevelId === lv.id)
return (
<div
key={lv.id}
className={cn(
'rounded-lg border bg-white p-3',
signed ? 'border-emerald-200' : 'border-dashed border-slate-200',
)}
>
<div className="mb-2 flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<h4 className="text-[13px] font-semibold text-slate-700">
{lv.name || `Cấp ${lv.order}`} {' '}
<span className="text-slate-900">{lv.approverFullName ?? '(chưa cấu hình)'}</span>
</h4>
{/* Không dùng `signed!` — thu hẹp bằng chính `signed &&` để
khỏi mượn null-forgiving trên dữ-liệu từ API (gotcha #80). */}
{signed && signed.approverUserId
&& signed.approverUserId !== signed.signedByUserId && (
<div className="mt-1 inline-flex items-center gap-1 rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700">
Admin <strong>{signed.signedByFullName}</strong> duyệt thay
</div>
)}
</div>
{signed ? (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-medium text-emerald-700">
<Check className="h-3 w-3" /> Đã duyệt
</span>
) : (
<span className="inline-flex shrink-0 items-center rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-500">
Chưa
</span>
)}
</>
)}
</div>
<div className="mt-0.5 whitespace-pre-wrap text-slate-600">
{signed
? (signed.comment?.trim() || '(duyệt — không ý kiến)')
: <span className="italic text-slate-300">Chưa </span>}
</div>
</div>
)
})}
</div>
{signed ? (
<>
<div className="whitespace-pre-wrap text-sm text-slate-800">
{signed.comment?.trim() || '(duyệt — không ý kiến)'}
</div>
<div className="mt-2 border-t border-slate-100 pt-1.5 text-[11px] text-slate-500">
{formatDateTime(signed.signedAt)}
</div>
</>
) : (
<div className="text-sm italic text-slate-400">Chưa ý kiến duyệt.</div>
)}
</div>
)
})}
</div>
)}
</div>
</div>
)

View File

@ -31,11 +31,9 @@ import { usePipelineStages } from '@/hooks/usePipelineStages'
import { api } from '@/lib/api'
import { cn } from '@/lib/cn'
import {
KHKK_APPROVAL_GROUPS,
KHKK_PHASE_BADGE,
KHKK_PHASE_LABELS,
KhkkPhase,
khkkGroupMenuLabel,
type KhkkListItemDto,
type KhkkPhaseValue,
type PagedResult,
@ -172,7 +170,6 @@ export function KhkkListPanel({
phase,
onPhaseChange,
group,
onGroupChange,
deletedView,
page,
onPageChange,
@ -184,8 +181,10 @@ export function KhkkListPanel({
onSearchChange: (v: string) => void
phase: KhkkPhaseValue | null
onPhaseChange: (v: KhkkPhaseValue | null) => void
// [S174 owner] CHỈ còn đọc — `Select` nhóm đã gỡ khỏi UI. Nhóm nay đến DUY NHẤT từ URL
// (`?group=n`, tức 8 leaf nhóm ở menu trái). Máy lọc giữ NGUYÊN (query + `stage2Content`);
// chỉ bỏ đường đổi nhóm bằng tay — vào từ leaf nhóm rồi thì ô đó là thừa.
group: number | null
onGroupChange: (v: number | null) => void
deletedView: boolean
page: number
onPageChange: (p: number) => void
@ -265,28 +264,16 @@ export function KhkkListPanel({
className="pl-8"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<Select
value={phase ?? ''}
aria-label="Lọc trạng thái"
onChange={e => onPhaseChange(e.target.value === '' ? null : (Number(e.target.value) as KhkkPhaseValue))}
>
<option value="">Tất cả trạng thái</option>
{(Object.keys(KHKK_PHASE_LABELS) as unknown as number[]).map(p => (
<option key={p} value={p}>{KHKK_PHASE_LABELS[Number(p) as KhkkPhaseValue]}</option>
))}
</Select>
<Select
value={group ?? ''}
aria-label="Lọc nhóm duyệt"
onChange={e => onGroupChange(e.target.value === '' ? null : Number(e.target.value))}
>
<option value="">Tất cả nhóm</option>
{KHKK_APPROVAL_GROUPS.map(n => (
<option key={n} value={n}>{khkkGroupMenuLabel(n)}</option>
))}
</Select>
</div>
<Select
value={phase ?? ''}
aria-label="Lọc trạng thái"
onChange={e => onPhaseChange(e.target.value === '' ? null : (Number(e.target.value) as KhkkPhaseValue))}
>
<option value="">Tất cả trạng thái</option>
{(Object.keys(KHKK_PHASE_LABELS) as unknown as number[]).map(p => (
<option key={p} value={p}>{KHKK_PHASE_LABELS[Number(p) as KhkkPhaseValue]}</option>
))}
</Select>
{flatMode && !deletedView && canPe && (
<p className="text-[10px] italic text-slate-400">
Đang lọc danh sách lấy thẳng từ máy chủ (không giới hạn 200 phiếu của cây).
@ -305,6 +292,7 @@ export function KhkkListPanel({
<PipelineTreePanel
currentStage={2}
className="border-0 shadow-none"
hideHeader
bodyMaxHClass="max-h-[28rem] lg:max-h-none"
stage2Options={{ stage2Content, stage2Count, onOpenKhkk: onSelect }}
/>

View File

@ -0,0 +1,252 @@
// [S174 owner] Cột PHẢI của màn "Duyệt NCC — Thao tác" — 1 cột, 2 TẦNG 50/50:
// · tầng TRÊN = phiếu Duyệt NCC ĐÃ DUYỆT (có NCC trúng thầu) mà CHƯA lập kế hoạch ký kết,
// gom theo DỰ ÁN — đây là "việc còn phải làm";
// · tầng DƯỚI = kế hoạch ký kết ĐÃ TẠO — đây là "việc đã làm rồi".
// Anh chốt: "Thử ý tưởng 1 cột 2 tầng thử, 50% tầng trên là duyệt NCC, còn 50% tầng dưới
// là KHKK". File MIRROR SHA256 identical với fe-user counterpart.
//
// 🔴 0 ENDPOINT MỚI — dùng lại đúng 2 cửa đã sống:
// · `GET /contract-signing-plans/approved-pe-awaiting-plan` (chính cửa `KhkkCreatePage`
// đang gọi) → `KhkkPeCandidateDto[]`; "chưa có kế hoạch" = `existingGroups` RỖNG.
// 🔸 Từ K2 S164 cửa này KHÔNG còn loại PE đã có kế hoạch (nó trả cả PE đã dùng vài
// nhóm, kèm `existingGroups`) ⇒ lọc rỗng là việc của FE, đừng tưởng BE lọc hộ.
// · `GET /contract-signing-plans` → `PagedResult<KhkkListItemDto>`.
//
// 🔴 GATE QUYỀN (gotcha #85 — mặt đã cắn thật ở S162): màn đang đứng là *Duyệt NCC*, nhưng
// CẢ HAI cửa trên gác bằng policy `KeHoachKyKet.Read` (`ContractSigningPlansController.cs:36`
// + `MenuPermissionHandler.cs:40` so khớp CHÍNH XÁC 1 key — KHÔNG kế thừa cha, KHÔNG OR key
// con). Người chỉ có quyền PE mà không có KHKK sẽ ăn 403 rải khắp cột. ⇒ gate bằng ĐÚNG key
// của endpoint, và khi thiếu quyền thì NÓI RA thay vì để cột trắng trông như "chưa có gì".
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { FileCheck, FileSignature, Lock } from 'lucide-react'
import { usePermission } from '@/hooks/usePermission'
import { api } from '@/lib/api'
import { cn } from '@/lib/cn'
import {
KHKK_PHASE_BADGE,
KHKK_PHASE_LABELS,
type KhkkListItemDto,
type KhkkPeCandidateDto,
type PagedResult,
} from '@/types/khkk'
const KHKK_TIER_PAGE_SIZE = 30
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
/** Vỏ 1 tầng — `min-h-0 flex-1` ở CẢ HAI tầng là thứ tạo ra tỉ lệ 50/50 thật; thiếu
* `min-h-0` thì con cuộn được sẽ đẩy tầng phình theo nội dung, hỏng tỉ lệ. */
function Tier({
title, count, icon, accent, children,
}: {
title: string
count: number | null
icon: React.ReactNode
accent: string
children: React.ReactNode
}) {
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex shrink-0 items-center gap-2 border-b border-slate-200 bg-slate-50/80 px-3 py-2">
<span className={cn('inline-flex h-5 w-5 items-center justify-center rounded', accent)} aria-hidden>
{icon}
</span>
<span className="min-w-0 flex-1 truncate text-[11px] font-bold uppercase tracking-wide text-slate-600">
{title}
</span>
{count !== null && (
<span className="shrink-0 rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">
{count}
</span>
)}
</div>
<div className="min-h-0 flex-1 overflow-y-auto">{children}</div>
</div>
)
}
export function PeKhkkSidePanel() {
const navigate = useNavigate()
const { can } = usePermission()
// 🔴 ĐÚNG key policy của endpoint — KHÔNG dùng `Khkk_*` (đó là key MENU, tầng hiển thị).
const canKhkk = can('KeHoachKyKet', 'Read')
const candidates = useQuery({
// Key dùng CHUNG với `KhkkCreatePage` là CỐ Ý: tạo kế hoạch xong quay lại đây thì
// cột tự tươi, không phải nhớ invalidate thêm một key thứ hai.
queryKey: ['khkk-pe-candidates'],
queryFn: async () =>
(await api.get<KhkkPeCandidateDto[]>('/contract-signing-plans/approved-pe-awaiting-plan')).data,
enabled: canKhkk,
})
const plans = useQuery({
queryKey: ['khkk-list', { side: true, page: 1, pageSize: KHKK_TIER_PAGE_SIZE }],
queryFn: async () =>
(await api.get<PagedResult<KhkkListItemDto>>('/contract-signing-plans', {
params: { page: 1, pageSize: KHKK_TIER_PAGE_SIZE },
})).data,
enabled: canKhkk,
})
// "Chưa được tạo phiếu KHKK" = chưa dùng nhóm duyệt NÀO. `?? []` phòng payload cũ còn
// trong cache React Query (DTO trước K2 không có field này) — thiếu guard thì `.length` nổ.
const awaiting = (candidates.data ?? []).filter((p) => (p.existingGroups ?? []).length === 0)
// Gom theo DỰ ÁN — đúng chữ "theo dự án" của anh. `Map` giữ thứ tự chèn nên dự án nào có
// phiếu mới nhất (BE trả sẵn theo thứ tự) thì nổi lên trước.
const byProject = new Map<string, KhkkPeCandidateDto[]>()
for (const pe of awaiting) {
const key = pe.projectName ?? '(chưa gán dự án)'
const bucket = byProject.get(key)
if (bucket) bucket.push(pe)
else byProject.set(key, [pe])
}
const planItems = plans.data?.items ?? []
return (
<aside className="hidden min-h-0 flex-col overflow-hidden border-l border-slate-200 bg-white xl:flex">
{!canKhkk ? (
// Cột trắng đọc thành "chưa có gì" — phải nói ĐÚNG lý do là thiếu quyền.
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-4 text-center">
<Lock className="h-5 w-5 text-slate-300" />
<p className="text-[11px] text-slate-500">
Bạn chưa quyền xem Kế hoạch kết nên cột này không dựng đưc. Việc duyệt NCC
hai panel bên trái không bị nh hưởng.
</p>
</div>
) : (
<>
<Tier
title="Đã duyệt · chưa lập KHKK"
count={awaiting.length}
accent="bg-amber-50 text-amber-600"
icon={<FileCheck className="h-3 w-3" />}
>
{candidates.isLoading && <div className="p-3 text-[11px] text-slate-500">Đang tải</div>}
{candidates.isError && (
<div className="space-y-1.5 p-3">
<p className="text-[11px] text-slate-500">Không tải đưc danh sách.</p>
<button
type="button"
onClick={() => candidates.refetch()}
className="rounded-md border border-slate-300 px-2 py-1 text-[10px] font-semibold text-slate-600 hover:border-brand-300 hover:bg-brand-50"
>
Thử lại
</button>
</div>
)}
{!candidates.isLoading && !candidates.isError && awaiting.length === 0 && (
<p className="p-3 text-[11px] italic text-slate-400">
Mọi phiếu đã duyệt đu đã kế hoạch kết.
</p>
)}
{[...byProject.entries()].map(([projectName, items]) => (
<div key={projectName}>
<div className="sticky top-0 z-10 flex items-center gap-1.5 border-b border-slate-100 bg-white/95 px-3 py-1 backdrop-blur">
<span className="min-w-0 flex-1 truncate text-[10px] font-semibold uppercase tracking-wide text-slate-500">
{projectName}
</span>
<span className="shrink-0 text-[10px] text-slate-400">{items.length}</span>
</div>
<ul className="divide-y divide-slate-100">
{items.map((pe) => (
<li key={pe.id}>
<button
type="button"
// Bấm = đi thẳng tới màn lập kế hoạch. Không preset `?group=` vì
// PE này chưa dùng nhóm nào ⇒ để màn tạo giữ default của nó.
onClick={() => navigate('/khkk/create')}
title="Lập kế hoạch ký kết cho phiếu này"
className="block w-full px-3 py-2 text-left transition hover:bg-amber-50/60"
>
<div className="flex items-center gap-1.5">
<span className="font-mono text-[10px] text-brand-700">{pe.maPhieu ?? '—'}</span>
<span className="text-[10px] text-slate-400">·</span>
<span className="truncate text-[10px] text-slate-500">{formatDate(pe.createdAt)}</span>
</div>
<div className="truncate text-[12px] font-medium text-slate-800">{pe.tenGoiThau}</div>
{pe.winnerSupplierNames.length > 0 && (
<div className="mt-0.5 truncate text-[10px] text-emerald-700">
{pe.winnerSupplierNames.join(' · ')}
</div>
)}
</button>
</li>
))}
</ul>
</div>
))}
</Tier>
<div className="shrink-0 border-t-2 border-slate-200" />
<Tier
title="Kế hoạch ký kết đã tạo"
count={plans.data?.total ?? null}
accent="bg-violet-50 text-violet-600"
icon={<FileSignature className="h-3 w-3" />}
>
{plans.isLoading && <div className="p-3 text-[11px] text-slate-500">Đang tải</div>}
{plans.isError && (
<div className="space-y-1.5 p-3">
<p className="text-[11px] text-slate-500">Không tải đưc danh sách.</p>
<button
type="button"
onClick={() => plans.refetch()}
className="rounded-md border border-slate-300 px-2 py-1 text-[10px] font-semibold text-slate-600 hover:border-brand-300 hover:bg-brand-50"
>
Thử lại
</button>
</div>
)}
{!plans.isLoading && !plans.isError && planItems.length === 0 && (
<p className="p-3 text-[11px] italic text-slate-400">Chưa kế hoạch kết nào.</p>
)}
<ul className="divide-y divide-slate-100">
{planItems.map((k) => (
<li key={k.id}>
<button
type="button"
onClick={() => navigate(`/khkk/${k.id}`)}
title="Mở kế hoạch ký kết"
className="block w-full px-3 py-2 text-left transition hover:bg-violet-50/60"
>
<div className="flex items-center gap-1.5">
<span className="font-mono text-[10px] text-violet-700">{k.maKeHoach ?? '(chưa cấp mã)'}</span>
{k.approvalGroup != null && (
<span className="rounded border border-violet-200 bg-violet-50 px-1 text-[9px] font-semibold text-violet-700">
N{k.approvalGroup}
</span>
)}
<span
className={cn(
'ml-auto shrink-0 rounded border px-1.5 py-0.5 text-[9px] font-medium',
KHKK_PHASE_BADGE[k.phase],
)}
>
{KHKK_PHASE_LABELS[k.phase]}
</span>
</div>
<div className="truncate text-[12px] font-medium text-slate-800">
{k.peTenGoiThau ?? '(chưa có tên gói thầu)'}
</div>
</button>
</li>
))}
</ul>
{plans.data && plans.data.total > planItems.length && (
<p className="border-t border-slate-100 px-3 py-1.5 text-[10px] italic text-slate-400">
Hiện {planItems.length}/{plans.data.total} kế hoạch mới nhất xem đ menu Kế hoạch kết .
</p>
)}
</Tier>
</>
)}
</aside>
)
}

View File

@ -88,6 +88,7 @@ export function PipelineTreePanel({
className,
bodyMaxHClass,
stage2Options,
hideHeader,
}: {
currentStage: PipelineStageNo
className?: string
@ -100,6 +101,12 @@ export function PipelineTreePanel({
// hiệu-chỉnh cho trang giấy-cuộn, vượt chỗ Panel-3 /inbox ~26px ⇒ 2 cuộn lồng nhau).
// Mặc định giữ nguyên — 3 host giấy-cuộn cũ không đổi hành vi.
bodyMaxHClass?: string
// [S174 owner] Ẩn thanh tiêu-đề "Cây toàn trình · N gói". CHỈ dùng ở host đã có tiêu-đề
// riêng ngay trên (KHKK GĐ2: header trang `KhkkListPage.tsx:124`) — 2 tiêu-đề chồng nhau
// trong cùng một cột là thừa. 6 host còn lại KHÔNG truyền ⇒ giữ nguyên hình cũ.
// 🔴 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
}) {
const { canPe, buildStages } = usePipelineStages()
const [search, setSearch] = useState('')
@ -189,29 +196,32 @@ export function PipelineTreePanel({
style={{ ['--accent' as string]: 'var(--color-brand-500)' }}
aria-label="Cây toàn trình theo gói thầu"
>
<div className="flex items-center gap-2 border-b border-slate-200 px-3 py-2.5">
<span
className="icon-chip h-7! w-7!"
style={{ ['--chip-bg' as string]: 'var(--color-brand-50)', ['--chip-fg' as string]: 'var(--color-brand-600)' }}
aria-hidden
>
<ListTree className="h-3.5 w-3.5" />
</span>
<span className="label-eyebrow min-w-0 flex-1 truncate">Cây toàn trình</span>
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">
{packageCount} gói
</span>
<button
type="button"
onClick={() => setMobileOpen(o => !o)}
aria-expanded={mobileOpen}
className="rounded-md border border-slate-300 px-2 py-1 text-[11px] font-semibold text-slate-600 transition hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/70 lg:hidden"
>
{mobileOpen ? 'Ẩn cây' : 'Hiện cây'}
</button>
</div>
{!hideHeader && (
<div className="flex items-center gap-2 border-b border-slate-200 px-3 py-2.5">
<span
className="icon-chip h-7! w-7!"
style={{ ['--chip-bg' as string]: 'var(--color-brand-50)', ['--chip-fg' as string]: 'var(--color-brand-600)' }}
aria-hidden
>
<ListTree className="h-3.5 w-3.5" />
</span>
<span className="label-eyebrow min-w-0 flex-1 truncate">Cây toàn trình</span>
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">
{packageCount} gói
</span>
<button
type="button"
onClick={() => setMobileOpen(o => !o)}
aria-expanded={mobileOpen}
className="rounded-md border border-slate-300 px-2 py-1 text-[11px] font-semibold text-slate-600 transition hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/70 lg:hidden"
>
{mobileOpen ? 'Ẩn cây' : 'Hiện cây'}
</button>
</div>
)}
<div className={cn('min-h-0 flex-col', mobileOpen ? 'flex' : 'hidden', 'lg:flex')}>
{/* [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

View File

@ -1,25 +1,32 @@
// [W2 KHKK — S161 2026-07-29] Lập "Kế hoạch ký kết HĐ" từ phiếu Duyệt NCC đã duyệt.
// Khuôn `pages/office/ProposalCreatePage.tsx` (PURO: ui/PageHeader + section
// .card-accent + .icon-chip header). File MIRROR SHA256 identical với fe-admin.
// File MIRROR SHA256 identical với fe-user counterpart.
//
// 3 section: (1) chọn phiếu PE nguồn — phiếu DaDuyet có NCC trúng thầu (BE lọc, FE
// không tự đoán); (2) NHÓM DUYỆT N1..N8 + quy trình duyệt V2 ApplicableType=10 admin
// đã ghim; (3) ghi chú. Dòng giá per-NCC do BE tự sinh từ winner của phiếu PE
// (snapshot `PeReferenceAmount`) — FE KHÔNG gửi lines.
//
// [K2 S164 — OG-7 UI-hướng-1] ĐỔI HÌNH DẠNG màn này: trước K2, PE đã có kế hoạch bị
// BE loại khỏi picker (biến mất) và tạo trùng thì ăn 409 chặn hẳn. Từ K2, rào là
// "1 PE = 1 kế hoạch MỖI NHÓM" ⇒ PE vẫn hiện, kèm danh sách nhóm đã dùng + nút phụ
// "Tạo thêm cho nhóm khác" (preset sang nhóm còn trống). Ẩn PE đi thì người dùng
// không phân biệt được "PE không đủ điều kiện" với "PE đã dùng hết nhóm".
import { useState, type FormEvent } from 'react'
// [S174 owner] ĐỔI KHUÔN: bỏ `PageHeader` + 3 khối `.card-accent` rời, chuyển sang ĐÚNG
// hình "Tạo phiếu Duyệt NCC mới" (`components/pe/PeWorkspaceCreateView.tsx:152-352`) —
// anh chốt "Tạo Header → tương tự NCC → sau khi Header tạo xong thì cho sang chi tiết":
// · MỘT card `rounded-lg border bg-white shadow-sm`, các section ngăn bằng `divide-y`;
// · thanh đầu = tiêu-đề + 1 dòng kể LUỒNG (nhập gì → bấm gì → cái gì mở khoá) + "← Hủy";
// · thanh cuối = hint TRÁI liệt kê ĐÍCH DANH trường còn thiếu, nút PHẢI.
// 🔸 Vế "sang chi tiết" VỐN ĐÃ ĐÚNG từ S161 (`onSuccess` → `navigate('/khkk/:id')`) — đợt
// này KHÔNG đụng luồng đó, chỉ đổi hình. Ghi ra để người sau khỏi tưởng vừa thêm mới.
//
// [K2 S164 — OG-7 UI-hướng-1] Rào là "1 PE = 1 kế hoạch MỖI NHÓM" ⇒ PE vẫn hiện, kèm
// danh sách nhóm đã dùng + nút phụ "Tạo thêm cho nhóm khác" (preset sang nhóm còn trống).
// Ẩn PE đi thì người dùng không phân biệt được "PE không đủ điều kiện" với "PE đã dùng
// hết nhóm".
import { useState, type FormEvent, type ReactNode } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { CheckCircle2, FileCheck, FileText, Layers, Plus, Save, Settings2, X } from 'lucide-react'
import { CheckCircle2, Plus } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/ui/PageHeader'
import { Button } from '@/components/ui/Button'
import { Label } from '@/components/ui/Label'
import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
@ -92,8 +99,7 @@ export function KhkkCreatePage() {
})
// Endpoint trả AwAdminOverviewDto { types: [{ applicableType, history: [...] }] } —
// KHÔNG phải flat array (khuôn ProposalCreatePage:59-69). Lọc isUserSelectable =
// quy trình admin đã ghim cho user pick.
// KHÔNG phải flat array. Lọc isUserSelectable = quy trình admin đã ghim cho user pick.
const workflows = useQuery({
queryKey: ['approval-workflows-v2', { applicableType: APPLICABLE_TYPE_KHKK, isUserSelectable: true }],
queryFn: async () => {
@ -121,6 +127,7 @@ export function KhkkCreatePage() {
const res = await api.post<{ id: string; maKeHoach: string | null }>('/contract-signing-plans', body)
return res.data
},
// 🔴 Vế "Header tạo xong thì sang chi tiết" nằm ĐÚNG ở đây — giữ nguyên từ S161.
onSuccess: (data) => {
toast.success(data.maKeHoach ? `Đã lập kế hoạch ${data.maKeHoach}` : 'Đã lập kế hoạch ký kết')
navigate(`/khkk/${data.id}`)
@ -142,6 +149,15 @@ export function KhkkCreatePage() {
const takenGroups = selectedPe?.existingGroups ?? []
const groupTaken = takenGroups.includes(approvalGroup)
// [S174] Khuôn `PeWorkspaceCreateView.tsx:145-150`: liệt kê ĐÍCH DANH cái còn thiếu thay
// vì chỉ khoá nút — nút xám không nói được vì sao nó xám.
const missingCreate = [
!purchaseEvaluationId && 'Phiếu Duyệt NCC nguồn',
!approvalWorkflowId && 'Quy trình duyệt',
groupTaken && `Nhóm duyệt còn trống (N${approvalGroup} đã có kế hoạch)`,
].filter(Boolean) as string[]
const canSubmit = missingCreate.length === 0 && !create.isPending
// Bấm chọn PE (cả ở dòng lẫn ở nút "Tạo thêm cho nhóm khác"): nếu nhóm đang chọn đã
// bị PE đó dùng thì nhảy sang nhóm trống đầu tiên ⇒ người dùng không phải tự dò.
const selectPe = (pe: KhkkPeCandidateDto) => {
@ -154,52 +170,37 @@ export function KhkkCreatePage() {
}
return (
<div className="space-y-5">
<PageHeader
eyebrow="Giai đoạn 2"
title="Lập kế hoạch ký kết HĐ"
subtitle="Chọn phiếu Duyệt NCC đã duyệt → hệ thống chép dòng giá của từng NCC trúng thầu"
icon={<FileCheck className="h-5 w-5" />}
accent="brand"
actions={
<Button variant="outline" onClick={() => navigate('/khkk/list')}>
<X className="mr-2 h-4 w-4" />
Huỷ
</Button>
}
/>
<form onSubmit={onSubmit} className="rounded-lg border border-slate-200 bg-white shadow-sm">
{/* Thanh đầu — mirror `PeWorkspaceCreateView.tsx:155-165` */}
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-5 py-3">
<div>
<h2 className="text-base font-semibold text-slate-900">Lập kế hoạch kết mới</h2>
<p className="mt-0.5 text-[12px] text-slate-500">
Nhập Section 1 + 2 bấm <strong>&ldquo;Tạo kế hoạch&rdquo;</strong> sau đó dòng giá NCC / căn cứ hồ / ý kiến mở khóa.
</p>
</div>
<Button type="button" variant="ghost" onClick={() => navigate('/khkk/list')} className="text-xs">
Hủy
</Button>
</div>
<form onSubmit={onSubmit} className="space-y-5">
{/* Section 1: Phiếu Duyệt NCC nguồn */}
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
<header className="flex 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-brand-50)', ['--chip-fg' as string]: 'var(--color-brand-600)' }}
aria-hidden
>
<FileText className="h-4 w-4" />
</span>
<h3 className="text-sm font-semibold tracking-tight text-brand-800">
Phiếu Duyệt NCC nguồn <span className="text-red-500">*</span>
</h3>
</header>
<div className="space-y-2 p-5">
{candidates.isLoading && <div className="text-sm text-muted-foreground">Đang tải danh sách phiếu...</div>}
{!candidates.isLoading && list.length === 0 && (
<div className="rounded-lg border border-dashed border-slate-300 p-6 text-center text-sm text-muted-foreground">
Không phiếu Duyệt NCC nào đã duyệt NCC trúng thầu.
</div>
)}
<div className="max-h-96 space-y-2 overflow-y-auto">
{list.map((pe) => {
const selected = pe.id === purchaseEvaluationId
// [K2 S164 — OG-7] `?? []` = phòng payload cũ trong cache (trước K2 DTO
// không có field này) — thiếu guard thì `.length` nổ trắng trang.
const taken = pe.existingGroups ?? []
const free = firstFreeGroup(taken)
return (
<div key={pe.id} className="space-y-1">
<div className="divide-y divide-slate-200">
<Section title="1. Phiếu Duyệt NCC nguồn *">
{candidates.isLoading && <div className="text-sm text-muted-foreground">Đang tải danh sách phiếu...</div>}
{!candidates.isLoading && list.length === 0 && (
<div className="rounded-lg border border-dashed border-slate-300 p-6 text-center text-sm text-muted-foreground">
Không phiếu Duyệt NCC nào đã duyệt NCC trúng thầu.
</div>
)}
<div className="max-h-96 space-y-2 overflow-y-auto">
{list.map((pe) => {
const selected = pe.id === purchaseEvaluationId
// [K2 S164 — OG-7] `?? []` = phòng payload cũ trong cache (trước K2 DTO
// không có field này) — thiếu guard thì `.length` nổ trắng trang.
const taken = pe.existingGroups ?? []
const free = firstFreeGroup(taken)
return (
<div key={pe.id} className="space-y-1">
<button
type="button"
onClick={() => selectPe(pe)}
@ -264,39 +265,23 @@ export function KhkkCreatePage() {
)}
</div>
)}
</div>
)
})}
</div>
</div>
)
})}
</div>
</section>
</Section>
{/* Section 2: Quy trình duyệt */}
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-violet-500)' }}>
<header className="flex 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-violet-50)', ['--chip-fg' as string]: 'var(--color-violet-700)' }}
aria-hidden
>
<Settings2 className="h-4 w-4" />
</span>
<h3 className="text-sm font-semibold tracking-tight text-violet-700">Nhóm duyệt & quy trình</h3>
</header>
<div className="grid grid-cols-1 gap-4 p-5 md:grid-cols-2">
<Section title="2. Nhóm duyệt & quy trình">
<div className="grid gap-3 md:grid-cols-2">
{/* [K2 S164 — vá-4] Nhóm duyệt N1..N8. Mặc định nhóm 1 (UI-hướng-1: ca HẸP,
user cần nhóm khác thì đổi ngay tại đây). Nhóm PE đã dùng bị disable —
rào cứng 409 vẫn ở BE, ô này chỉ để không mời bấm nhầm. */}
<div className="space-y-2">
<Label htmlFor="approvalGroup" className="flex items-center gap-1.5">
<Layers className="h-3.5 w-3.5 text-violet-600" />
Nhóm duyệt (N1N8) <span className="text-red-500">*</span>
</Label>
<select
<div>
<Label htmlFor="approvalGroup" className="text-[11px]">Nhóm duyệt (N1N8) *</Label>
<Select
id="approvalGroup"
value={approvalGroup}
onChange={(e) => setApprovalGroup(Number(e.target.value))}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
>
{KHKK_APPROVAL_GROUPS.map((g) => {
const used = takenGroups.includes(g)
@ -307,83 +292,92 @@ export function KhkkCreatePage() {
</option>
)
})}
</select>
</Select>
{groupTaken ? (
<div className="mt-1 text-xs font-medium text-red-600">
<p className="mt-1 text-[11px] font-medium text-red-600">
Phiếu Duyệt NCC này đã kế hoạch kết cho nhóm N{approvalGroup} chọn nhóm khác.
</div>
</p>
) : (
<div className="text-xs text-muted-foreground mt-1">
<p className="mt-1 text-[11px] text-slate-500">
Mỗi phiếu Duyệt NCC lập đưc 1 kế hoạch cho MỖI nhóm duyệt
</div>
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="workflow">
Quy trình duyệt <span className="text-red-500">*</span>
</Label>
<select
<div>
<Label htmlFor="workflow" className="text-[11px]">Quy trình duyệt *</Label>
<Select
id="workflow"
value={approvalWorkflowId}
onChange={(e) => setApprovalWorkflowId(e.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
>
<option value=""> Chọn quy trình </option>
<option value=""> Chọn quy trình duyệt </option>
{workflows.data?.map((w) => (
<option key={w.id} value={w.id}>
{w.code} - {w.name}
</option>
))}
</select>
<div className="text-xs text-muted-foreground mt-1">
Chỉ hiển thị quy trình loại "Kế hoạch ký kết HĐ" admin đã ghim cho user chọn
</div>
</Select>
{workflows.data && workflows.data.length === 0 ? (
<p className="mt-1 text-[11px] text-amber-700">
Chưa quy trình duyệt "Kế hoạch ký kết HĐ" nào đưc ghim cho user chọn. Liên hệ admin tạo trước.
</p>
) : (
<p className="mt-1 text-[11px] text-slate-500">
Chỉ hiển thị quy trình loại "Kế hoạch ký kết HĐ" admin đã ghim cho user chọn
</p>
)}
</div>
</div>
</section>
</Section>
{/* Section 3: Ghi chú */}
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-teal-500)' }}>
<header className="flex 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-teal-50)', ['--chip-fg' as string]: 'var(--color-teal-700)' }}
aria-hidden
>
<FileText className="h-4 w-4" />
<Section title="3. Ghi chú">
<Label htmlFor="ghiChu" className="text-[11px]">Ghi chú kế hoạch</Label>
<Textarea
id="ghiChu"
value={ghiChu}
onChange={(e) => setGhiChu(e.target.value)}
maxLength={2000}
rows={4}
placeholder="Lưu ý về tiến độ ký kết, điều kiện thương thảo..."
/>
<div className="mt-1 text-[11px] text-slate-500">{ghiChu.length}/2000</div>
</Section>
</div>
{/* Thanh cuối — hint TRÁI kể rõ còn thiếu gì, nút PHẢI (mirror `:318-337`).
`groupTaken` chặn ngay ở FE lượt bấm chắc chắn ăn 409 của BE
(`Features.cs:395-402`) — vẫn để BE là rào thật, đây chỉ đỡ 1 vòng mạng. */}
<div className="flex items-center justify-between gap-3 border-t border-slate-200 bg-slate-50 px-5 py-3">
<div className="min-w-0 text-[11px]">
{missingCreate.length > 0 ? (
<span className="text-red-600">
Chưa đ đ tạo kế hoạch còn thiếu: <strong>{missingCreate.join(' · ')}</strong>
</span>
<h3 className="text-sm font-semibold tracking-tight text-teal-700">Ghi chú</h3>
</header>
<div className="space-y-2 p-5">
<Label htmlFor="ghiChu">Ghi chú kế hoạch</Label>
<Textarea
id="ghiChu"
value={ghiChu}
onChange={(e) => setGhiChu(e.target.value)}
maxLength={2000}
rows={4}
placeholder="Lưu ý về tiến độ ký kết, điều kiện thương thảo..."
/>
<div className="text-xs text-muted-foreground mt-1">{ghiChu.length}/2000</div>
</div>
</section>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => navigate('/khkk/list')}>
Huỷ
) : (
<span className="font-medium text-emerald-600"> Đ thông tin bắt buộc thể tạo kế hoạch</span>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<Button type="button" variant="ghost" onClick={() => navigate('/khkk/list')} className="text-xs">
Hủy
</Button>
{/* [K2 S164] `groupTaken` chặn ngay ở FE lượt bấm chắc chắn ăn 409 của BE
(`Features.cs:395-402`) — vẫn để BE là rào thật, đây chỉ là đỡ 1 vòng mạng. */}
<Button
type="submit"
disabled={create.isPending || !purchaseEvaluationId || !approvalWorkflowId || groupTaken}
>
<Save className="mr-2 h-4 w-4" />
{create.isPending ? 'Đang lưu...' : 'Lưu nháp'}
<Button type="submit" disabled={!canSubmit}>
{create.isPending ? 'Đang tạo…' : 'Tạo kế hoạch'}
</Button>
</div>
</form>
</div>
</div>
</form>
)
}
// Helper — duplicate từ `PeWorkspaceCreateView` để tránh circular import (cùng lý do
// vì sao bên đó cũng tự dựng lại thay vì import từ `PeDetailTabs`).
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="px-5 py-4">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-500">{title}</h3>
{children}
</section>
)
}

View File

@ -19,7 +19,6 @@ import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { Select } from '@/components/ui/Select'
import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
import { KhkkWorkflowPanel } from '@/components/khkk/KhkkWorkflowPanel'
import { useAuth } from '@/contexts/AuthContext'
import { usePermission } from '@/hooks/usePermission'
import { api } from '@/lib/api'
@ -159,10 +158,13 @@ export function KhkkDetailPage() {
}
/>
{/* Panel duyệt đứng TRƯỚC nội dung — người duyệt thấy việc-phải-làm trước (mirror thứ
tự của màn Duyệt NCC). Nút Xóa phiếu nay nằm trong panel này. */}
<KhkkWorkflowPanel plan={k} onChanged={invalidate} onDeleted={() => navigate(backToList)} />
{/* [S174 owner] Khối "Quy trình duyệt" (sơ-đồ Bước/Cấp + HÀNH ĐỘNG + 2 lịch sử) ĐÃ GỠ
khỏi trang này — anh chốt "bỏ hết, nó sẽ hiển thị ở Menu duyệt". Nhà của nó nay là
Panel-3 của màn danh sách (`KhkkListPage.tsx:196` — cùng `KhkkWorkflowPanel`), nên
gỡ ở đây là bỏ BẢN SAO THỨ HAI, không phải bỏ tính năng.
🔴 Hệ quả CÒN LẠI phải biết: `<lg` không có 3 panel nên `selectPlan` điều hướng vào
đúng trang này (`KhkkListPage.tsx:107`) ⇒ trên mobile hiện KHÔNG còn đường Gửi
duyệt / Xóa phiếu. Đã khai với anh; mở lại thì bọc `<div className="lg:hidden">`. */}
<KhkkDetailContent plan={k} readOnly={readOnly} onChanged={invalidate} />
{bridgeOpen && <CreateContractFromKhkkDialog plan={k} onClose={() => setBridgeOpen(false)} />}

View File

@ -147,7 +147,6 @@ export function KhkkListPage() {
phase={phase}
onPhaseChange={(v) => { setChipPhase(v); setPage(1) }}
group={group}
onGroupChange={(v) => { setGroupOverride(v); setPage(1) }}
deletedView={deletedView}
page={page}
onPageChange={setPage}

View File

@ -15,6 +15,7 @@ import { toast } from 'sonner'
import { ClipboardCheck } from 'lucide-react'
import { EmptyState } from '@/components/EmptyState'
import { PeDetailTabs } from '@/components/pe/PeDetailTabs'
import { PeKhkkSidePanel } from '@/components/pe/PeKhkkSidePanel'
import { PeListPanel } from '@/components/pe/PeListPanel'
import { PeWorkspaceCreateView } from '@/components/pe/PeWorkspaceCreateView'
import { api } from '@/lib/api'
@ -77,7 +78,10 @@ export function PurchaseEvaluationWorkspacePage() {
</div>
</header>
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[260px_1fr] xl:grid-cols-[320px_1fr]">
{/* [S174 owner] Cột 3 (`PeKhkkSidePanel`, 2 tầng 50/50) CHỈ mọc ở `xl` — thêm nó vào
`lg` sẽ bóp panel giữa (nơi nhập liệu thật) xuống dưới ngưỡng dùng được. Ở `lg`
bố cục giữ ĐÚNG 2 panel như trước, không đổi một pixel nào. */}
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[260px_1fr] xl:grid-cols-[320px_1fr_320px]">
{/* Panel 1: List pure picker + sticky create + pencil edit hover.
Workspace chỉ list phiếu Bản nháp (DangSoanThao) — đã gửi duyệt rồi
không hiện ở đây (vào Danh sách / Duyệt). User 2026-05-07. */}
@ -130,6 +134,10 @@ export function PurchaseEvaluationWorkspacePage() {
/>
)}
</main>
{/* Panel 3: 1 cột 2 tầng — trên = PE đã duyệt CHƯA lập KHKK (gom theo dự án),
dưới = KHKK đã tạo. Tự gate `KeHoachKyKet.Read` bên trong (gotcha #85). */}
<PeKhkkSidePanel />
</div>
{/* Mobile fallback: nếu không lg, redirect về detail page */}

View File

@ -21,7 +21,7 @@
import { useState, type ReactNode } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
ClipboardList, FileCheck, MessageSquare, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
Check, ClipboardList, FileCheck, MessageSquare, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
} from 'lucide-react'
import { toast } from 'sonner'
import { PePipelineStrip } from '@/components/pe/PePipelineStrip'
@ -589,61 +589,85 @@ export function KhkkDetailContent({
Phiếu chưa pin quy trình duyệt chưa cấp nào đ ghi ý kiến.
</div>
) : (
<div className="space-y-4">
<div className="space-y-3">
{steps.map((step) => {
const signedCount = step.levels.filter(lv =>
opinions.some(o => o.approvalWorkflowLevelId === lv.id)).length
return (
<div key={step.id} className="rounded-lg border border-slate-200">
<div className="flex flex-wrap items-center gap-2 border-b border-slate-100 bg-slate-50/70 px-3 py-2">
<span className="text-xs font-semibold text-slate-800">Bước {step.order} {step.name}</span>
<div key={step.id} className="rounded-lg border border-slate-200 bg-slate-50/40">
<div className="flex flex-wrap items-center gap-2 border-b border-slate-200 bg-slate-50/80 px-3 py-2">
<span className="text-[12px] font-bold uppercase tracking-wide text-slate-700">
Bước {step.order} {step.name}
</span>
{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>
)}
<span className="ml-auto text-[10px] text-slate-500">
<span
className="ml-auto text-[10px] text-slate-500"
title={`Tổng ${step.levels.length} NV tham gia (mỗi cấp chỉ cần 1 NV ký — OR-of-N)`}
>
{signedCount}/{step.levels.length} cấp đã duyệt · {step.levels.length} NV tham gia
</span>
</div>
<div className="grid grid-cols-1 gap-2 p-3 md:grid-cols-2">
{step.levels.length === 0 && (
<p className="text-[11px] italic text-slate-400">Bước này chưa cấu hình cấp duyệt.</p>
)}
{step.levels.map((lv) => {
const signed = opinions.find(o => o.approvalWorkflowLevelId === lv.id)
return (
<div
key={lv.id}
className={cn(
'rounded-md border px-2.5 py-2 text-[11px]',
signed ? 'border-emerald-200 bg-emerald-50/40' : 'border-dashed border-slate-200 bg-white',
)}
>
<div className="flex flex-wrap items-center gap-x-1.5 text-slate-700">
<span className="font-medium">{lv.name || `Cấp ${lv.order}`}</span>
<span className="text-slate-400">·</span>
<span>{lv.approverFullName ?? '(chưa cấu hình)'}</span>
{signed && (
<>
<span className="text-slate-400">·</span>
<span className="text-slate-500">{formatDateTime(signed.signedAt)}</span>
{signed.approverUserId && signed.approverUserId !== signed.signedByUserId && (
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[9px] font-semibold text-amber-800">
duyệt thay ({signed.signedByFullName})
<div className="p-3">
{step.levels.length === 0 ? (
<div className="text-[12px] italic text-slate-400"> Bước này chưa cấu hình cấp duyệt.</div>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{step.levels.map((lv) => {
const signed = opinions.find(o => o.approvalWorkflowLevelId === lv.id)
return (
<div
key={lv.id}
className={cn(
'rounded-lg border bg-white p-3',
signed ? 'border-emerald-200' : 'border-dashed border-slate-200',
)}
>
<div className="mb-2 flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<h4 className="text-[13px] font-semibold text-slate-700">
{lv.name || `Cấp ${lv.order}`} {' '}
<span className="text-slate-900">{lv.approverFullName ?? '(chưa cấu hình)'}</span>
</h4>
{/* Không dùng `signed!` — thu hẹp bằng chính `signed &&` để
khỏi mượn null-forgiving trên dữ-liệu từ API (gotcha #80). */}
{signed && signed.approverUserId
&& signed.approverUserId !== signed.signedByUserId && (
<div className="mt-1 inline-flex items-center gap-1 rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700">
Admin <strong>{signed.signedByFullName}</strong> duyệt thay
</div>
)}
</div>
{signed ? (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-medium text-emerald-700">
<Check className="h-3 w-3" /> Đã duyệt
</span>
) : (
<span className="inline-flex shrink-0 items-center rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-500">
Chưa
</span>
)}
</>
)}
</div>
<div className="mt-0.5 whitespace-pre-wrap text-slate-600">
{signed
? (signed.comment?.trim() || '(duyệt — không ý kiến)')
: <span className="italic text-slate-300">Chưa </span>}
</div>
</div>
)
})}
</div>
{signed ? (
<>
<div className="whitespace-pre-wrap text-sm text-slate-800">
{signed.comment?.trim() || '(duyệt — không ý kiến)'}
</div>
<div className="mt-2 border-t border-slate-100 pt-1.5 text-[11px] text-slate-500">
{formatDateTime(signed.signedAt)}
</div>
</>
) : (
<div className="text-sm italic text-slate-400">Chưa ý kiến duyệt.</div>
)}
</div>
)
})}
</div>
)}
</div>
</div>
)

View File

@ -31,11 +31,9 @@ import { usePipelineStages } from '@/hooks/usePipelineStages'
import { api } from '@/lib/api'
import { cn } from '@/lib/cn'
import {
KHKK_APPROVAL_GROUPS,
KHKK_PHASE_BADGE,
KHKK_PHASE_LABELS,
KhkkPhase,
khkkGroupMenuLabel,
type KhkkListItemDto,
type KhkkPhaseValue,
type PagedResult,
@ -172,7 +170,6 @@ export function KhkkListPanel({
phase,
onPhaseChange,
group,
onGroupChange,
deletedView,
page,
onPageChange,
@ -184,8 +181,10 @@ export function KhkkListPanel({
onSearchChange: (v: string) => void
phase: KhkkPhaseValue | null
onPhaseChange: (v: KhkkPhaseValue | null) => void
// [S174 owner] CHỈ còn đọc — `Select` nhóm đã gỡ khỏi UI. Nhóm nay đến DUY NHẤT từ URL
// (`?group=n`, tức 8 leaf nhóm ở menu trái). Máy lọc giữ NGUYÊN (query + `stage2Content`);
// chỉ bỏ đường đổi nhóm bằng tay — vào từ leaf nhóm rồi thì ô đó là thừa.
group: number | null
onGroupChange: (v: number | null) => void
deletedView: boolean
page: number
onPageChange: (p: number) => void
@ -265,28 +264,16 @@ export function KhkkListPanel({
className="pl-8"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<Select
value={phase ?? ''}
aria-label="Lọc trạng thái"
onChange={e => onPhaseChange(e.target.value === '' ? null : (Number(e.target.value) as KhkkPhaseValue))}
>
<option value="">Tất cả trạng thái</option>
{(Object.keys(KHKK_PHASE_LABELS) as unknown as number[]).map(p => (
<option key={p} value={p}>{KHKK_PHASE_LABELS[Number(p) as KhkkPhaseValue]}</option>
))}
</Select>
<Select
value={group ?? ''}
aria-label="Lọc nhóm duyệt"
onChange={e => onGroupChange(e.target.value === '' ? null : Number(e.target.value))}
>
<option value="">Tất cả nhóm</option>
{KHKK_APPROVAL_GROUPS.map(n => (
<option key={n} value={n}>{khkkGroupMenuLabel(n)}</option>
))}
</Select>
</div>
<Select
value={phase ?? ''}
aria-label="Lọc trạng thái"
onChange={e => onPhaseChange(e.target.value === '' ? null : (Number(e.target.value) as KhkkPhaseValue))}
>
<option value="">Tất cả trạng thái</option>
{(Object.keys(KHKK_PHASE_LABELS) as unknown as number[]).map(p => (
<option key={p} value={p}>{KHKK_PHASE_LABELS[Number(p) as KhkkPhaseValue]}</option>
))}
</Select>
{flatMode && !deletedView && canPe && (
<p className="text-[10px] italic text-slate-400">
Đang lọc danh sách lấy thẳng từ máy chủ (không giới hạn 200 phiếu của cây).
@ -305,6 +292,7 @@ export function KhkkListPanel({
<PipelineTreePanel
currentStage={2}
className="border-0 shadow-none"
hideHeader
bodyMaxHClass="max-h-[28rem] lg:max-h-none"
stage2Options={{ stage2Content, stage2Count, onOpenKhkk: onSelect }}
/>

View File

@ -0,0 +1,252 @@
// [S174 owner] Cột PHẢI của màn "Duyệt NCC — Thao tác" — 1 cột, 2 TẦNG 50/50:
// · tầng TRÊN = phiếu Duyệt NCC ĐÃ DUYỆT (có NCC trúng thầu) mà CHƯA lập kế hoạch ký kết,
// gom theo DỰ ÁN — đây là "việc còn phải làm";
// · tầng DƯỚI = kế hoạch ký kết ĐÃ TẠO — đây là "việc đã làm rồi".
// Anh chốt: "Thử ý tưởng 1 cột 2 tầng thử, 50% tầng trên là duyệt NCC, còn 50% tầng dưới
// là KHKK". File MIRROR SHA256 identical với fe-user counterpart.
//
// 🔴 0 ENDPOINT MỚI — dùng lại đúng 2 cửa đã sống:
// · `GET /contract-signing-plans/approved-pe-awaiting-plan` (chính cửa `KhkkCreatePage`
// đang gọi) → `KhkkPeCandidateDto[]`; "chưa có kế hoạch" = `existingGroups` RỖNG.
// 🔸 Từ K2 S164 cửa này KHÔNG còn loại PE đã có kế hoạch (nó trả cả PE đã dùng vài
// nhóm, kèm `existingGroups`) ⇒ lọc rỗng là việc của FE, đừng tưởng BE lọc hộ.
// · `GET /contract-signing-plans` → `PagedResult<KhkkListItemDto>`.
//
// 🔴 GATE QUYỀN (gotcha #85 — mặt đã cắn thật ở S162): màn đang đứng là *Duyệt NCC*, nhưng
// CẢ HAI cửa trên gác bằng policy `KeHoachKyKet.Read` (`ContractSigningPlansController.cs:36`
// + `MenuPermissionHandler.cs:40` so khớp CHÍNH XÁC 1 key — KHÔNG kế thừa cha, KHÔNG OR key
// con). Người chỉ có quyền PE mà không có KHKK sẽ ăn 403 rải khắp cột. ⇒ gate bằng ĐÚNG key
// của endpoint, và khi thiếu quyền thì NÓI RA thay vì để cột trắng trông như "chưa có gì".
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { FileCheck, FileSignature, Lock } from 'lucide-react'
import { usePermission } from '@/hooks/usePermission'
import { api } from '@/lib/api'
import { cn } from '@/lib/cn'
import {
KHKK_PHASE_BADGE,
KHKK_PHASE_LABELS,
type KhkkListItemDto,
type KhkkPeCandidateDto,
type PagedResult,
} from '@/types/khkk'
const KHKK_TIER_PAGE_SIZE = 30
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
/** Vỏ 1 tầng — `min-h-0 flex-1` ở CẢ HAI tầng là thứ tạo ra tỉ lệ 50/50 thật; thiếu
* `min-h-0` thì con cuộn được sẽ đẩy tầng phình theo nội dung, hỏng tỉ lệ. */
function Tier({
title, count, icon, accent, children,
}: {
title: string
count: number | null
icon: React.ReactNode
accent: string
children: React.ReactNode
}) {
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex shrink-0 items-center gap-2 border-b border-slate-200 bg-slate-50/80 px-3 py-2">
<span className={cn('inline-flex h-5 w-5 items-center justify-center rounded', accent)} aria-hidden>
{icon}
</span>
<span className="min-w-0 flex-1 truncate text-[11px] font-bold uppercase tracking-wide text-slate-600">
{title}
</span>
{count !== null && (
<span className="shrink-0 rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">
{count}
</span>
)}
</div>
<div className="min-h-0 flex-1 overflow-y-auto">{children}</div>
</div>
)
}
export function PeKhkkSidePanel() {
const navigate = useNavigate()
const { can } = usePermission()
// 🔴 ĐÚNG key policy của endpoint — KHÔNG dùng `Khkk_*` (đó là key MENU, tầng hiển thị).
const canKhkk = can('KeHoachKyKet', 'Read')
const candidates = useQuery({
// Key dùng CHUNG với `KhkkCreatePage` là CỐ Ý: tạo kế hoạch xong quay lại đây thì
// cột tự tươi, không phải nhớ invalidate thêm một key thứ hai.
queryKey: ['khkk-pe-candidates'],
queryFn: async () =>
(await api.get<KhkkPeCandidateDto[]>('/contract-signing-plans/approved-pe-awaiting-plan')).data,
enabled: canKhkk,
})
const plans = useQuery({
queryKey: ['khkk-list', { side: true, page: 1, pageSize: KHKK_TIER_PAGE_SIZE }],
queryFn: async () =>
(await api.get<PagedResult<KhkkListItemDto>>('/contract-signing-plans', {
params: { page: 1, pageSize: KHKK_TIER_PAGE_SIZE },
})).data,
enabled: canKhkk,
})
// "Chưa được tạo phiếu KHKK" = chưa dùng nhóm duyệt NÀO. `?? []` phòng payload cũ còn
// trong cache React Query (DTO trước K2 không có field này) — thiếu guard thì `.length` nổ.
const awaiting = (candidates.data ?? []).filter((p) => (p.existingGroups ?? []).length === 0)
// Gom theo DỰ ÁN — đúng chữ "theo dự án" của anh. `Map` giữ thứ tự chèn nên dự án nào có
// phiếu mới nhất (BE trả sẵn theo thứ tự) thì nổi lên trước.
const byProject = new Map<string, KhkkPeCandidateDto[]>()
for (const pe of awaiting) {
const key = pe.projectName ?? '(chưa gán dự án)'
const bucket = byProject.get(key)
if (bucket) bucket.push(pe)
else byProject.set(key, [pe])
}
const planItems = plans.data?.items ?? []
return (
<aside className="hidden min-h-0 flex-col overflow-hidden border-l border-slate-200 bg-white xl:flex">
{!canKhkk ? (
// Cột trắng đọc thành "chưa có gì" — phải nói ĐÚNG lý do là thiếu quyền.
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-4 text-center">
<Lock className="h-5 w-5 text-slate-300" />
<p className="text-[11px] text-slate-500">
Bạn chưa quyền xem Kế hoạch kết nên cột này không dựng đưc. Việc duyệt NCC
hai panel bên trái không bị nh hưởng.
</p>
</div>
) : (
<>
<Tier
title="Đã duyệt · chưa lập KHKK"
count={awaiting.length}
accent="bg-amber-50 text-amber-600"
icon={<FileCheck className="h-3 w-3" />}
>
{candidates.isLoading && <div className="p-3 text-[11px] text-slate-500">Đang tải</div>}
{candidates.isError && (
<div className="space-y-1.5 p-3">
<p className="text-[11px] text-slate-500">Không tải đưc danh sách.</p>
<button
type="button"
onClick={() => candidates.refetch()}
className="rounded-md border border-slate-300 px-2 py-1 text-[10px] font-semibold text-slate-600 hover:border-brand-300 hover:bg-brand-50"
>
Thử lại
</button>
</div>
)}
{!candidates.isLoading && !candidates.isError && awaiting.length === 0 && (
<p className="p-3 text-[11px] italic text-slate-400">
Mọi phiếu đã duyệt đu đã kế hoạch kết.
</p>
)}
{[...byProject.entries()].map(([projectName, items]) => (
<div key={projectName}>
<div className="sticky top-0 z-10 flex items-center gap-1.5 border-b border-slate-100 bg-white/95 px-3 py-1 backdrop-blur">
<span className="min-w-0 flex-1 truncate text-[10px] font-semibold uppercase tracking-wide text-slate-500">
{projectName}
</span>
<span className="shrink-0 text-[10px] text-slate-400">{items.length}</span>
</div>
<ul className="divide-y divide-slate-100">
{items.map((pe) => (
<li key={pe.id}>
<button
type="button"
// Bấm = đi thẳng tới màn lập kế hoạch. Không preset `?group=` vì
// PE này chưa dùng nhóm nào ⇒ để màn tạo giữ default của nó.
onClick={() => navigate('/khkk/create')}
title="Lập kế hoạch ký kết cho phiếu này"
className="block w-full px-3 py-2 text-left transition hover:bg-amber-50/60"
>
<div className="flex items-center gap-1.5">
<span className="font-mono text-[10px] text-brand-700">{pe.maPhieu ?? '—'}</span>
<span className="text-[10px] text-slate-400">·</span>
<span className="truncate text-[10px] text-slate-500">{formatDate(pe.createdAt)}</span>
</div>
<div className="truncate text-[12px] font-medium text-slate-800">{pe.tenGoiThau}</div>
{pe.winnerSupplierNames.length > 0 && (
<div className="mt-0.5 truncate text-[10px] text-emerald-700">
{pe.winnerSupplierNames.join(' · ')}
</div>
)}
</button>
</li>
))}
</ul>
</div>
))}
</Tier>
<div className="shrink-0 border-t-2 border-slate-200" />
<Tier
title="Kế hoạch ký kết đã tạo"
count={plans.data?.total ?? null}
accent="bg-violet-50 text-violet-600"
icon={<FileSignature className="h-3 w-3" />}
>
{plans.isLoading && <div className="p-3 text-[11px] text-slate-500">Đang tải</div>}
{plans.isError && (
<div className="space-y-1.5 p-3">
<p className="text-[11px] text-slate-500">Không tải đưc danh sách.</p>
<button
type="button"
onClick={() => plans.refetch()}
className="rounded-md border border-slate-300 px-2 py-1 text-[10px] font-semibold text-slate-600 hover:border-brand-300 hover:bg-brand-50"
>
Thử lại
</button>
</div>
)}
{!plans.isLoading && !plans.isError && planItems.length === 0 && (
<p className="p-3 text-[11px] italic text-slate-400">Chưa kế hoạch kết nào.</p>
)}
<ul className="divide-y divide-slate-100">
{planItems.map((k) => (
<li key={k.id}>
<button
type="button"
onClick={() => navigate(`/khkk/${k.id}`)}
title="Mở kế hoạch ký kết"
className="block w-full px-3 py-2 text-left transition hover:bg-violet-50/60"
>
<div className="flex items-center gap-1.5">
<span className="font-mono text-[10px] text-violet-700">{k.maKeHoach ?? '(chưa cấp mã)'}</span>
{k.approvalGroup != null && (
<span className="rounded border border-violet-200 bg-violet-50 px-1 text-[9px] font-semibold text-violet-700">
N{k.approvalGroup}
</span>
)}
<span
className={cn(
'ml-auto shrink-0 rounded border px-1.5 py-0.5 text-[9px] font-medium',
KHKK_PHASE_BADGE[k.phase],
)}
>
{KHKK_PHASE_LABELS[k.phase]}
</span>
</div>
<div className="truncate text-[12px] font-medium text-slate-800">
{k.peTenGoiThau ?? '(chưa có tên gói thầu)'}
</div>
</button>
</li>
))}
</ul>
{plans.data && plans.data.total > planItems.length && (
<p className="border-t border-slate-100 px-3 py-1.5 text-[10px] italic text-slate-400">
Hiện {planItems.length}/{plans.data.total} kế hoạch mới nhất xem đ menu Kế hoạch kết .
</p>
)}
</Tier>
</>
)}
</aside>
)
}

View File

@ -88,6 +88,7 @@ export function PipelineTreePanel({
className,
bodyMaxHClass,
stage2Options,
hideHeader,
}: {
currentStage: PipelineStageNo
className?: string
@ -100,6 +101,12 @@ export function PipelineTreePanel({
// hiệu-chỉnh cho trang giấy-cuộn, vượt chỗ Panel-3 /inbox ~26px ⇒ 2 cuộn lồng nhau).
// Mặc định giữ nguyên — 3 host giấy-cuộn cũ không đổi hành vi.
bodyMaxHClass?: string
// [S174 owner] Ẩn thanh tiêu-đề "Cây toàn trình · N gói". CHỈ dùng ở host đã có tiêu-đề
// riêng ngay trên (KHKK GĐ2: header trang `KhkkListPage.tsx:124`) — 2 tiêu-đề chồng nhau
// trong cùng một cột là thừa. 6 host còn lại KHÔNG truyền ⇒ giữ nguyên hình cũ.
// 🔴 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
}) {
const { canPe, buildStages } = usePipelineStages()
const [search, setSearch] = useState('')
@ -189,29 +196,32 @@ export function PipelineTreePanel({
style={{ ['--accent' as string]: 'var(--color-brand-500)' }}
aria-label="Cây toàn trình theo gói thầu"
>
<div className="flex items-center gap-2 border-b border-slate-200 px-3 py-2.5">
<span
className="icon-chip h-7! w-7!"
style={{ ['--chip-bg' as string]: 'var(--color-brand-50)', ['--chip-fg' as string]: 'var(--color-brand-600)' }}
aria-hidden
>
<ListTree className="h-3.5 w-3.5" />
</span>
<span className="label-eyebrow min-w-0 flex-1 truncate">Cây toàn trình</span>
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">
{packageCount} gói
</span>
<button
type="button"
onClick={() => setMobileOpen(o => !o)}
aria-expanded={mobileOpen}
className="rounded-md border border-slate-300 px-2 py-1 text-[11px] font-semibold text-slate-600 transition hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/70 lg:hidden"
>
{mobileOpen ? 'Ẩn cây' : 'Hiện cây'}
</button>
</div>
{!hideHeader && (
<div className="flex items-center gap-2 border-b border-slate-200 px-3 py-2.5">
<span
className="icon-chip h-7! w-7!"
style={{ ['--chip-bg' as string]: 'var(--color-brand-50)', ['--chip-fg' as string]: 'var(--color-brand-600)' }}
aria-hidden
>
<ListTree className="h-3.5 w-3.5" />
</span>
<span className="label-eyebrow min-w-0 flex-1 truncate">Cây toàn trình</span>
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">
{packageCount} gói
</span>
<button
type="button"
onClick={() => setMobileOpen(o => !o)}
aria-expanded={mobileOpen}
className="rounded-md border border-slate-300 px-2 py-1 text-[11px] font-semibold text-slate-600 transition hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/70 lg:hidden"
>
{mobileOpen ? 'Ẩn cây' : 'Hiện cây'}
</button>
</div>
)}
<div className={cn('min-h-0 flex-col', mobileOpen ? 'flex' : 'hidden', 'lg:flex')}>
{/* [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

View File

@ -1,25 +1,32 @@
// [W2 KHKK — S161 2026-07-29] Lập "Kế hoạch ký kết HĐ" từ phiếu Duyệt NCC đã duyệt.
// Khuôn `pages/office/ProposalCreatePage.tsx` (PURO: ui/PageHeader + section
// .card-accent + .icon-chip header). File MIRROR SHA256 identical với fe-admin.
// File MIRROR SHA256 identical với fe-user counterpart.
//
// 3 section: (1) chọn phiếu PE nguồn — phiếu DaDuyet có NCC trúng thầu (BE lọc, FE
// không tự đoán); (2) NHÓM DUYỆT N1..N8 + quy trình duyệt V2 ApplicableType=10 admin
// đã ghim; (3) ghi chú. Dòng giá per-NCC do BE tự sinh từ winner của phiếu PE
// (snapshot `PeReferenceAmount`) — FE KHÔNG gửi lines.
//
// [K2 S164 — OG-7 UI-hướng-1] ĐỔI HÌNH DẠNG màn này: trước K2, PE đã có kế hoạch bị
// BE loại khỏi picker (biến mất) và tạo trùng thì ăn 409 chặn hẳn. Từ K2, rào là
// "1 PE = 1 kế hoạch MỖI NHÓM" ⇒ PE vẫn hiện, kèm danh sách nhóm đã dùng + nút phụ
// "Tạo thêm cho nhóm khác" (preset sang nhóm còn trống). Ẩn PE đi thì người dùng
// không phân biệt được "PE không đủ điều kiện" với "PE đã dùng hết nhóm".
import { useState, type FormEvent } from 'react'
// [S174 owner] ĐỔI KHUÔN: bỏ `PageHeader` + 3 khối `.card-accent` rời, chuyển sang ĐÚNG
// hình "Tạo phiếu Duyệt NCC mới" (`components/pe/PeWorkspaceCreateView.tsx:152-352`) —
// anh chốt "Tạo Header → tương tự NCC → sau khi Header tạo xong thì cho sang chi tiết":
// · MỘT card `rounded-lg border bg-white shadow-sm`, các section ngăn bằng `divide-y`;
// · thanh đầu = tiêu-đề + 1 dòng kể LUỒNG (nhập gì → bấm gì → cái gì mở khoá) + "← Hủy";
// · thanh cuối = hint TRÁI liệt kê ĐÍCH DANH trường còn thiếu, nút PHẢI.
// 🔸 Vế "sang chi tiết" VỐN ĐÃ ĐÚNG từ S161 (`onSuccess` → `navigate('/khkk/:id')`) — đợt
// này KHÔNG đụng luồng đó, chỉ đổi hình. Ghi ra để người sau khỏi tưởng vừa thêm mới.
//
// [K2 S164 — OG-7 UI-hướng-1] Rào là "1 PE = 1 kế hoạch MỖI NHÓM" ⇒ PE vẫn hiện, kèm
// danh sách nhóm đã dùng + nút phụ "Tạo thêm cho nhóm khác" (preset sang nhóm còn trống).
// Ẩn PE đi thì người dùng không phân biệt được "PE không đủ điều kiện" với "PE đã dùng
// hết nhóm".
import { useState, type FormEvent, type ReactNode } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { CheckCircle2, FileCheck, FileText, Layers, Plus, Save, Settings2, X } from 'lucide-react'
import { CheckCircle2, Plus } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/ui/PageHeader'
import { Button } from '@/components/ui/Button'
import { Label } from '@/components/ui/Label'
import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
@ -92,8 +99,7 @@ export function KhkkCreatePage() {
})
// Endpoint trả AwAdminOverviewDto { types: [{ applicableType, history: [...] }] } —
// KHÔNG phải flat array (khuôn ProposalCreatePage:59-69). Lọc isUserSelectable =
// quy trình admin đã ghim cho user pick.
// KHÔNG phải flat array. Lọc isUserSelectable = quy trình admin đã ghim cho user pick.
const workflows = useQuery({
queryKey: ['approval-workflows-v2', { applicableType: APPLICABLE_TYPE_KHKK, isUserSelectable: true }],
queryFn: async () => {
@ -121,6 +127,7 @@ export function KhkkCreatePage() {
const res = await api.post<{ id: string; maKeHoach: string | null }>('/contract-signing-plans', body)
return res.data
},
// 🔴 Vế "Header tạo xong thì sang chi tiết" nằm ĐÚNG ở đây — giữ nguyên từ S161.
onSuccess: (data) => {
toast.success(data.maKeHoach ? `Đã lập kế hoạch ${data.maKeHoach}` : 'Đã lập kế hoạch ký kết')
navigate(`/khkk/${data.id}`)
@ -142,6 +149,15 @@ export function KhkkCreatePage() {
const takenGroups = selectedPe?.existingGroups ?? []
const groupTaken = takenGroups.includes(approvalGroup)
// [S174] Khuôn `PeWorkspaceCreateView.tsx:145-150`: liệt kê ĐÍCH DANH cái còn thiếu thay
// vì chỉ khoá nút — nút xám không nói được vì sao nó xám.
const missingCreate = [
!purchaseEvaluationId && 'Phiếu Duyệt NCC nguồn',
!approvalWorkflowId && 'Quy trình duyệt',
groupTaken && `Nhóm duyệt còn trống (N${approvalGroup} đã có kế hoạch)`,
].filter(Boolean) as string[]
const canSubmit = missingCreate.length === 0 && !create.isPending
// Bấm chọn PE (cả ở dòng lẫn ở nút "Tạo thêm cho nhóm khác"): nếu nhóm đang chọn đã
// bị PE đó dùng thì nhảy sang nhóm trống đầu tiên ⇒ người dùng không phải tự dò.
const selectPe = (pe: KhkkPeCandidateDto) => {
@ -154,52 +170,37 @@ export function KhkkCreatePage() {
}
return (
<div className="space-y-5">
<PageHeader
eyebrow="Giai đoạn 2"
title="Lập kế hoạch ký kết HĐ"
subtitle="Chọn phiếu Duyệt NCC đã duyệt → hệ thống chép dòng giá của từng NCC trúng thầu"
icon={<FileCheck className="h-5 w-5" />}
accent="brand"
actions={
<Button variant="outline" onClick={() => navigate('/khkk/list')}>
<X className="mr-2 h-4 w-4" />
Huỷ
</Button>
}
/>
<form onSubmit={onSubmit} className="rounded-lg border border-slate-200 bg-white shadow-sm">
{/* Thanh đầu — mirror `PeWorkspaceCreateView.tsx:155-165` */}
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-5 py-3">
<div>
<h2 className="text-base font-semibold text-slate-900">Lập kế hoạch kết mới</h2>
<p className="mt-0.5 text-[12px] text-slate-500">
Nhập Section 1 + 2 bấm <strong>&ldquo;Tạo kế hoạch&rdquo;</strong> sau đó dòng giá NCC / căn cứ hồ / ý kiến mở khóa.
</p>
</div>
<Button type="button" variant="ghost" onClick={() => navigate('/khkk/list')} className="text-xs">
Hủy
</Button>
</div>
<form onSubmit={onSubmit} className="space-y-5">
{/* Section 1: Phiếu Duyệt NCC nguồn */}
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
<header className="flex 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-brand-50)', ['--chip-fg' as string]: 'var(--color-brand-600)' }}
aria-hidden
>
<FileText className="h-4 w-4" />
</span>
<h3 className="text-sm font-semibold tracking-tight text-brand-800">
Phiếu Duyệt NCC nguồn <span className="text-red-500">*</span>
</h3>
</header>
<div className="space-y-2 p-5">
{candidates.isLoading && <div className="text-sm text-muted-foreground">Đang tải danh sách phiếu...</div>}
{!candidates.isLoading && list.length === 0 && (
<div className="rounded-lg border border-dashed border-slate-300 p-6 text-center text-sm text-muted-foreground">
Không phiếu Duyệt NCC nào đã duyệt NCC trúng thầu.
</div>
)}
<div className="max-h-96 space-y-2 overflow-y-auto">
{list.map((pe) => {
const selected = pe.id === purchaseEvaluationId
// [K2 S164 — OG-7] `?? []` = phòng payload cũ trong cache (trước K2 DTO
// không có field này) — thiếu guard thì `.length` nổ trắng trang.
const taken = pe.existingGroups ?? []
const free = firstFreeGroup(taken)
return (
<div key={pe.id} className="space-y-1">
<div className="divide-y divide-slate-200">
<Section title="1. Phiếu Duyệt NCC nguồn *">
{candidates.isLoading && <div className="text-sm text-muted-foreground">Đang tải danh sách phiếu...</div>}
{!candidates.isLoading && list.length === 0 && (
<div className="rounded-lg border border-dashed border-slate-300 p-6 text-center text-sm text-muted-foreground">
Không phiếu Duyệt NCC nào đã duyệt NCC trúng thầu.
</div>
)}
<div className="max-h-96 space-y-2 overflow-y-auto">
{list.map((pe) => {
const selected = pe.id === purchaseEvaluationId
// [K2 S164 — OG-7] `?? []` = phòng payload cũ trong cache (trước K2 DTO
// không có field này) — thiếu guard thì `.length` nổ trắng trang.
const taken = pe.existingGroups ?? []
const free = firstFreeGroup(taken)
return (
<div key={pe.id} className="space-y-1">
<button
type="button"
onClick={() => selectPe(pe)}
@ -264,39 +265,23 @@ export function KhkkCreatePage() {
)}
</div>
)}
</div>
)
})}
</div>
</div>
)
})}
</div>
</section>
</Section>
{/* Section 2: Quy trình duyệt */}
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-violet-500)' }}>
<header className="flex 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-violet-50)', ['--chip-fg' as string]: 'var(--color-violet-700)' }}
aria-hidden
>
<Settings2 className="h-4 w-4" />
</span>
<h3 className="text-sm font-semibold tracking-tight text-violet-700">Nhóm duyệt & quy trình</h3>
</header>
<div className="grid grid-cols-1 gap-4 p-5 md:grid-cols-2">
<Section title="2. Nhóm duyệt & quy trình">
<div className="grid gap-3 md:grid-cols-2">
{/* [K2 S164 — vá-4] Nhóm duyệt N1..N8. Mặc định nhóm 1 (UI-hướng-1: ca HẸP,
user cần nhóm khác thì đổi ngay tại đây). Nhóm PE đã dùng bị disable —
rào cứng 409 vẫn ở BE, ô này chỉ để không mời bấm nhầm. */}
<div className="space-y-2">
<Label htmlFor="approvalGroup" className="flex items-center gap-1.5">
<Layers className="h-3.5 w-3.5 text-violet-600" />
Nhóm duyệt (N1N8) <span className="text-red-500">*</span>
</Label>
<select
<div>
<Label htmlFor="approvalGroup" className="text-[11px]">Nhóm duyệt (N1N8) *</Label>
<Select
id="approvalGroup"
value={approvalGroup}
onChange={(e) => setApprovalGroup(Number(e.target.value))}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
>
{KHKK_APPROVAL_GROUPS.map((g) => {
const used = takenGroups.includes(g)
@ -307,83 +292,92 @@ export function KhkkCreatePage() {
</option>
)
})}
</select>
</Select>
{groupTaken ? (
<div className="mt-1 text-xs font-medium text-red-600">
<p className="mt-1 text-[11px] font-medium text-red-600">
Phiếu Duyệt NCC này đã kế hoạch kết cho nhóm N{approvalGroup} chọn nhóm khác.
</div>
</p>
) : (
<div className="text-xs text-muted-foreground mt-1">
<p className="mt-1 text-[11px] text-slate-500">
Mỗi phiếu Duyệt NCC lập đưc 1 kế hoạch cho MỖI nhóm duyệt
</div>
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="workflow">
Quy trình duyệt <span className="text-red-500">*</span>
</Label>
<select
<div>
<Label htmlFor="workflow" className="text-[11px]">Quy trình duyệt *</Label>
<Select
id="workflow"
value={approvalWorkflowId}
onChange={(e) => setApprovalWorkflowId(e.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
>
<option value=""> Chọn quy trình </option>
<option value=""> Chọn quy trình duyệt </option>
{workflows.data?.map((w) => (
<option key={w.id} value={w.id}>
{w.code} - {w.name}
</option>
))}
</select>
<div className="text-xs text-muted-foreground mt-1">
Chỉ hiển thị quy trình loại "Kế hoạch ký kết HĐ" admin đã ghim cho user chọn
</div>
</Select>
{workflows.data && workflows.data.length === 0 ? (
<p className="mt-1 text-[11px] text-amber-700">
Chưa quy trình duyệt "Kế hoạch ký kết HĐ" nào đưc ghim cho user chọn. Liên hệ admin tạo trước.
</p>
) : (
<p className="mt-1 text-[11px] text-slate-500">
Chỉ hiển thị quy trình loại "Kế hoạch ký kết HĐ" admin đã ghim cho user chọn
</p>
)}
</div>
</div>
</section>
</Section>
{/* Section 3: Ghi chú */}
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-teal-500)' }}>
<header className="flex 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-teal-50)', ['--chip-fg' as string]: 'var(--color-teal-700)' }}
aria-hidden
>
<FileText className="h-4 w-4" />
<Section title="3. Ghi chú">
<Label htmlFor="ghiChu" className="text-[11px]">Ghi chú kế hoạch</Label>
<Textarea
id="ghiChu"
value={ghiChu}
onChange={(e) => setGhiChu(e.target.value)}
maxLength={2000}
rows={4}
placeholder="Lưu ý về tiến độ ký kết, điều kiện thương thảo..."
/>
<div className="mt-1 text-[11px] text-slate-500">{ghiChu.length}/2000</div>
</Section>
</div>
{/* Thanh cuối — hint TRÁI kể rõ còn thiếu gì, nút PHẢI (mirror `:318-337`).
`groupTaken` chặn ngay ở FE lượt bấm chắc chắn ăn 409 của BE
(`Features.cs:395-402`) — vẫn để BE là rào thật, đây chỉ đỡ 1 vòng mạng. */}
<div className="flex items-center justify-between gap-3 border-t border-slate-200 bg-slate-50 px-5 py-3">
<div className="min-w-0 text-[11px]">
{missingCreate.length > 0 ? (
<span className="text-red-600">
Chưa đ đ tạo kế hoạch còn thiếu: <strong>{missingCreate.join(' · ')}</strong>
</span>
<h3 className="text-sm font-semibold tracking-tight text-teal-700">Ghi chú</h3>
</header>
<div className="space-y-2 p-5">
<Label htmlFor="ghiChu">Ghi chú kế hoạch</Label>
<Textarea
id="ghiChu"
value={ghiChu}
onChange={(e) => setGhiChu(e.target.value)}
maxLength={2000}
rows={4}
placeholder="Lưu ý về tiến độ ký kết, điều kiện thương thảo..."
/>
<div className="text-xs text-muted-foreground mt-1">{ghiChu.length}/2000</div>
</div>
</section>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => navigate('/khkk/list')}>
Huỷ
) : (
<span className="font-medium text-emerald-600"> Đ thông tin bắt buộc thể tạo kế hoạch</span>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<Button type="button" variant="ghost" onClick={() => navigate('/khkk/list')} className="text-xs">
Hủy
</Button>
{/* [K2 S164] `groupTaken` chặn ngay ở FE lượt bấm chắc chắn ăn 409 của BE
(`Features.cs:395-402`) — vẫn để BE là rào thật, đây chỉ là đỡ 1 vòng mạng. */}
<Button
type="submit"
disabled={create.isPending || !purchaseEvaluationId || !approvalWorkflowId || groupTaken}
>
<Save className="mr-2 h-4 w-4" />
{create.isPending ? 'Đang lưu...' : 'Lưu nháp'}
<Button type="submit" disabled={!canSubmit}>
{create.isPending ? 'Đang tạo…' : 'Tạo kế hoạch'}
</Button>
</div>
</form>
</div>
</div>
</form>
)
}
// Helper — duplicate từ `PeWorkspaceCreateView` để tránh circular import (cùng lý do
// vì sao bên đó cũng tự dựng lại thay vì import từ `PeDetailTabs`).
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="px-5 py-4">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-500">{title}</h3>
{children}
</section>
)
}

View File

@ -19,7 +19,6 @@ import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { Select } from '@/components/ui/Select'
import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
import { KhkkWorkflowPanel } from '@/components/khkk/KhkkWorkflowPanel'
import { useAuth } from '@/contexts/AuthContext'
import { usePermission } from '@/hooks/usePermission'
import { api } from '@/lib/api'
@ -159,10 +158,13 @@ export function KhkkDetailPage() {
}
/>
{/* Panel duyệt đứng TRƯỚC nội dung — người duyệt thấy việc-phải-làm trước (mirror thứ
tự của màn Duyệt NCC). Nút Xóa phiếu nay nằm trong panel này. */}
<KhkkWorkflowPanel plan={k} onChanged={invalidate} onDeleted={() => navigate(backToList)} />
{/* [S174 owner] Khối "Quy trình duyệt" (sơ-đồ Bước/Cấp + HÀNH ĐỘNG + 2 lịch sử) ĐÃ GỠ
khỏi trang này — anh chốt "bỏ hết, nó sẽ hiển thị ở Menu duyệt". Nhà của nó nay là
Panel-3 của màn danh sách (`KhkkListPage.tsx:196` — cùng `KhkkWorkflowPanel`), nên
gỡ ở đây là bỏ BẢN SAO THỨ HAI, không phải bỏ tính năng.
🔴 Hệ quả CÒN LẠI phải biết: `<lg` không có 3 panel nên `selectPlan` điều hướng vào
đúng trang này (`KhkkListPage.tsx:107`) ⇒ trên mobile hiện KHÔNG còn đường Gửi
duyệt / Xóa phiếu. Đã khai với anh; mở lại thì bọc `<div className="lg:hidden">`. */}
<KhkkDetailContent plan={k} readOnly={readOnly} onChanged={invalidate} />
{bridgeOpen && <CreateContractFromKhkkDialog plan={k} onClose={() => setBridgeOpen(false)} />}

View File

@ -147,7 +147,6 @@ export function KhkkListPage() {
phase={phase}
onPhaseChange={(v) => { setChipPhase(v); setPage(1) }}
group={group}
onGroupChange={(v) => { setGroupOverride(v); setPage(1) }}
deletedView={deletedView}
page={page}
onPageChange={setPage}

View File

@ -15,6 +15,7 @@ import { toast } from 'sonner'
import { ClipboardCheck } from 'lucide-react'
import { EmptyState } from '@/components/EmptyState'
import { PeDetailTabs } from '@/components/pe/PeDetailTabs'
import { PeKhkkSidePanel } from '@/components/pe/PeKhkkSidePanel'
import { PeListPanel } from '@/components/pe/PeListPanel'
import { PeWorkspaceCreateView } from '@/components/pe/PeWorkspaceCreateView'
import { api } from '@/lib/api'
@ -77,7 +78,10 @@ export function PurchaseEvaluationWorkspacePage() {
</div>
</header>
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[260px_1fr] xl:grid-cols-[320px_1fr]">
{/* [S174 owner] Cột 3 (`PeKhkkSidePanel`, 2 tầng 50/50) CHỈ mọc ở `xl` — thêm nó vào
`lg` sẽ bóp panel giữa (nơi nhập liệu thật) xuống dưới ngưỡng dùng được. Ở `lg`
bố cục giữ ĐÚNG 2 panel như trước, không đổi một pixel nào. */}
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[260px_1fr] xl:grid-cols-[320px_1fr_320px]">
{/* Panel 1: List pure picker + sticky create + pencil edit hover.
Workspace chỉ list phiếu Bản nháp (DangSoanThao) — đã gửi duyệt rồi
không hiện ở đây (vào Danh sách / Duyệt). User 2026-05-07. */}
@ -130,6 +134,10 @@ export function PurchaseEvaluationWorkspacePage() {
/>
)}
</main>
{/* Panel 3: 1 cột 2 tầng — trên = PE đã duyệt CHƯA lập KHKK (gom theo dự án),
dưới = KHKK đã tạo. Tự gate `KeHoachKyKet.Read` bên trong (gotcha #85). */}
<PeKhkkSidePanel />
</div>
{/* Mobile fallback: nếu không lg, redirect về detail page */}