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

View File

@ -31,11 +31,9 @@ import { usePipelineStages } from '@/hooks/usePipelineStages'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
import { import {
KHKK_APPROVAL_GROUPS,
KHKK_PHASE_BADGE, KHKK_PHASE_BADGE,
KHKK_PHASE_LABELS, KHKK_PHASE_LABELS,
KhkkPhase, KhkkPhase,
khkkGroupMenuLabel,
type KhkkListItemDto, type KhkkListItemDto,
type KhkkPhaseValue, type KhkkPhaseValue,
type PagedResult, type PagedResult,
@ -172,7 +170,6 @@ export function KhkkListPanel({
phase, phase,
onPhaseChange, onPhaseChange,
group, group,
onGroupChange,
deletedView, deletedView,
page, page,
onPageChange, onPageChange,
@ -184,8 +181,10 @@ export function KhkkListPanel({
onSearchChange: (v: string) => void onSearchChange: (v: string) => void
phase: KhkkPhaseValue | null phase: KhkkPhaseValue | null
onPhaseChange: (v: KhkkPhaseValue | null) => void 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 group: number | null
onGroupChange: (v: number | null) => void
deletedView: boolean deletedView: boolean
page: number page: number
onPageChange: (p: number) => void onPageChange: (p: number) => void
@ -265,28 +264,16 @@ export function KhkkListPanel({
className="pl-8" className="pl-8"
/> />
</div> </div>
<div className="grid grid-cols-2 gap-2"> <Select
<Select value={phase ?? ''}
value={phase ?? ''} aria-label="Lọc trạng thái"
aria-label="Lọc trạng thái" onChange={e => onPhaseChange(e.target.value === '' ? null : (Number(e.target.value) as KhkkPhaseValue))}
onChange={e => onPhaseChange(e.target.value === '' ? null : (Number(e.target.value) as KhkkPhaseValue))} >
> <option value="">Tất cả trạng thái</option>
<option value="">Tất cả trạng thái</option> {(Object.keys(KHKK_PHASE_LABELS) as unknown as number[]).map(p => (
{(Object.keys(KHKK_PHASE_LABELS) as unknown as number[]).map(p => ( <option key={p} value={p}>{KHKK_PHASE_LABELS[Number(p) as KhkkPhaseValue]}</option>
<option key={p} value={p}>{KHKK_PHASE_LABELS[Number(p) as KhkkPhaseValue]}</option> ))}
))} </Select>
</Select>
<Select
value={group ?? ''}
aria-label="Lọc nhóm duyệt"
onChange={e => onGroupChange(e.target.value === '' ? null : Number(e.target.value))}
>
<option value="">Tất cả nhóm</option>
{KHKK_APPROVAL_GROUPS.map(n => (
<option key={n} value={n}>{khkkGroupMenuLabel(n)}</option>
))}
</Select>
</div>
{flatMode && !deletedView && canPe && ( {flatMode && !deletedView && canPe && (
<p className="text-[10px] italic text-slate-400"> <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). Đ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 <PipelineTreePanel
currentStage={2} currentStage={2}
className="border-0 shadow-none" className="border-0 shadow-none"
hideHeader
bodyMaxHClass="max-h-[28rem] lg:max-h-none" bodyMaxHClass="max-h-[28rem] lg:max-h-none"
stage2Options={{ stage2Content, stage2Count, onOpenKhkk: onSelect }} stage2Options={{ stage2Content, stage2Count, onOpenKhkk: onSelect }}
/> />

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, className,
bodyMaxHClass, bodyMaxHClass,
stage2Options, stage2Options,
hideHeader,
}: { }: {
currentStage: PipelineStageNo currentStage: PipelineStageNo
className?: string 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). // 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. // Mặc định giữ nguyên — 3 host giấy-cuộn cũ không đổi hành vi.
bodyMaxHClass?: string 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 { canPe, buildStages } = usePipelineStages()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
@ -189,29 +196,32 @@ export function PipelineTreePanel({
style={{ ['--accent' as string]: 'var(--color-brand-500)' }} style={{ ['--accent' as string]: 'var(--color-brand-500)' }}
aria-label="Cây toàn trình theo gói thầu" 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"> {!hideHeader && (
<span <div className="flex items-center gap-2 border-b border-slate-200 px-3 py-2.5">
className="icon-chip h-7! w-7!" <span
style={{ ['--chip-bg' as string]: 'var(--color-brand-50)', ['--chip-fg' as string]: 'var(--color-brand-600)' }} className="icon-chip h-7! w-7!"
aria-hidden 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> <ListTree className="h-3.5 w-3.5" />
<span className="label-eyebrow min-w-0 flex-1 truncate">Cây toàn trình</span> </span>
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600"> <span className="label-eyebrow min-w-0 flex-1 truncate">Cây toàn trình</span>
{packageCount} gói <span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">
</span> {packageCount} gói
<button </span>
type="button" <button
onClick={() => setMobileOpen(o => !o)} type="button"
aria-expanded={mobileOpen} onClick={() => setMobileOpen(o => !o)}
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" 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> {mobileOpen ? 'Ẩn cây' : 'Hiện cây'}
</div> </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"> <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" /> <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 <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. // [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 // File MIRROR SHA256 identical với fe-user counterpart.
// .card-accent + .icon-chip header). File MIRROR SHA256 identical với fe-admin.
// //
// 3 section: (1) chọn phiếu PE nguồn — phiếu DaDuyet có NCC trúng thầu (BE lọc, FE // 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 // 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 // đã 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. // (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ị // [S174 owner] ĐỔI KHUÔN: bỏ `PageHeader` + 3 khối `.card-accent` rời, chuyển sang ĐÚNG
// 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à // hình "Tạo phiếu Duyệt NCC mới" (`components/pe/PeWorkspaceCreateView.tsx:152-352`) —
// "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ụ // anh chốt "Tạo Header → tương tự NCC → sau khi Header tạo xong thì cho sang chi tiết":
// "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 // · MỘT card `rounded-lg border bg-white shadow-sm`, các section ngăn bằng `divide-y`;
// không phân biệt được "PE không đủ điều kiện" với "PE đã dùng hết nhóm". // · thanh đầu = tiêu-đề + 1 dòng kể LUỒNG (nhập gì → bấm gì → cái gì mở khoá) + "← Hủy";
import { useState, type FormEvent } from 'react' // · 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 { useMutation, useQuery } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom' 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 { toast } from 'sonner'
import { PageHeader } from '@/components/ui/PageHeader'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Label } from '@/components/ui/Label' import { Label } from '@/components/ui/Label'
import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea' import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError' import { getErrorMessage } from '@/lib/apiError'
@ -92,8 +99,7 @@ export function KhkkCreatePage() {
}) })
// Endpoint trả AwAdminOverviewDto { types: [{ applicableType, history: [...] }] } — // Endpoint trả AwAdminOverviewDto { types: [{ applicableType, history: [...] }] } —
// KHÔNG phải flat array (khuôn ProposalCreatePage:59-69). Lọc isUserSelectable = // KHÔNG phải flat array. Lọc isUserSelectable = quy trình admin đã ghim cho user pick.
// quy trình admin đã ghim cho user pick.
const workflows = useQuery({ const workflows = useQuery({
queryKey: ['approval-workflows-v2', { applicableType: APPLICABLE_TYPE_KHKK, isUserSelectable: true }], queryKey: ['approval-workflows-v2', { applicableType: APPLICABLE_TYPE_KHKK, isUserSelectable: true }],
queryFn: async () => { queryFn: async () => {
@ -121,6 +127,7 @@ export function KhkkCreatePage() {
const res = await api.post<{ id: string; maKeHoach: string | null }>('/contract-signing-plans', body) const res = await api.post<{ id: string; maKeHoach: string | null }>('/contract-signing-plans', body)
return res.data return res.data
}, },
// 🔴 Vế "Header tạo xong thì sang chi tiết" nằm ĐÚNG ở đây — giữ nguyên từ S161.
onSuccess: (data) => { onSuccess: (data) => {
toast.success(data.maKeHoach ? `Đã lập kế hoạch ${data.maKeHoach}` : 'Đã lập kế hoạch ký kết') toast.success(data.maKeHoach ? `Đã lập kế hoạch ${data.maKeHoach}` : 'Đã lập kế hoạch ký kết')
navigate(`/khkk/${data.id}`) navigate(`/khkk/${data.id}`)
@ -142,6 +149,15 @@ export function KhkkCreatePage() {
const takenGroups = selectedPe?.existingGroups ?? [] const takenGroups = selectedPe?.existingGroups ?? []
const groupTaken = takenGroups.includes(approvalGroup) 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ấ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ò. // 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) => { const selectPe = (pe: KhkkPeCandidateDto) => {
@ -154,52 +170,37 @@ export function KhkkCreatePage() {
} }
return ( return (
<div className="space-y-5"> <form onSubmit={onSubmit} className="rounded-lg border border-slate-200 bg-white shadow-sm">
<PageHeader {/* Thanh đầu — mirror `PeWorkspaceCreateView.tsx:155-165` */}
eyebrow="Giai đoạn 2" <div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-5 py-3">
title="Lập kế hoạch ký kết HĐ" <div>
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" <h2 className="text-base font-semibold text-slate-900">Lập kế hoạch kết mới</h2>
icon={<FileCheck className="h-5 w-5" />} <p className="mt-0.5 text-[12px] text-slate-500">
accent="brand" 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.
actions={ </p>
<Button variant="outline" onClick={() => navigate('/khkk/list')}> </div>
<X className="mr-2 h-4 w-4" /> <Button type="button" variant="ghost" onClick={() => navigate('/khkk/list')} className="text-xs">
Huỷ Hủy
</Button> </Button>
} </div>
/>
<form onSubmit={onSubmit} className="space-y-5"> <div className="divide-y divide-slate-200">
{/* Section 1: Phiếu Duyệt NCC nguồn */} <Section title="1. Phiếu Duyệt NCC nguồn *">
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}> {candidates.isLoading && <div className="text-sm text-muted-foreground">Đang tải danh sách phiếu...</div>}
<header className="flex items-center gap-2 border-b border-slate-100 px-5 py-3"> {!candidates.isLoading && list.length === 0 && (
<span <div className="rounded-lg border border-dashed border-slate-300 p-6 text-center text-sm text-muted-foreground">
className="icon-chip" Không phiếu Duyệt NCC nào đã duyệt NCC trúng thầu.
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: 'var(--color-brand-50)', ['--chip-fg' as string]: 'var(--color-brand-600)' }} </div>
aria-hidden )}
> <div className="max-h-96 space-y-2 overflow-y-auto">
<FileText className="h-4 w-4" /> {list.map((pe) => {
</span> const selected = pe.id === purchaseEvaluationId
<h3 className="text-sm font-semibold tracking-tight text-brand-800"> // [K2 S164 — OG-7] `?? []` = phòng payload cũ trong cache (trước K2 DTO
Phiếu Duyệt NCC nguồn <span className="text-red-500">*</span> // không có field này) — thiếu guard thì `.length` nổ trắng trang.
</h3> const taken = pe.existingGroups ?? []
</header> const free = firstFreeGroup(taken)
<div className="space-y-2 p-5"> return (
{candidates.isLoading && <div className="text-sm text-muted-foreground">Đang tải danh sách phiếu...</div>} <div key={pe.id} className="space-y-1">
{!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 <button
type="button" type="button"
onClick={() => selectPe(pe)} onClick={() => selectPe(pe)}
@ -264,39 +265,23 @@ export function KhkkCreatePage() {
)} )}
</div> </div>
)} )}
</div> </div>
) )
})} })}
</div>
</div> </div>
</section> </Section>
{/* Section 2: Quy trình duyệt */} <Section title="2. Nhóm duyệt & quy trình">
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-violet-500)' }}> <div className="grid gap-3 md:grid-cols-2">
<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">
{/* [K2 S164 — vá-4] Nhóm duyệt N1..N8. Mặc định nhóm 1 (UI-hướng-1: ca HẸP, {/* [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 — 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. */} rào cứng 409 vẫn ở BE, ô này chỉ để không mời bấm nhầm. */}
<div className="space-y-2"> <div>
<Label htmlFor="approvalGroup" className="flex items-center gap-1.5"> <Label htmlFor="approvalGroup" className="text-[11px]">Nhóm duyệt (N1N8) *</Label>
<Layers className="h-3.5 w-3.5 text-violet-600" /> <Select
Nhóm duyệt (N1N8) <span className="text-red-500">*</span>
</Label>
<select
id="approvalGroup" id="approvalGroup"
value={approvalGroup} value={approvalGroup}
onChange={(e) => setApprovalGroup(Number(e.target.value))} 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) => { {KHKK_APPROVAL_GROUPS.map((g) => {
const used = takenGroups.includes(g) const used = takenGroups.includes(g)
@ -307,83 +292,92 @@ export function KhkkCreatePage() {
</option> </option>
) )
})} })}
</select> </Select>
{groupTaken ? ( {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. 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 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>
<div className="space-y-2"> <div>
<Label htmlFor="workflow"> <Label htmlFor="workflow" className="text-[11px]">Quy trình duyệt *</Label>
Quy trình duyệt <span className="text-red-500">*</span> <Select
</Label>
<select
id="workflow" id="workflow"
value={approvalWorkflowId} value={approvalWorkflowId}
onChange={(e) => setApprovalWorkflowId(e.target.value)} 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) => ( {workflows.data?.map((w) => (
<option key={w.id} value={w.id}> <option key={w.id} value={w.id}>
{w.code} - {w.name} {w.code} - {w.name}
</option> </option>
))} ))}
</select> </Select>
<div className="text-xs text-muted-foreground mt-1"> {workflows.data && workflows.data.length === 0 ? (
Chỉ hiển thị quy trình loại "Kế hoạch ký kết HĐ" admin đã ghim cho user chọn <p className="mt-1 text-[11px] text-amber-700">
</div> 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>
</div> </div>
</section> </Section>
{/* Section 3: Ghi chú */} <Section title="3. Ghi chú">
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-teal-500)' }}> <Label htmlFor="ghiChu" className="text-[11px]">Ghi chú kế hoạch</Label>
<header className="flex items-center gap-2 border-b border-slate-100 px-5 py-3"> <Textarea
<span id="ghiChu"
className="icon-chip" value={ghiChu}
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: 'var(--color-teal-50)', ['--chip-fg' as string]: 'var(--color-teal-700)' }} onChange={(e) => setGhiChu(e.target.value)}
aria-hidden maxLength={2000}
> rows={4}
<FileText className="h-4 w-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> </span>
<h3 className="text-sm font-semibold tracking-tight text-teal-700">Ghi chú</h3> ) : (
</header> <span className="font-medium text-emerald-600"> Đ thông tin bắt buộc thể tạo kế hoạch</span>
<div className="space-y-2 p-5"> )}
<Label htmlFor="ghiChu">Ghi chú kế hoạch</Label> </div>
<Textarea <div className="flex shrink-0 items-center gap-2">
id="ghiChu" <Button type="button" variant="ghost" onClick={() => navigate('/khkk/list')} className="text-xs">
value={ghiChu} Hủy
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ỷ
</Button> </Button>
{/* [K2 S164] `groupTaken` chặn ngay ở FE lượt bấm chắc chắn ăn 409 của BE <Button type="submit" disabled={!canSubmit}>
(`Features.cs:395-402`) — vẫn để BE là rào thật, đây chỉ là đỡ 1 vòng mạng. */} {create.isPending ? 'Đang tạo…' : 'Tạo kế hoạch'}
<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> </Button>
</div> </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 { Label } from '@/components/ui/Label'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent' import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
import { KhkkWorkflowPanel } from '@/components/khkk/KhkkWorkflowPanel'
import { useAuth } from '@/contexts/AuthContext' import { useAuth } from '@/contexts/AuthContext'
import { usePermission } from '@/hooks/usePermission' import { usePermission } from '@/hooks/usePermission'
import { api } from '@/lib/api' 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ứ {/* [S174 owner] Khối "Quy trình duyệt" (sơ-đồ Bước/Cấp + HÀNH ĐỘNG + 2 lịch sử) ĐÃ GỠ
tự của màn Duyệt NCC). Nút Xóa phiếu nay nằm trong panel này. */} 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à
<KhkkWorkflowPanel plan={k} onChanged={invalidate} onDeleted={() => navigate(backToList)} /> 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} /> <KhkkDetailContent plan={k} readOnly={readOnly} onChanged={invalidate} />
{bridgeOpen && <CreateContractFromKhkkDialog plan={k} onClose={() => setBridgeOpen(false)} />} {bridgeOpen && <CreateContractFromKhkkDialog plan={k} onClose={() => setBridgeOpen(false)} />}

View File

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

View File

@ -15,6 +15,7 @@ import { toast } from 'sonner'
import { ClipboardCheck } from 'lucide-react' import { ClipboardCheck } from 'lucide-react'
import { EmptyState } from '@/components/EmptyState' import { EmptyState } from '@/components/EmptyState'
import { PeDetailTabs } from '@/components/pe/PeDetailTabs' import { PeDetailTabs } from '@/components/pe/PeDetailTabs'
import { PeKhkkSidePanel } from '@/components/pe/PeKhkkSidePanel'
import { PeListPanel } from '@/components/pe/PeListPanel' import { PeListPanel } from '@/components/pe/PeListPanel'
import { PeWorkspaceCreateView } from '@/components/pe/PeWorkspaceCreateView' import { PeWorkspaceCreateView } from '@/components/pe/PeWorkspaceCreateView'
import { api } from '@/lib/api' import { api } from '@/lib/api'
@ -77,7 +78,10 @@ export function PurchaseEvaluationWorkspacePage() {
</div> </div>
</header> </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. {/* 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 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. */} không hiện ở đây (vào Danh sách / Duyệt). User 2026-05-07. */}
@ -130,6 +134,10 @@ export function PurchaseEvaluationWorkspacePage() {
/> />
)} )}
</main> </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> </div>
{/* Mobile fallback: nếu không lg, redirect về detail page */} {/* Mobile fallback: nếu không lg, redirect về detail page */}

View File

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

View File

@ -31,11 +31,9 @@ import { usePipelineStages } from '@/hooks/usePipelineStages'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
import { import {
KHKK_APPROVAL_GROUPS,
KHKK_PHASE_BADGE, KHKK_PHASE_BADGE,
KHKK_PHASE_LABELS, KHKK_PHASE_LABELS,
KhkkPhase, KhkkPhase,
khkkGroupMenuLabel,
type KhkkListItemDto, type KhkkListItemDto,
type KhkkPhaseValue, type KhkkPhaseValue,
type PagedResult, type PagedResult,
@ -172,7 +170,6 @@ export function KhkkListPanel({
phase, phase,
onPhaseChange, onPhaseChange,
group, group,
onGroupChange,
deletedView, deletedView,
page, page,
onPageChange, onPageChange,
@ -184,8 +181,10 @@ export function KhkkListPanel({
onSearchChange: (v: string) => void onSearchChange: (v: string) => void
phase: KhkkPhaseValue | null phase: KhkkPhaseValue | null
onPhaseChange: (v: KhkkPhaseValue | null) => void 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 group: number | null
onGroupChange: (v: number | null) => void
deletedView: boolean deletedView: boolean
page: number page: number
onPageChange: (p: number) => void onPageChange: (p: number) => void
@ -265,28 +264,16 @@ export function KhkkListPanel({
className="pl-8" className="pl-8"
/> />
</div> </div>
<div className="grid grid-cols-2 gap-2"> <Select
<Select value={phase ?? ''}
value={phase ?? ''} aria-label="Lọc trạng thái"
aria-label="Lọc trạng thái" onChange={e => onPhaseChange(e.target.value === '' ? null : (Number(e.target.value) as KhkkPhaseValue))}
onChange={e => onPhaseChange(e.target.value === '' ? null : (Number(e.target.value) as KhkkPhaseValue))} >
> <option value="">Tất cả trạng thái</option>
<option value="">Tất cả trạng thái</option> {(Object.keys(KHKK_PHASE_LABELS) as unknown as number[]).map(p => (
{(Object.keys(KHKK_PHASE_LABELS) as unknown as number[]).map(p => ( <option key={p} value={p}>{KHKK_PHASE_LABELS[Number(p) as KhkkPhaseValue]}</option>
<option key={p} value={p}>{KHKK_PHASE_LABELS[Number(p) as KhkkPhaseValue]}</option> ))}
))} </Select>
</Select>
<Select
value={group ?? ''}
aria-label="Lọc nhóm duyệt"
onChange={e => onGroupChange(e.target.value === '' ? null : Number(e.target.value))}
>
<option value="">Tất cả nhóm</option>
{KHKK_APPROVAL_GROUPS.map(n => (
<option key={n} value={n}>{khkkGroupMenuLabel(n)}</option>
))}
</Select>
</div>
{flatMode && !deletedView && canPe && ( {flatMode && !deletedView && canPe && (
<p className="text-[10px] italic text-slate-400"> <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). Đ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 <PipelineTreePanel
currentStage={2} currentStage={2}
className="border-0 shadow-none" className="border-0 shadow-none"
hideHeader
bodyMaxHClass="max-h-[28rem] lg:max-h-none" bodyMaxHClass="max-h-[28rem] lg:max-h-none"
stage2Options={{ stage2Content, stage2Count, onOpenKhkk: onSelect }} stage2Options={{ stage2Content, stage2Count, onOpenKhkk: onSelect }}
/> />

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, className,
bodyMaxHClass, bodyMaxHClass,
stage2Options, stage2Options,
hideHeader,
}: { }: {
currentStage: PipelineStageNo currentStage: PipelineStageNo
className?: string 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). // 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. // Mặc định giữ nguyên — 3 host giấy-cuộn cũ không đổi hành vi.
bodyMaxHClass?: string 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 { canPe, buildStages } = usePipelineStages()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
@ -189,29 +196,32 @@ export function PipelineTreePanel({
style={{ ['--accent' as string]: 'var(--color-brand-500)' }} style={{ ['--accent' as string]: 'var(--color-brand-500)' }}
aria-label="Cây toàn trình theo gói thầu" 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"> {!hideHeader && (
<span <div className="flex items-center gap-2 border-b border-slate-200 px-3 py-2.5">
className="icon-chip h-7! w-7!" <span
style={{ ['--chip-bg' as string]: 'var(--color-brand-50)', ['--chip-fg' as string]: 'var(--color-brand-600)' }} className="icon-chip h-7! w-7!"
aria-hidden 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> <ListTree className="h-3.5 w-3.5" />
<span className="label-eyebrow min-w-0 flex-1 truncate">Cây toàn trình</span> </span>
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600"> <span className="label-eyebrow min-w-0 flex-1 truncate">Cây toàn trình</span>
{packageCount} gói <span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">
</span> {packageCount} gói
<button </span>
type="button" <button
onClick={() => setMobileOpen(o => !o)} type="button"
aria-expanded={mobileOpen} onClick={() => setMobileOpen(o => !o)}
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" 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> {mobileOpen ? 'Ẩn cây' : 'Hiện cây'}
</div> </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"> <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" /> <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 <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. // [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 // File MIRROR SHA256 identical với fe-user counterpart.
// .card-accent + .icon-chip header). File MIRROR SHA256 identical với fe-admin.
// //
// 3 section: (1) chọn phiếu PE nguồn — phiếu DaDuyet có NCC trúng thầu (BE lọc, FE // 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 // 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 // đã 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. // (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ị // [S174 owner] ĐỔI KHUÔN: bỏ `PageHeader` + 3 khối `.card-accent` rời, chuyển sang ĐÚNG
// 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à // hình "Tạo phiếu Duyệt NCC mới" (`components/pe/PeWorkspaceCreateView.tsx:152-352`) —
// "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ụ // anh chốt "Tạo Header → tương tự NCC → sau khi Header tạo xong thì cho sang chi tiết":
// "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 // · MỘT card `rounded-lg border bg-white shadow-sm`, các section ngăn bằng `divide-y`;
// không phân biệt được "PE không đủ điều kiện" với "PE đã dùng hết nhóm". // · thanh đầu = tiêu-đề + 1 dòng kể LUỒNG (nhập gì → bấm gì → cái gì mở khoá) + "← Hủy";
import { useState, type FormEvent } from 'react' // · 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 { useMutation, useQuery } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom' 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 { toast } from 'sonner'
import { PageHeader } from '@/components/ui/PageHeader'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Label } from '@/components/ui/Label' import { Label } from '@/components/ui/Label'
import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea' import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError' import { getErrorMessage } from '@/lib/apiError'
@ -92,8 +99,7 @@ export function KhkkCreatePage() {
}) })
// Endpoint trả AwAdminOverviewDto { types: [{ applicableType, history: [...] }] } — // Endpoint trả AwAdminOverviewDto { types: [{ applicableType, history: [...] }] } —
// KHÔNG phải flat array (khuôn ProposalCreatePage:59-69). Lọc isUserSelectable = // KHÔNG phải flat array. Lọc isUserSelectable = quy trình admin đã ghim cho user pick.
// quy trình admin đã ghim cho user pick.
const workflows = useQuery({ const workflows = useQuery({
queryKey: ['approval-workflows-v2', { applicableType: APPLICABLE_TYPE_KHKK, isUserSelectable: true }], queryKey: ['approval-workflows-v2', { applicableType: APPLICABLE_TYPE_KHKK, isUserSelectable: true }],
queryFn: async () => { queryFn: async () => {
@ -121,6 +127,7 @@ export function KhkkCreatePage() {
const res = await api.post<{ id: string; maKeHoach: string | null }>('/contract-signing-plans', body) const res = await api.post<{ id: string; maKeHoach: string | null }>('/contract-signing-plans', body)
return res.data return res.data
}, },
// 🔴 Vế "Header tạo xong thì sang chi tiết" nằm ĐÚNG ở đây — giữ nguyên từ S161.
onSuccess: (data) => { onSuccess: (data) => {
toast.success(data.maKeHoach ? `Đã lập kế hoạch ${data.maKeHoach}` : 'Đã lập kế hoạch ký kết') toast.success(data.maKeHoach ? `Đã lập kế hoạch ${data.maKeHoach}` : 'Đã lập kế hoạch ký kết')
navigate(`/khkk/${data.id}`) navigate(`/khkk/${data.id}`)
@ -142,6 +149,15 @@ export function KhkkCreatePage() {
const takenGroups = selectedPe?.existingGroups ?? [] const takenGroups = selectedPe?.existingGroups ?? []
const groupTaken = takenGroups.includes(approvalGroup) 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ấ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ò. // 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) => { const selectPe = (pe: KhkkPeCandidateDto) => {
@ -154,52 +170,37 @@ export function KhkkCreatePage() {
} }
return ( return (
<div className="space-y-5"> <form onSubmit={onSubmit} className="rounded-lg border border-slate-200 bg-white shadow-sm">
<PageHeader {/* Thanh đầu — mirror `PeWorkspaceCreateView.tsx:155-165` */}
eyebrow="Giai đoạn 2" <div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-5 py-3">
title="Lập kế hoạch ký kết HĐ" <div>
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" <h2 className="text-base font-semibold text-slate-900">Lập kế hoạch kết mới</h2>
icon={<FileCheck className="h-5 w-5" />} <p className="mt-0.5 text-[12px] text-slate-500">
accent="brand" 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.
actions={ </p>
<Button variant="outline" onClick={() => navigate('/khkk/list')}> </div>
<X className="mr-2 h-4 w-4" /> <Button type="button" variant="ghost" onClick={() => navigate('/khkk/list')} className="text-xs">
Huỷ Hủy
</Button> </Button>
} </div>
/>
<form onSubmit={onSubmit} className="space-y-5"> <div className="divide-y divide-slate-200">
{/* Section 1: Phiếu Duyệt NCC nguồn */} <Section title="1. Phiếu Duyệt NCC nguồn *">
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}> {candidates.isLoading && <div className="text-sm text-muted-foreground">Đang tải danh sách phiếu...</div>}
<header className="flex items-center gap-2 border-b border-slate-100 px-5 py-3"> {!candidates.isLoading && list.length === 0 && (
<span <div className="rounded-lg border border-dashed border-slate-300 p-6 text-center text-sm text-muted-foreground">
className="icon-chip" Không phiếu Duyệt NCC nào đã duyệt NCC trúng thầu.
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: 'var(--color-brand-50)', ['--chip-fg' as string]: 'var(--color-brand-600)' }} </div>
aria-hidden )}
> <div className="max-h-96 space-y-2 overflow-y-auto">
<FileText className="h-4 w-4" /> {list.map((pe) => {
</span> const selected = pe.id === purchaseEvaluationId
<h3 className="text-sm font-semibold tracking-tight text-brand-800"> // [K2 S164 — OG-7] `?? []` = phòng payload cũ trong cache (trước K2 DTO
Phiếu Duyệt NCC nguồn <span className="text-red-500">*</span> // không có field này) — thiếu guard thì `.length` nổ trắng trang.
</h3> const taken = pe.existingGroups ?? []
</header> const free = firstFreeGroup(taken)
<div className="space-y-2 p-5"> return (
{candidates.isLoading && <div className="text-sm text-muted-foreground">Đang tải danh sách phiếu...</div>} <div key={pe.id} className="space-y-1">
{!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 <button
type="button" type="button"
onClick={() => selectPe(pe)} onClick={() => selectPe(pe)}
@ -264,39 +265,23 @@ export function KhkkCreatePage() {
)} )}
</div> </div>
)} )}
</div> </div>
) )
})} })}
</div>
</div> </div>
</section> </Section>
{/* Section 2: Quy trình duyệt */} <Section title="2. Nhóm duyệt & quy trình">
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-violet-500)' }}> <div className="grid gap-3 md:grid-cols-2">
<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">
{/* [K2 S164 — vá-4] Nhóm duyệt N1..N8. Mặc định nhóm 1 (UI-hướng-1: ca HẸP, {/* [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 — 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. */} rào cứng 409 vẫn ở BE, ô này chỉ để không mời bấm nhầm. */}
<div className="space-y-2"> <div>
<Label htmlFor="approvalGroup" className="flex items-center gap-1.5"> <Label htmlFor="approvalGroup" className="text-[11px]">Nhóm duyệt (N1N8) *</Label>
<Layers className="h-3.5 w-3.5 text-violet-600" /> <Select
Nhóm duyệt (N1N8) <span className="text-red-500">*</span>
</Label>
<select
id="approvalGroup" id="approvalGroup"
value={approvalGroup} value={approvalGroup}
onChange={(e) => setApprovalGroup(Number(e.target.value))} 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) => { {KHKK_APPROVAL_GROUPS.map((g) => {
const used = takenGroups.includes(g) const used = takenGroups.includes(g)
@ -307,83 +292,92 @@ export function KhkkCreatePage() {
</option> </option>
) )
})} })}
</select> </Select>
{groupTaken ? ( {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. 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 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>
<div className="space-y-2"> <div>
<Label htmlFor="workflow"> <Label htmlFor="workflow" className="text-[11px]">Quy trình duyệt *</Label>
Quy trình duyệt <span className="text-red-500">*</span> <Select
</Label>
<select
id="workflow" id="workflow"
value={approvalWorkflowId} value={approvalWorkflowId}
onChange={(e) => setApprovalWorkflowId(e.target.value)} 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) => ( {workflows.data?.map((w) => (
<option key={w.id} value={w.id}> <option key={w.id} value={w.id}>
{w.code} - {w.name} {w.code} - {w.name}
</option> </option>
))} ))}
</select> </Select>
<div className="text-xs text-muted-foreground mt-1"> {workflows.data && workflows.data.length === 0 ? (
Chỉ hiển thị quy trình loại "Kế hoạch ký kết HĐ" admin đã ghim cho user chọn <p className="mt-1 text-[11px] text-amber-700">
</div> 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>
</div> </div>
</section> </Section>
{/* Section 3: Ghi chú */} <Section title="3. Ghi chú">
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-teal-500)' }}> <Label htmlFor="ghiChu" className="text-[11px]">Ghi chú kế hoạch</Label>
<header className="flex items-center gap-2 border-b border-slate-100 px-5 py-3"> <Textarea
<span id="ghiChu"
className="icon-chip" value={ghiChu}
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: 'var(--color-teal-50)', ['--chip-fg' as string]: 'var(--color-teal-700)' }} onChange={(e) => setGhiChu(e.target.value)}
aria-hidden maxLength={2000}
> rows={4}
<FileText className="h-4 w-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> </span>
<h3 className="text-sm font-semibold tracking-tight text-teal-700">Ghi chú</h3> ) : (
</header> <span className="font-medium text-emerald-600"> Đ thông tin bắt buộc thể tạo kế hoạch</span>
<div className="space-y-2 p-5"> )}
<Label htmlFor="ghiChu">Ghi chú kế hoạch</Label> </div>
<Textarea <div className="flex shrink-0 items-center gap-2">
id="ghiChu" <Button type="button" variant="ghost" onClick={() => navigate('/khkk/list')} className="text-xs">
value={ghiChu} Hủy
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ỷ
</Button> </Button>
{/* [K2 S164] `groupTaken` chặn ngay ở FE lượt bấm chắc chắn ăn 409 của BE <Button type="submit" disabled={!canSubmit}>
(`Features.cs:395-402`) — vẫn để BE là rào thật, đây chỉ là đỡ 1 vòng mạng. */} {create.isPending ? 'Đang tạo…' : 'Tạo kế hoạch'}
<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> </Button>
</div> </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 { Label } from '@/components/ui/Label'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent' import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
import { KhkkWorkflowPanel } from '@/components/khkk/KhkkWorkflowPanel'
import { useAuth } from '@/contexts/AuthContext' import { useAuth } from '@/contexts/AuthContext'
import { usePermission } from '@/hooks/usePermission' import { usePermission } from '@/hooks/usePermission'
import { api } from '@/lib/api' 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ứ {/* [S174 owner] Khối "Quy trình duyệt" (sơ-đồ Bước/Cấp + HÀNH ĐỘNG + 2 lịch sử) ĐÃ GỠ
tự của màn Duyệt NCC). Nút Xóa phiếu nay nằm trong panel này. */} 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à
<KhkkWorkflowPanel plan={k} onChanged={invalidate} onDeleted={() => navigate(backToList)} /> 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} /> <KhkkDetailContent plan={k} readOnly={readOnly} onChanged={invalidate} />
{bridgeOpen && <CreateContractFromKhkkDialog plan={k} onClose={() => setBridgeOpen(false)} />} {bridgeOpen && <CreateContractFromKhkkDialog plan={k} onClose={() => setBridgeOpen(false)} />}

View File

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

View File

@ -15,6 +15,7 @@ import { toast } from 'sonner'
import { ClipboardCheck } from 'lucide-react' import { ClipboardCheck } from 'lucide-react'
import { EmptyState } from '@/components/EmptyState' import { EmptyState } from '@/components/EmptyState'
import { PeDetailTabs } from '@/components/pe/PeDetailTabs' import { PeDetailTabs } from '@/components/pe/PeDetailTabs'
import { PeKhkkSidePanel } from '@/components/pe/PeKhkkSidePanel'
import { PeListPanel } from '@/components/pe/PeListPanel' import { PeListPanel } from '@/components/pe/PeListPanel'
import { PeWorkspaceCreateView } from '@/components/pe/PeWorkspaceCreateView' import { PeWorkspaceCreateView } from '@/components/pe/PeWorkspaceCreateView'
import { api } from '@/lib/api' import { api } from '@/lib/api'
@ -77,7 +78,10 @@ export function PurchaseEvaluationWorkspacePage() {
</div> </div>
</header> </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. {/* 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 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. */} không hiện ở đây (vào Danh sách / Duyệt). User 2026-05-07. */}
@ -130,6 +134,10 @@ export function PurchaseEvaluationWorkspacePage() {
/> />
)} )}
</main> </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> </div>
{/* Mobile fallback: nếu không lg, redirect về detail page */} {/* Mobile fallback: nếu không lg, redirect về detail page */}