[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
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:
@ -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ó 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 ký
|
||||
</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 ký</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 có ý kiến duyệt.</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -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 }}
|
||||
/>
|
||||
|
||||
252
fe-admin/src/components/pe/PeKhkkSidePanel.tsx
Normal file
252
fe-admin/src/components/pe/PeKhkkSidePanel.tsx
Normal 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 có quyền xem Kế hoạch ký kết HĐ 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 đã có kế hoạch ký 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 có kế hoạch ký 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ý kết HĐ.
|
||||
</p>
|
||||
)}
|
||||
</Tier>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user