Compare commits

...

3 Commits

Author SHA1 Message Date
d0453bad7b [CLAUDE] Contract: UAT S174 vong-4 — Thao tac chi 3 trang-thai mo + go vien card cua cay KHKK
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m49s
1. Tang duoi man Thao tac: DANH-SACH-TRANG 3 trang-thai — anh: "cho thao tac chi hien tra
   lai va nhap, tu choi thoi nhe" => {DangSoanThao, TraLai, TuChoi}. `ChoDuyet` CUNG bi loai
   (khac vong-3 chi bo `DaDuyet`): phieu dang trinh duyet thi nguoi soan khong thao tac gi
   duoc nua.
   🔴 Doi tu LOAI-TRU sang DANH-SACH-TRANG la CO CHU DICH: loai-tru (`!== DaDuyet`) se tu
   dong NHAN moi phase them ve sau; danh-sach-trang thi phase moi phai khai TUONG MINH moi
   hien. O man chi nen chua viec CON SUA DUOC, im lang nhan them la hong => chon ve chat.
   Tieu-de doi thanh "KHKK · nhap / tra lai / tu choi" — noi DUNG tap dang hien, de nguoi
   dung biet ngay vi sao phieu vua gui duyet "bien mat".

2. Cay toan trinh trong man Danh sach KHKK: GO vo card — anh: "style cho no giong nhau dong
   bo nhe, style ben KHKK co cai vien ben ngoai".
   🔴 GOC RE + BAI HOC: truoc do da truyen `className="border-0 shadow-none"` voi Y DINH tat
   vo card, nhung `.card-accent` khai o `index.css:112` NAM NGOAI `@layer` nen THANG utility
   cua Tailwind v4 => lenh tat bi VO HIEU IM LANG. Day dung la gotcha #66 cua chinh repo
   (rule tho ngoai @layer thang utility). Fix = GO CLASS theo dieu kien (`!hideHeader &&
   'card-accent lg:sticky lg:top-4'`), KHONG de bang utility.
   `hideHeader` nay mang dung nghia "dang NHUNG trong panel khac" => bo ca vo card lan sticky
   cho lien mach voi host, y nhu cay ben Duyet NCC. 6 host con lai khong truyen => nguyen ven.

Verify: build 2 app PASS; mirror SHA256 3/3 cap KHOP; grep xac nhan `card-accent` nay nam
sau dieu kien `!hideHeader`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:53:25 +07:00
f86f30d291 [CLAUDE] Contract: UAT S174 vong-3 — 5 chinh KHKK theo anh chot (FE-only, 0-mig)
1. Ho thu: GO `PipelineTreePanel` khoi `/inbox` — anh: "cho hop thu vut panel nay vao lam j,
   tap trung vao KHKK thoi". Ho thu = hang-doi-viec-cua-toi, cay toan trinh = ban-do-toan-he
   => sai the loai. Cho trong nay tra ve dung vai: cho quy trinh duyet cua HD dang chon.
   🔸 HAI trang GD3/GD4 con lai (`MyContractsPage` · `HardCopiesPage`) GIU NGUYEN — anh chi
   dich danh Ho thu, khong noi go het; khong tu suy rong. (Lineage: panel vao day @S168
   GD3-D theo owner slot (63) + "mo rong = ca 3 trang"; nay owner thu lai DUNG trang nay.)

2. Panel KHKK: BO nut "+ Them moi". Man nay KHONG co duong "tao tu so 0" — ke hoach LUON
   sinh tu mot phieu Duyet NCC, ma cac phieu du dieu kien da nam nguyen o tang tren => nut
   chi xoa form chu khong tao them duoc gi. Khac CO CHU DICH voi `PeListPanel`.

3. Tang duoi CHI giu ke hoach DANG XU LY — an phieu `DaDuyet` (anh: "da duyet bo di luon").
   🔴 KHAI GIOI-HAN: loc o FE vi endpoint chi nhan `phase` DON, khong co tham so "loai tru"
   => (a) KHONG dung `total` cua server lam so hien thi (da gom phieu DaDuyet) — dem tu mang
   da loc; (b) cua-so 30 phieu moi nhat co the TOAN DaDuyet => tang duoi rong du con phieu
   nhap cu hon. Chap nhan o UAT; muon chuan thi BE them tham so loai-tru.
   Doi luon dong chu "x/total" -> "Da an ke hoach Da duyet": ti so cu doc thanh "dang cat
   bot" trong khi that ra la "dang loc bo".

4. Nhom duyet + Quy trinh duyet GAN CUNG theo leaf menu — anh: "gan cung luon theo tung nhom
   ko cho User dieu chinh cho nay luon".
   - `approvalGroup` khong con la state, DAN XUAT thang tu `?group=n`. Bo luon khoi dong-bo
     `lastPresetGroup` + ham `firstFreeGroup()` (chung chi ton tai de tu nhay nhom = dung
     hanh-vi vua bi cam).
   - Quy trinh: match theo ma tu NGUON BE, khong suy tu man hinh — `DbInitializer.cs:609`
     `var code = $"KHKK-N{n}"`. Khong khop => CHAN tao + noi ro thieu ma nao, TUYET DOI
     khong tu roi ve quy trinh nhom khac (gan nham = phieu di sai duong duyet).
   - UI: hien gia-tri co dinh, CO Y khong dung `<Select disabled>` (o xam van trong nhu
     "bam duoc nhung dang khoa" va van trong tab-order).

5. Panel giua: GO Section 5 "Y kien cap duyet" (anh: "bo luon cai nay ra luon"). So-do
   Buoc/Cap + chu ky da song o Panel 3 => de ca hai la ke mot chuyen hai lan tren cung man.
   DTO giu nguyen (`levelOpinions`/`workflowSteps` panel 3 van dung) — chi bo cho VE.

Verify: build 2 app PASS; mirror SHA256 3/3 cap KHOP.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:50:17 +07:00
b7c6ee5a66 wal: flush 20260805T1535 2026-08-05 15:35:07 +07:00
12 changed files with 248 additions and 421 deletions

View File

@ -24,7 +24,11 @@ chain:
- [x] 🆕 VIỆC ANH GIAO S174 (ngắt mạch vá-governance): 7 chỉnh UI KHKK/PE qua 6 ảnh khoanh đỏ + 4 câu trả lời — commit `14a917f` 16 file, push `1b3bcaa..14a917f`. Build 2 app PASS · mirror SHA256 8/8 · byte-level bundle local xác nhận. Chi tiết ở body commit. - [x] 🆕 VIỆC ANH GIAO S174 (ngắt mạch vá-governance): 7 chỉnh UI KHKK/PE qua 6 ảnh khoanh đỏ + 4 câu trả lời — commit `14a917f` 16 file, push `1b3bcaa..14a917f`. Build 2 app PASS · mirror SHA256 8/8 · byte-level bundle local xác nhận. Chi tiết ở body commit.
- [x] deploy `14a917f` XANH (~5 phút, poll 15/28). Bundle prod ĐỔI cả 2: admin `Ajv-MaCz``DpZjuFBn` · eoffice `YsXRkBSR``DU_NaPIg`; 5/5 chuỗi mới HIT ở CẢ HAI; `Tất cả nhóm` VẮNG (đúng). Trang chủ http=200 ×2. 🔴 KHÔNG có `GITEA_TOKEN` ⇒ verify bằng grep bundle prod (#77) thay vì Actions API — 2 vế hash-ĐỔI + chuỗi-CÓ mới đủ, mỗi vế lẻ đều nói dối được - [x] deploy `14a917f` XANH (~5 phút, poll 15/28). Bundle prod ĐỔI cả 2: admin `Ajv-MaCz``DpZjuFBn` · eoffice `YsXRkBSR``DU_NaPIg`; 5/5 chuỗi mới HIT ở CẢ HAI; `Tất cả nhóm` VẮNG (đúng). Trang chủ http=200 ×2. 🔴 KHÔNG có `GITEA_TOKEN` ⇒ verify bằng grep bundle prod (#77) thay vì Actions API — 2 vế hash-ĐỔI + chuỗi-CÓ mới đủ, mỗi vế lẻ đều nói dối được
- [ ] 🆕 nợ đã KHAI với anh, chưa làm: (a) bấm PE ở tầng trên panel mới chỉ mở `/khkk/create`, chưa preselect phiếu — cần `?peId=` · (b) trang chi tiết KHKK trên mobile mất nút Gửi duyệt/Xóa phiếu (gỡ cùng khối Quy trình duyệt) — mở lại bằng `lg:hidden` - [x] 🔄 VÒNG 2 S174 — anh BÁC chỗ đặt: *"bỏ phần này đi chỗ duyệt NCC đã golive và đã tốt rồi, ý tao là đang muốn mày làm giống giao diện thao tác của Duyệt NCC"*. Commit `3f339c0` (amend): REVERT `PurchaseEvaluationWorkspacePage` ×2 về ĐÚNG BYTE `1b3bcaa` + `git mv` panel `components/pe/PeKhkkSidePanel``components/khkk/KhkkWorkListPanel` + dựng `/khkk/create` thành HOST 2-PANEL (panel-1 2 tầng callback-driven, panel-2 form|xem). Deploy XANH poll 17: admin `DpZjuFBn``CcypDu1E` · eoffice `DU_NaPIg``6q1cAHcJ`; 5/5 chuỗi mới HIT ×2, control-ÂM `xl:grid-cols-[320px_1fr_320px]` VẮNG ×2 (chứng revert sạch tới prod)
- [!] 🔴 BÀI HỌC vòng này — 2 lỗi CÙNG HỌ "kết-quả trông thành-công nhưng không đo thứ mình tưởng", cần cân nhắc lên gotcha/memory:
(a) **"làm giống màn X" ≠ "sửa màn X"** — anh khoanh đỏ TRÊN màn PE để chỉ NỘI DUNG muốn có, lead đọc thành CHỖ ĐỂ SỬA ⇒ chèn nhầm cột vào màn đã golive. Ảnh sau (anh 7) mới lộ chỗ đặt thật.
(b) **`git add` abort TRỌN lệnh khi có 1 path đã bị `git mv` xoá** ⇒ 6 file sửa nội dung KHÔNG được stage mà `git commit` vẫn báo thành công; tín-hiệu duy nhất = dòng `2 files changed, 0 insertions(+), 0 deletions(-)`. Bắt được nhờ đọc SỐ + verify `git show HEAD:` thay vì tin chữ "thành công". Cùng họ với `539ms`-build và 3 probe hỏng đầu phiên.
- [ ] 🆕 nợ đã KHAI với anh, chưa làm: trang chi tiết KHKK trên mobile mất nút Gửi duyệt/Xóa phiếu (gỡ cùng khối Quy trình duyệt) — mở lại bằng `lg:hidden`. 🧊 Nợ "preselect `?peId=`" ĐÃ TỰ TIÊU: panel nay callback-driven, bấm PE nạp thẳng vào form, không còn điều hướng.
next: chờ poll deploy `b4d42pfzu` → báo anh kết quả → quay lại vá 5 điểm governance treo (STATUS:473 → HANDOFF 5 slot → vrapp → Carry re-stamp → slot 70) next: chờ poll deploy `b4d42pfzu` → báo anh kết quả → quay lại vá 5 điểm governance treo (STATUS:473 → HANDOFF 5 slot → vrapp → Carry re-stamp → slot 70)

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 {
Check, ClipboardList, FileCheck, MessageSquare, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users, ClipboardList, FileCheck, 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'
@ -255,9 +255,10 @@ export function KhkkDetailContent({
// File "đính kèm khi duyệt" thuộc panel 3 (mục 5) — Section 4 chỉ liệt kê hồ-sơ của phiếu. // File "đính kèm khi duyệt" thuộc panel 3 (mục 5) — Section 4 chỉ liệt kê hồ-sơ của phiếu.
const planAttachments = plan.attachments.filter(a => a.purpose !== KhkkAttachmentPurpose.ApprovalAttachment) const planAttachments = plan.attachments.filter(a => a.purpose !== KhkkAttachmentPurpose.ApprovalAttachment)
// ── Section 5: ý-kiến cấp duyệt, gom theo BƯỚC (Phòng) → CẤP → NV ────────────────── // 🧊 [S174 owner] Section 5kiến cấp duyệt" ĐÃ GỠ khỏi panel giữa — anh: "bỏ luôn cái
const opinions = plan.levelOpinions ?? [] // này ra luôn". Sơ-đồ Bước/Cấp + chữ ký đã sống ở PANEL 3 (`KhkkWorkflowPanel`), để cả hai
const steps = plan.workflowSteps ?? [] // là kể một chuyện hai lần trên cùng màn hình. Dữ-liệu `plan.levelOpinions` /
// `plan.workflowSteps` VẪN có trong DTO (panel 3 dùng) — chỉ bỏ chỗ VẼ ở đây.
return ( return (
<div className="card-accent overflow-hidden" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}> <div className="card-accent overflow-hidden" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
@ -569,112 +570,6 @@ export function KhkkDetailContent({
</ul> </ul>
)} )}
</Section> </Section>
{/* ─────────────── 5 — Ảnh 3 khoanh đỏ ───────────────
Gom theo BƯỚC (kèm PHÒNG) → lưới 2 cột các ô ý-kiến của từng CẤP, đúng khuôn
Section "Ý kiến cấp duyệt" V2 của Duyệt NCC (`PeDetailTabs.tsx:705-774`):
forEach Bước → forEach Cấp → 1 ô. Ô chưa ký vẫn HIỆN (chỗ trống có tên người) —
vắng ô là người đọc không biết còn thiếu chữ ký của ai. */}
<Section
title="5. Ý kiến cấp duyệt"
icon={<MessageSquare className="h-4 w-4" />}
accent="var(--color-violet-500)"
head="text-violet-700"
chipBg="var(--color-violet-50)"
chipFg="var(--color-violet-700)"
count={opinions.length}
>
{steps.length === 0 ? (
<div className="text-sm text-muted-foreground">
Phiếu chưa pin quy trình duyệt chưa cấp nào đ ghi ý kiến.
</div>
) : (
<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 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 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"
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="p-3">
{step.levels.length === 0 ? (
<div className="text-[12px] italic text-slate-400"> Bước này chưa cấu hình cấp duyệt.</div>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{step.levels.map((lv) => {
const signed = opinions.find(o => o.approvalWorkflowLevelId === lv.id)
return (
<div
key={lv.id}
className={cn(
'rounded-lg border bg-white p-3',
signed ? 'border-emerald-200' : 'border-dashed border-slate-200',
)}
>
<div className="mb-2 flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<h4 className="text-[13px] font-semibold text-slate-700">
{lv.name || `Cấp ${lv.order}`} {' '}
<span className="text-slate-900">{lv.approverFullName ?? '(chưa cấu hình)'}</span>
</h4>
{/* Không dùng `signed!` — thu hẹp bằng chính `signed &&` để
khỏi mượn null-forgiving trên dữ-liệu từ API (gotcha #80). */}
{signed && signed.approverUserId
&& signed.approverUserId !== signed.signedByUserId && (
<div className="mt-1 inline-flex items-center gap-1 rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700">
Admin <strong>{signed.signedByFullName}</strong> duyệt thay
</div>
)}
</div>
{signed ? (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-medium text-emerald-700">
<Check className="h-3 w-3" /> Đã duyệt
</span>
) : (
<span className="inline-flex shrink-0 items-center rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-500">
Chưa
</span>
)}
</div>
{signed ? (
<>
<div className="whitespace-pre-wrap text-sm text-slate-800">
{signed.comment?.trim() || '(duyệt — không ý kiến)'}
</div>
<div className="mt-2 border-t border-slate-100 pt-1.5 text-[11px] text-slate-500">
{formatDateTime(signed.signedAt)}
</div>
</>
) : (
<div className="text-sm italic text-slate-400">Chưa ý kiến duyệt.</div>
)}
</div>
)
})}
</div>
)}
</div>
</div>
)
})}
</div>
)}
</Section>
</div> </div>
{/* Dialog thêm/sửa căn cứ */} {/* Dialog thêm/sửa căn cứ */}

View File

@ -291,7 +291,6 @@ export function KhkkListPanel({
{!flatMode && ( {!flatMode && (
<PipelineTreePanel <PipelineTreePanel
currentStage={2} currentStage={2}
className="border-0 shadow-none"
hideHeader 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

@ -31,6 +31,7 @@ import { cn } from '@/lib/cn'
import { import {
KHKK_PHASE_BADGE, KHKK_PHASE_BADGE,
KHKK_PHASE_LABELS, KHKK_PHASE_LABELS,
KhkkPhase,
type KhkkListItemDto, type KhkkListItemDto,
type KhkkPeCandidateDto, type KhkkPeCandidateDto,
type PagedResult, type PagedResult,
@ -78,7 +79,6 @@ export function KhkkWorkListPanel({
onPickPlan, onPickPlan,
selectedPeId, selectedPeId,
selectedPlanId, selectedPlanId,
footer,
}: { }: {
/** Bấm PE ở tầng trên → host nạp phiếu đó vào form tạo (Ở LẠI màn, không điều hướng). */ /** Bấm PE ở tầng trên → host nạp phiếu đó vào form tạo (Ở LẠI màn, không điều hướng). */
onPickPe: (peId: string) => void onPickPe: (peId: string) => void
@ -87,8 +87,6 @@ export function KhkkWorkListPanel({
onPickPlan: (planId: string) => void onPickPlan: (planId: string) => void
selectedPeId?: string | null selectedPeId?: string | null
selectedPlanId?: string | null selectedPlanId?: string | null
/** Nút "+ Thêm mới" dính đáy — mirror `PeListPanel` của màn Duyệt NCC — Thao tác. */
footer?: React.ReactNode
}) { }) {
const { can } = usePermission() const { can } = usePermission()
// 🔴 ĐÚNG key policy của endpoint — KHÔNG dùng `Khkk_*` (đó là key MENU, tầng hiển thị). // 🔴 ĐÚNG key policy của endpoint — KHÔNG dùng `Khkk_*` (đó là key MENU, tầng hiển thị).
@ -126,7 +124,19 @@ export function KhkkWorkListPanel({
else byProject.set(key, [pe]) else byProject.set(key, [pe])
} }
const planItems = plans.data?.items ?? [] // [S174 owner] "chỗ thao tác chỉ hiển trả lại và nháp, từ chối thôi nhé" ⇒ DANH-SÁCH-TRẮNG
// 3 trạng thái, KHÔNG phải loại-trừ. 🔴 Đổi từ loại-trừ sang danh-sách-trắng là CÓ CHỦ ĐÍCH:
// loại-trừ (`!== DaDuyet`) sẽ tự động NHẬN mọi phase mới thêm về sau; danh-sách-trắng thì
// phase mới phải được khai TƯỜNG MINH mới hiện. Ở màn Thao tác — nơi chỉ nên có việc CÒN
// SỬA ĐƯỢC — im lặng nhận thêm là hỏng, nên chọn vế chặt.
// 🔸 `ChoDuyet` CŨNG bị loại (khác lượt trước chỉ bỏ `DaDuyet`): phiếu đang trình duyệt thì
// người soạn không thao tác gì được nữa.
// 🔴 KHAI GIỚI-HẠN (lọc ở FE): endpoint chỉ nhận `phase` ĐƠN, không nhận nhiều ⇒ (1) KHÔNG
// dùng `plans.data.total` làm số hiển thị (nó đếm MỌI phase) — đếm từ mảng đã lọc;
// (2) cửa-sổ 30 phiếu mới nhất có thể toàn phiếu đã duyệt ⇒ tầng dưới rỗng dù còn nháp
// cũ hơn. Chấp nhận ở UAT; muốn chuẩn thì BE nhận `phases[]` rồi lọc SERVER-SIDE.
const OPEN_PHASES: number[] = [KhkkPhase.DangSoanThao, KhkkPhase.TraLai, KhkkPhase.TuChoi]
const planItems = (plans.data?.items ?? []).filter((k) => OPEN_PHASES.includes(k.phase))
return ( return (
<aside className="flex min-h-0 flex-col overflow-hidden border-r border-slate-200 bg-white"> <aside className="flex min-h-0 flex-col overflow-hidden border-r border-slate-200 bg-white">
@ -209,8 +219,11 @@ export function KhkkWorkListPanel({
<div className="shrink-0 border-t-2 border-slate-200" /> <div className="shrink-0 border-t-2 border-slate-200" />
<Tier <Tier
title="Kế hoạch ký kết đã tạo" // Tên nói ĐÚNG tập đang hiện, không nói chung chung — người đọc phải biết ngay
count={plans.data?.total ?? null} // vì sao phiếu họ vừa gửi duyệt "biến mất" khỏi đây.
title="KHKK · nháp / trả lại / từ chối"
// Đếm từ mảng ĐÃ LỌC, KHÔNG lấy `total` của server (xem khối lọc phía trên).
count={plans.data ? planItems.length : null}
accent="bg-violet-50 text-violet-600" accent="bg-violet-50 text-violet-600"
icon={<FileSignature className="h-3 w-3" />} icon={<FileSignature className="h-3 w-3" />}
> >
@ -228,7 +241,10 @@ export function KhkkWorkListPanel({
</div> </div>
)} )}
{!plans.isLoading && !plans.isError && planItems.length === 0 && ( {!plans.isLoading && !plans.isError && planItems.length === 0 && (
<p className="p-3 text-[11px] italic text-slate-400">Chưa kế hoạch kết nào.</p> <p className="p-3 text-[11px] italic text-slate-400">
Không còn kế hoạch nào đang xử . Phiếu đã duyệt không hiện đây xem tại
menu Kế hoạch kết .
</p>
)} )}
<ul className="divide-y divide-slate-100"> <ul className="divide-y divide-slate-100">
{planItems.map((k) => ( {planItems.map((k) => (
@ -267,19 +283,23 @@ export function KhkkWorkListPanel({
</li> </li>
))} ))}
</ul> </ul>
{plans.data && plans.data.total > planItems.length && ( {/* 🔴 KHÔNG in "x/total" nữa: `total` gồm cả phiếu đã duyệt vừa bị lọc ⇒ tỉ số đó
sẽ đọc thành "đang cắt bớt" trong khi thật ra là "đang lọc bỏ". Nói đúng việc
đang làm, và chỉ nói khi thật sự có cái bị giấu. */}
{plans.data && plans.data.items.length > planItems.length && (
<p className="border-t border-slate-100 px-3 py-1.5 text-[10px] italic text-slate-400"> <p className="border-t border-slate-100 px-3 py-1.5 text-[10px] italic text-slate-400">
Hiện {planItems.length}/{plans.data.total} kế hoạch mới nhất xem đ menu Kế hoạch kết . Đã n kế hoạch trạng thái Đã duyệt xem đ menu Kế hoạch kết .
</p> </p>
)} )}
</Tier> </Tier>
</> </>
)} )}
{/* Nút hành-động dính ĐÁY, ngoài vùng cuộn của 2 tầng — mirror "+ Thêm mới" của {/* 🧊 [S174] KHÔNG có nút "+ Thêm mới" ở đáy — anh chốt bỏ. Lý do đứng vững: màn này
`PeListPanel` (màn Duyệt NCC — Thao tác). Hiện KỂ CẢ khi thiếu quyền KHKK: cột KHÔNG có đường "tạo từ số 0". Một kế hoạch LUÔN sinh ra từ một phiếu Duyệt NCC, mà
không dựng được thì vẫn phải còn đường bấm, không thì màn thành ngõ cụt. */} các phiếu đủ điều kiện đã nằm nguyên ở tầng trên ⇒ nút chỉ xoá form chứ không tạo
{footer && <div className="shrink-0 border-t border-slate-200 p-2">{footer}</div>} thêm được gì. Đây là chỗ khác CÓ CHỦ ĐÍCH với `PeListPanel` (bên đó tạo phiếu PE
mới là việc có thật). Ai định thêm lại: phải trả lời được "thêm mới CÁI GÌ". */}
</aside> </aside>
) )
} }

View File

@ -192,7 +192,17 @@ export function PipelineTreePanel({
return ( return (
<aside <aside
className={cn('card-accent flex flex-col self-start overflow-hidden lg:sticky lg:top-4', className)} className={cn(
'flex flex-col self-start overflow-hidden',
// [S174 owner] "style cho nó giống nhau đồng bộ nhé, style bên KHKK có cái viền bên
// ngoài" — `hideHeader` = đang NHÚNG trong panel khác ⇒ bỏ vỏ card + sticky để liền
// mạch với host, y như cây bên Duyệt NCC.
// 🔴 KHÔNG vá bằng cách truyền `border-0 shadow-none`: `.card-accent` khai ở
// `index.css:112` NẰM NGOÀI `@layer` nên THẮNG utility của Tailwind v4 (gotcha #66)
// ⇒ cách đó bị vô hiệu IM LẶNG (đúng thứ vừa xảy ra). Phải GỠ CLASS, không đè.
!hideHeader && 'card-accent lg:sticky lg:top-4',
className,
)}
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"
> >

View File

@ -23,19 +23,17 @@
import { useState, type FormEvent, type ReactNode } from 'react' 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 { FileCheck, Plus, X } from 'lucide-react' import { FileCheck, X } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { EmptyState } from '@/components/EmptyState' import { EmptyState } from '@/components/EmptyState'
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 { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent' import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
import { KhkkWorkListPanel } from '@/components/khkk/KhkkWorkListPanel' import { KhkkWorkListPanel } from '@/components/khkk/KhkkWorkListPanel'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError' import { getErrorMessage } from '@/lib/apiError'
import { import {
KHKK_APPROVAL_GROUPS,
KHKK_APPROVAL_GROUP_DEFAULT, KHKK_APPROVAL_GROUP_DEFAULT,
KHKK_APPROVAL_GROUP_LABELS, KHKK_APPROVAL_GROUP_LABELS,
khkkGroupMenuLabel, khkkGroupMenuLabel,
@ -55,11 +53,9 @@ interface WorkflowDto {
isUserSelectable: boolean isUserSelectable: boolean
} }
// Nhóm trống ĐẦU TIÊN của một PE (null = đã dùng hết 8 nhóm)để ô chọn nhóm không đứng // 🧊 [S174] `firstFreeGroup()` ĐÃ GỠ cùng lúc bỏ ô chọn nhóm — nó chỉ tồn tại để tự nhảy
// yên ở một nhóm chắc chắn 409. // sang nhóm trống, đúng hành-vi anh vừa cấm ("gán cứng theo từng nhóm, ko cho User điều
function firstFreeGroup(taken: number[]): number | null { // chỉnh"). Giữ lại thành hàm chết thì lượt sau có người gọi lại.
return KHKK_APPROVAL_GROUPS.find((g) => !taken.includes(g)) ?? null
}
export function KhkkCreatePage() { export function KhkkCreatePage() {
const navigate = useNavigate() const navigate = useNavigate()
@ -69,21 +65,17 @@ export function KhkkCreatePage() {
const presetGroup = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null const presetGroup = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null
const [purchaseEvaluationId, setPurchaseEvaluationId] = useState('') const [purchaseEvaluationId, setPurchaseEvaluationId] = useState('')
const [approvalWorkflowId, setApprovalWorkflowId] = useState('')
const [approvalGroup, setApprovalGroup] = useState<number>(presetGroup ?? KHKK_APPROVAL_GROUP_DEFAULT)
const [ghiChu, setGhiChu] = useState('') const [ghiChu, setGhiChu] = useState('')
// [S174 owner] "gán cứng luôn theo từng nhóm, ko cho User điều chỉnh chỗ này luôn"
// ⇒ nhóm duyệt KHÔNG còn là state: DẪN XUẤT thẳng từ leaf menu (`?group=n`). Không còn
// state thì không thể lệch, nên khối đồng-bộ-preset cũ (`lastPresetGroup`) cũng bỏ luôn —
// đó là thứ chỉ tồn tại để vá đúng cái lệch này.
const approvalGroup = presetGroup ?? KHKK_APPROVAL_GROUP_DEFAULT
// [S174] Phiếu KHKK đang XEM ở panel 2. Có giá trị ⇒ panel 2 hiện phiếu (read-only) thay // [S174] Phiếu KHKK đang XEM ở panel 2. Có giá trị ⇒ panel 2 hiện phiếu (read-only) thay
// cho form tạo. `null` ⇒ về form tạo. Đây là thứ thay cho việc `navigate('/khkk/:id')`. // cho form tạo. `null` ⇒ về form tạo. Đây là thứ thay cho việc `navigate('/khkk/:id')`.
const [viewPlanId, setViewPlanId] = useState<string | null>(null) const [viewPlanId, setViewPlanId] = useState<string | null>(null)
// Đổi leaf nhóm ⇒ đồng bộ ô chọn nhóm. CHỈ đổi khi CHÍNH THAM SỐ URL đổi — so với
// `approvalGroup` thì người dùng tự chọn nhóm khác sẽ bị kéo ngược về nhóm của URL.
const [lastPresetGroup, setLastPresetGroup] = useState(presetGroup)
if (presetGroup !== lastPresetGroup) {
setLastPresetGroup(presetGroup)
if (presetGroup !== null) setApprovalGroup(presetGroup)
}
// Cùng query key với `KhkkWorkListPanel` ⇒ dùng CHUNG cache, không gọi mạng 2 lần. // Cùng query key với `KhkkWorkListPanel` ⇒ dùng CHUNG cache, không gọi mạng 2 lần.
const candidates = useQuery({ const candidates = useQuery({
queryKey: ['khkk-pe-candidates'], queryKey: ['khkk-pe-candidates'],
@ -103,6 +95,16 @@ export function KhkkCreatePage() {
}, },
}) })
// [S174 owner] Quy trình duyệt cũng GÁN CỨNG theo nhóm — mỗi nhóm đúng MỘT quy trình.
// 🔴 Chuỗi mã lấy từ NGUỒN BE, không suy từ màn hình: `DbInitializer.cs:609`
// `var code = $"KHKK-N{n}"` (vòng `for n = 1..8`, seed idempotent per-Code).
// Không khớp ⇒ KHÔNG tự rơi về quy trình khác: chặn tạo + nói rõ thiếu mã nào, vì gán
// nhầm quy trình là phiếu đi sai đường duyệt — hỏng nặng hơn là không tạo được.
const wantWorkflowCode = `KHKK-N${approvalGroup}`
const autoWorkflow = workflows.data?.find(
(w) => (w.code ?? '').trim().toUpperCase() === wantWorkflowCode,
) ?? null
// Phiếu đang XEM — chỉ tải khi thật sự có cái để xem. // Phiếu đang XEM — chỉ tải khi thật sự có cái để xem.
const viewPlan = useQuery({ const viewPlan = useQuery({
queryKey: ['khkk-detail', viewPlanId], queryKey: ['khkk-detail', viewPlanId],
@ -113,10 +115,10 @@ export function KhkkCreatePage() {
const create = useMutation({ const create = useMutation({
mutationFn: async () => { mutationFn: async () => {
if (!purchaseEvaluationId) throw new Error('Vui lòng chọn phiếu Duyệt NCC nguồn') if (!purchaseEvaluationId) throw new Error('Vui lòng chọn phiếu Duyệt NCC nguồn')
if (!approvalWorkflowId) throw new Error('Vui lòng chọn Quy trình duyệt') if (!autoWorkflow) throw new Error(`Chưa có quy trình duyệt ${wantWorkflowCode}`)
const body: CreateKhkkInput = { const body: CreateKhkkInput = {
peId: purchaseEvaluationId, // [F-2] BE Command field = PeId peId: purchaseEvaluationId, // [F-2] BE Command field = PeId
approvalWorkflowId, approvalWorkflowId: autoWorkflow.id,
ghiChu: ghiChu.trim() || null, ghiChu: ghiChu.trim() || null,
approvalGroup, // [K2] gửi TƯỜNG MINH, không dựa default ngầm của BE approvalGroup, // [K2] gửi TƯỜNG MINH, không dựa default ngầm của BE
} }
@ -127,7 +129,6 @@ export function KhkkCreatePage() {
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')
setPurchaseEvaluationId('') setPurchaseEvaluationId('')
setApprovalWorkflowId('')
setGhiChu('') setGhiChu('')
setViewPlanId(data.id) setViewPlanId(data.id)
}, },
@ -141,21 +142,17 @@ export function KhkkCreatePage() {
const missingCreate = [ const missingCreate = [
!purchaseEvaluationId && 'Phiếu Duyệt NCC nguồn', !purchaseEvaluationId && 'Phiếu Duyệt NCC nguồn',
!approvalWorkflowId && 'Quy trình duyệt', !workflows.isLoading && !autoWorkflow && `Quy trình ${wantWorkflowCode} (chưa cấu hình)`,
groupTaken && `Nhóm duyệt còn trống (N${approvalGroup} đã có kế hoạch)`, groupTaken && `Phiếu này đã có kế hoạch nhóm N${approvalGroup}`,
].filter(Boolean) as string[] ].filter(Boolean) as string[]
const canSubmit = missingCreate.length === 0 && !create.isPending const canSubmit = missingCreate.length === 0 && !!autoWorkflow && !create.isPending
// Bấm PE ở panel 1: nạp vào form + rời chế-độ xem. Nhóm đang chọn đã bị PE đó dùng thì // [S174] Bấm PE ở panel 1 = nạp vào form + rời chế-độ xem. 🧊 Khối "nhảy sang nhóm trống
// nhảy sang nhóm trống đầu tiên ⇒ người dùng không phải tự dò. // đầu tiên" ĐÃ BỎ: nhóm nay gán cứng theo leaf menu, tự nhảy nhóm = đúng thứ anh vừa cấm.
// PE đã có kế hoạch ở nhóm này ⇒ `groupTaken` chặn + nói rõ, người dùng tự sang leaf khác.
const pickPe = (peId: string) => { const pickPe = (peId: string) => {
setPurchaseEvaluationId(peId) setPurchaseEvaluationId(peId)
setViewPlanId(null) setViewPlanId(null)
const taken = list.find((p) => p.id === peId)?.existingGroups ?? []
if (taken.includes(approvalGroup)) {
const free = firstFreeGroup(taken)
if (free !== null) setApprovalGroup(free)
}
} }
const onSubmit = (e: FormEvent) => { const onSubmit = (e: FormEvent) => {
@ -185,22 +182,6 @@ export function KhkkCreatePage() {
onPickPlan={setViewPlanId} onPickPlan={setViewPlanId}
selectedPeId={purchaseEvaluationId || null} selectedPeId={purchaseEvaluationId || null}
selectedPlanId={viewPlanId} selectedPlanId={viewPlanId}
footer={
<Button
type="button"
className="w-full"
// "Thêm mới" = trả panel 2 về form TRẮNG. Không điều hướng đi đâu.
onClick={() => {
setViewPlanId(null)
setPurchaseEvaluationId('')
setApprovalWorkflowId('')
setGhiChu('')
}}
>
<Plus className="mr-2 h-4 w-4" />
Thêm mới
</Button>
}
/> />
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block"> <main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
@ -294,55 +275,59 @@ export function KhkkCreatePage() {
</div> </div>
</Section> </Section>
<Section title="2. Nhóm duyệt & quy trình"> {/* [S174 owner] "gán cứng luôn theo từng nhóm, ko cho User điều chỉnh chỗ này
luôn" ⇒ CẢ HAI ô thành GIÁ-TRỊ CỐ ĐỊNH, không còn `Select`. Cố ý KHÔNG
dùng `<Select disabled>`: ô xám vẫn trông như "bấm được nhưng đang khoá"
và vẫn nằm trong tab-order — hiển thị thẳng giá trị mới nói đúng rằng
đây là dữ-kiện, không phải lựa-chọn. Muốn nhóm khác thì đổi leaf ở menu. */}
<Section title="2. Nhóm duyệt & quy trình (theo menu, không đổi được)">
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
<div> <div>
<Label htmlFor="approvalGroup" className="text-[11px]">Nhóm duyệt (N1N8) *</Label> <Label className="text-[11px]">Nhóm duyệt</Label>
<Select <div className="flex h-9 items-center gap-2 rounded-md border border-slate-200 bg-slate-50 px-3 text-sm text-slate-700">
id="approvalGroup" <span className="rounded border border-violet-200 bg-violet-50 px-1.5 py-0.5 text-[10px] font-semibold text-violet-700">
value={approvalGroup} N{approvalGroup}
onChange={(e) => setApprovalGroup(Number(e.target.value))} </span>
> <span className="truncate">
{KHKK_APPROVAL_GROUPS.map((g) => { {KHKK_APPROVAL_GROUP_LABELS[approvalGroup] ?? `Nhóm ${approvalGroup}`}
const used = takenGroups.includes(g) </span>
return ( </div>
<option key={g} value={g} disabled={used}>
N{g} - {KHKK_APPROVAL_GROUP_LABELS[g] ?? `Nhóm ${g}`}
{used ? ' (đã có kế hoạch)' : ''}
</option>
)
})}
</Select>
{groupTaken ? ( {groupTaken ? (
<p className="mt-1 text-[11px] font-medium text-red-600"> <p className="mt-1 text-[11px] font-medium text-red-600">
Phiếu này đã kế hoạch cho nhóm N{approvalGroup} chọn nhóm khác. Phiếu này đã kế hoạch cho nhóm N{approvalGroup} chọn phiếu khác,
hoặc sang leaf nhóm khác menu bên trái.
</p> </p>
) : ( ) : (
<p className="mt-1 text-[11px] text-slate-500"> <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 Lấy theo mục đang mở menu đi nhóm bằng ch sang mục khác
</p> </p>
)} )}
</div> </div>
<div> <div>
<Label htmlFor="workflow" className="text-[11px]">Quy trình duyệt *</Label> <Label className="text-[11px]">Quy trình duyệt</Label>
<Select <div className="flex h-9 items-center rounded-md border border-slate-200 bg-slate-50 px-3 text-sm text-slate-700">
id="workflow" {workflows.isLoading ? (
value={approvalWorkflowId} <span className="text-slate-400">Đang tải</span>
onChange={(e) => setApprovalWorkflowId(e.target.value)} ) : autoWorkflow ? (
> <span className="truncate">
<option value=""> Chọn quy trình duyệt </option> <span className="font-mono text-[12px] text-brand-700">{autoWorkflow.code}</span>
{workflows.data?.map((w) => ( <span className="text-slate-400"> · </span>
<option key={w.id} value={w.id}>{w.code} - {w.name}</option> {autoWorkflow.name}
))} </span>
</Select> ) : (
{workflows.data && workflows.data.length === 0 ? ( <span className="text-red-600">Không tìm thấy {wantWorkflowCode}</span>
)}
</div>
{!workflows.isLoading && !autoWorkflow ? (
<p className="mt-1 text-[11px] text-amber-700"> <p className="mt-1 text-[11px] text-amber-700">
Chưa quy trình duyệt "Kế hoạch ký kết HĐ" nào đưc ghim cho user chọn. Liên hệ admin tạo trước. Chưa quy trình <strong>{wantWorkflowCode}</strong> (hoặc chưa đưc ghim
cho user chọn). Nhờ admin mở trong màn Quy trình duyệt rồi quay lại hệ thống
KHÔNG tự dùng quy trình của nhóm khác.
</p> </p>
) : ( ) : (
<p className="mt-1 text-[11px] text-slate-500"> <p className="mt-1 text-[11px] text-slate-500">
Chỉ hiển thị quy trình admin đã ghim cho user chọn Mỗi nhóm duyệt gắn đúng một quy trình hệ thống tự chọn theo nhóm
</p> </p>
)} )}
</div> </div>
@ -380,7 +365,7 @@ export function KhkkCreatePage() {
type="button" type="button"
variant="ghost" variant="ghost"
className="text-xs" className="text-xs"
onClick={() => { setPurchaseEvaluationId(''); setApprovalWorkflowId(''); setGhiChu('') }} onClick={() => { setPurchaseEvaluationId(''); setGhiChu('') }}
> >
Hủy Hủy
</Button> </Button>

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 {
Check, ClipboardList, FileCheck, MessageSquare, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users, ClipboardList, FileCheck, 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'
@ -255,9 +255,10 @@ export function KhkkDetailContent({
// File "đính kèm khi duyệt" thuộc panel 3 (mục 5) — Section 4 chỉ liệt kê hồ-sơ của phiếu. // File "đính kèm khi duyệt" thuộc panel 3 (mục 5) — Section 4 chỉ liệt kê hồ-sơ của phiếu.
const planAttachments = plan.attachments.filter(a => a.purpose !== KhkkAttachmentPurpose.ApprovalAttachment) const planAttachments = plan.attachments.filter(a => a.purpose !== KhkkAttachmentPurpose.ApprovalAttachment)
// ── Section 5: ý-kiến cấp duyệt, gom theo BƯỚC (Phòng) → CẤP → NV ────────────────── // 🧊 [S174 owner] Section 5kiến cấp duyệt" ĐÃ GỠ khỏi panel giữa — anh: "bỏ luôn cái
const opinions = plan.levelOpinions ?? [] // này ra luôn". Sơ-đồ Bước/Cấp + chữ ký đã sống ở PANEL 3 (`KhkkWorkflowPanel`), để cả hai
const steps = plan.workflowSteps ?? [] // là kể một chuyện hai lần trên cùng màn hình. Dữ-liệu `plan.levelOpinions` /
// `plan.workflowSteps` VẪN có trong DTO (panel 3 dùng) — chỉ bỏ chỗ VẼ ở đây.
return ( return (
<div className="card-accent overflow-hidden" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}> <div className="card-accent overflow-hidden" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
@ -569,112 +570,6 @@ export function KhkkDetailContent({
</ul> </ul>
)} )}
</Section> </Section>
{/* ─────────────── 5 — Ảnh 3 khoanh đỏ ───────────────
Gom theo BƯỚC (kèm PHÒNG) → lưới 2 cột các ô ý-kiến của từng CẤP, đúng khuôn
Section "Ý kiến cấp duyệt" V2 của Duyệt NCC (`PeDetailTabs.tsx:705-774`):
forEach Bước → forEach Cấp → 1 ô. Ô chưa ký vẫn HIỆN (chỗ trống có tên người) —
vắng ô là người đọc không biết còn thiếu chữ ký của ai. */}
<Section
title="5. Ý kiến cấp duyệt"
icon={<MessageSquare className="h-4 w-4" />}
accent="var(--color-violet-500)"
head="text-violet-700"
chipBg="var(--color-violet-50)"
chipFg="var(--color-violet-700)"
count={opinions.length}
>
{steps.length === 0 ? (
<div className="text-sm text-muted-foreground">
Phiếu chưa pin quy trình duyệt chưa cấp nào đ ghi ý kiến.
</div>
) : (
<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 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 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"
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="p-3">
{step.levels.length === 0 ? (
<div className="text-[12px] italic text-slate-400"> Bước này chưa cấu hình cấp duyệt.</div>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{step.levels.map((lv) => {
const signed = opinions.find(o => o.approvalWorkflowLevelId === lv.id)
return (
<div
key={lv.id}
className={cn(
'rounded-lg border bg-white p-3',
signed ? 'border-emerald-200' : 'border-dashed border-slate-200',
)}
>
<div className="mb-2 flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<h4 className="text-[13px] font-semibold text-slate-700">
{lv.name || `Cấp ${lv.order}`} {' '}
<span className="text-slate-900">{lv.approverFullName ?? '(chưa cấu hình)'}</span>
</h4>
{/* Không dùng `signed!` — thu hẹp bằng chính `signed &&` để
khỏi mượn null-forgiving trên dữ-liệu từ API (gotcha #80). */}
{signed && signed.approverUserId
&& signed.approverUserId !== signed.signedByUserId && (
<div className="mt-1 inline-flex items-center gap-1 rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700">
Admin <strong>{signed.signedByFullName}</strong> duyệt thay
</div>
)}
</div>
{signed ? (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-medium text-emerald-700">
<Check className="h-3 w-3" /> Đã duyệt
</span>
) : (
<span className="inline-flex shrink-0 items-center rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-500">
Chưa
</span>
)}
</div>
{signed ? (
<>
<div className="whitespace-pre-wrap text-sm text-slate-800">
{signed.comment?.trim() || '(duyệt — không ý kiến)'}
</div>
<div className="mt-2 border-t border-slate-100 pt-1.5 text-[11px] text-slate-500">
{formatDateTime(signed.signedAt)}
</div>
</>
) : (
<div className="text-sm italic text-slate-400">Chưa ý kiến duyệt.</div>
)}
</div>
)
})}
</div>
)}
</div>
</div>
)
})}
</div>
)}
</Section>
</div> </div>
{/* Dialog thêm/sửa căn cứ */} {/* Dialog thêm/sửa căn cứ */}

View File

@ -291,7 +291,6 @@ export function KhkkListPanel({
{!flatMode && ( {!flatMode && (
<PipelineTreePanel <PipelineTreePanel
currentStage={2} currentStage={2}
className="border-0 shadow-none"
hideHeader 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

@ -31,6 +31,7 @@ import { cn } from '@/lib/cn'
import { import {
KHKK_PHASE_BADGE, KHKK_PHASE_BADGE,
KHKK_PHASE_LABELS, KHKK_PHASE_LABELS,
KhkkPhase,
type KhkkListItemDto, type KhkkListItemDto,
type KhkkPeCandidateDto, type KhkkPeCandidateDto,
type PagedResult, type PagedResult,
@ -78,7 +79,6 @@ export function KhkkWorkListPanel({
onPickPlan, onPickPlan,
selectedPeId, selectedPeId,
selectedPlanId, selectedPlanId,
footer,
}: { }: {
/** Bấm PE ở tầng trên → host nạp phiếu đó vào form tạo (Ở LẠI màn, không điều hướng). */ /** Bấm PE ở tầng trên → host nạp phiếu đó vào form tạo (Ở LẠI màn, không điều hướng). */
onPickPe: (peId: string) => void onPickPe: (peId: string) => void
@ -87,8 +87,6 @@ export function KhkkWorkListPanel({
onPickPlan: (planId: string) => void onPickPlan: (planId: string) => void
selectedPeId?: string | null selectedPeId?: string | null
selectedPlanId?: string | null selectedPlanId?: string | null
/** Nút "+ Thêm mới" dính đáy — mirror `PeListPanel` của màn Duyệt NCC — Thao tác. */
footer?: React.ReactNode
}) { }) {
const { can } = usePermission() const { can } = usePermission()
// 🔴 ĐÚNG key policy của endpoint — KHÔNG dùng `Khkk_*` (đó là key MENU, tầng hiển thị). // 🔴 ĐÚNG key policy của endpoint — KHÔNG dùng `Khkk_*` (đó là key MENU, tầng hiển thị).
@ -126,7 +124,19 @@ export function KhkkWorkListPanel({
else byProject.set(key, [pe]) else byProject.set(key, [pe])
} }
const planItems = plans.data?.items ?? [] // [S174 owner] "chỗ thao tác chỉ hiển trả lại và nháp, từ chối thôi nhé" ⇒ DANH-SÁCH-TRẮNG
// 3 trạng thái, KHÔNG phải loại-trừ. 🔴 Đổi từ loại-trừ sang danh-sách-trắng là CÓ CHỦ ĐÍCH:
// loại-trừ (`!== DaDuyet`) sẽ tự động NHẬN mọi phase mới thêm về sau; danh-sách-trắng thì
// phase mới phải được khai TƯỜNG MINH mới hiện. Ở màn Thao tác — nơi chỉ nên có việc CÒN
// SỬA ĐƯỢC — im lặng nhận thêm là hỏng, nên chọn vế chặt.
// 🔸 `ChoDuyet` CŨNG bị loại (khác lượt trước chỉ bỏ `DaDuyet`): phiếu đang trình duyệt thì
// người soạn không thao tác gì được nữa.
// 🔴 KHAI GIỚI-HẠN (lọc ở FE): endpoint chỉ nhận `phase` ĐƠN, không nhận nhiều ⇒ (1) KHÔNG
// dùng `plans.data.total` làm số hiển thị (nó đếm MỌI phase) — đếm từ mảng đã lọc;
// (2) cửa-sổ 30 phiếu mới nhất có thể toàn phiếu đã duyệt ⇒ tầng dưới rỗng dù còn nháp
// cũ hơn. Chấp nhận ở UAT; muốn chuẩn thì BE nhận `phases[]` rồi lọc SERVER-SIDE.
const OPEN_PHASES: number[] = [KhkkPhase.DangSoanThao, KhkkPhase.TraLai, KhkkPhase.TuChoi]
const planItems = (plans.data?.items ?? []).filter((k) => OPEN_PHASES.includes(k.phase))
return ( return (
<aside className="flex min-h-0 flex-col overflow-hidden border-r border-slate-200 bg-white"> <aside className="flex min-h-0 flex-col overflow-hidden border-r border-slate-200 bg-white">
@ -209,8 +219,11 @@ export function KhkkWorkListPanel({
<div className="shrink-0 border-t-2 border-slate-200" /> <div className="shrink-0 border-t-2 border-slate-200" />
<Tier <Tier
title="Kế hoạch ký kết đã tạo" // Tên nói ĐÚNG tập đang hiện, không nói chung chung — người đọc phải biết ngay
count={plans.data?.total ?? null} // vì sao phiếu họ vừa gửi duyệt "biến mất" khỏi đây.
title="KHKK · nháp / trả lại / từ chối"
// Đếm từ mảng ĐÃ LỌC, KHÔNG lấy `total` của server (xem khối lọc phía trên).
count={plans.data ? planItems.length : null}
accent="bg-violet-50 text-violet-600" accent="bg-violet-50 text-violet-600"
icon={<FileSignature className="h-3 w-3" />} icon={<FileSignature className="h-3 w-3" />}
> >
@ -228,7 +241,10 @@ export function KhkkWorkListPanel({
</div> </div>
)} )}
{!plans.isLoading && !plans.isError && planItems.length === 0 && ( {!plans.isLoading && !plans.isError && planItems.length === 0 && (
<p className="p-3 text-[11px] italic text-slate-400">Chưa kế hoạch kết nào.</p> <p className="p-3 text-[11px] italic text-slate-400">
Không còn kế hoạch nào đang xử . Phiếu đã duyệt không hiện đây xem tại
menu Kế hoạch kết .
</p>
)} )}
<ul className="divide-y divide-slate-100"> <ul className="divide-y divide-slate-100">
{planItems.map((k) => ( {planItems.map((k) => (
@ -267,19 +283,23 @@ export function KhkkWorkListPanel({
</li> </li>
))} ))}
</ul> </ul>
{plans.data && plans.data.total > planItems.length && ( {/* 🔴 KHÔNG in "x/total" nữa: `total` gồm cả phiếu đã duyệt vừa bị lọc ⇒ tỉ số đó
sẽ đọc thành "đang cắt bớt" trong khi thật ra là "đang lọc bỏ". Nói đúng việc
đang làm, và chỉ nói khi thật sự có cái bị giấu. */}
{plans.data && plans.data.items.length > planItems.length && (
<p className="border-t border-slate-100 px-3 py-1.5 text-[10px] italic text-slate-400"> <p className="border-t border-slate-100 px-3 py-1.5 text-[10px] italic text-slate-400">
Hiện {planItems.length}/{plans.data.total} kế hoạch mới nhất xem đ menu Kế hoạch kết . Đã n kế hoạch trạng thái Đã duyệt xem đ menu Kế hoạch kết .
</p> </p>
)} )}
</Tier> </Tier>
</> </>
)} )}
{/* Nút hành-động dính ĐÁY, ngoài vùng cuộn của 2 tầng — mirror "+ Thêm mới" của {/* 🧊 [S174] KHÔNG có nút "+ Thêm mới" ở đáy — anh chốt bỏ. Lý do đứng vững: màn này
`PeListPanel` (màn Duyệt NCC — Thao tác). Hiện KỂ CẢ khi thiếu quyền KHKK: cột KHÔNG có đường "tạo từ số 0". Một kế hoạch LUÔN sinh ra từ một phiếu Duyệt NCC, mà
không dựng được thì vẫn phải còn đường bấm, không thì màn thành ngõ cụt. */} các phiếu đủ điều kiện đã nằm nguyên ở tầng trên ⇒ nút chỉ xoá form chứ không tạo
{footer && <div className="shrink-0 border-t border-slate-200 p-2">{footer}</div>} thêm được gì. Đây là chỗ khác CÓ CHỦ ĐÍCH với `PeListPanel` (bên đó tạo phiếu PE
mới là việc có thật). Ai định thêm lại: phải trả lời được "thêm mới CÁI GÌ". */}
</aside> </aside>
) )
} }

View File

@ -192,7 +192,17 @@ export function PipelineTreePanel({
return ( return (
<aside <aside
className={cn('card-accent flex flex-col self-start overflow-hidden lg:sticky lg:top-4', className)} className={cn(
'flex flex-col self-start overflow-hidden',
// [S174 owner] "style cho nó giống nhau đồng bộ nhé, style bên KHKK có cái viền bên
// ngoài" — `hideHeader` = đang NHÚNG trong panel khác ⇒ bỏ vỏ card + sticky để liền
// mạch với host, y như cây bên Duyệt NCC.
// 🔴 KHÔNG vá bằng cách truyền `border-0 shadow-none`: `.card-accent` khai ở
// `index.css:112` NẰM NGOÀI `@layer` nên THẮNG utility của Tailwind v4 (gotcha #66)
// ⇒ cách đó bị vô hiệu IM LẶNG (đúng thứ vừa xảy ra). Phải GỠ CLASS, không đè.
!hideHeader && 'card-accent lg:sticky lg:top-4',
className,
)}
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"
> >

View File

@ -10,7 +10,6 @@ import { useNavigate, useSearchParams } from 'react-router-dom'
import { Inbox, AlertTriangle, Clock, FileText, Search, ClipboardList } from 'lucide-react' import { Inbox, AlertTriangle, Clock, FileText, Search, ClipboardList } from 'lucide-react'
import { ContractDetailContent } from '@/components/contracts/ContractDetailContent' import { ContractDetailContent } from '@/components/contracts/ContractDetailContent'
import { WorkflowHistoryPanel } from '@/components/contracts/WorkflowHistoryPanel' import { WorkflowHistoryPanel } from '@/components/contracts/WorkflowHistoryPanel'
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
import { PhaseBadge } from '@/components/PhaseBadge' import { PhaseBadge } from '@/components/PhaseBadge'
import { SlaTimer } from '@/components/SlaTimer' import { SlaTimer } from '@/components/SlaTimer'
import { EmptyState } from '@/components/EmptyState' import { EmptyState } from '@/components/EmptyState'
@ -339,13 +338,19 @@ export function InboxPage() {
{/* Panel 3 — Workflow + history */} {/* Panel 3 — Workflow + history */}
<aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block"> <aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block">
{/* [S168 GĐ3-D — owner slot (63) chốt D] Chưa chọn HĐ ⇒ cây toàn trình (GĐ3) {/* [S174 owner] GỠ `PipelineTreePanel` khỏi Hộp thư — anh: "chỗ hộp thư vứt panel
thế chỗ ô trng; chọn HĐ thì nhường chỗ cho quy trình duyệt. Trade-off D này vào làm j, tập trung vào KHKK thôi". Hộp thư là hàng-đợi-việc-của-tôi; cây
(cây rời panel khi chọn HĐ) — sub-implfe-gd3.md §1. */} toàn trình là bản-đồ-toàn-hệ ⇒ sai thể loại, và nó chiếm mất chỗ đáng ra để
{/* [gate F-1/F-2] Panel-3 TỰ CUỘN ⇒ thân cây `lg:max-h-none` giữ đúng 1 thanh cuộn trống chờ quy trình duyệt của HĐ đang chọn.
(chính /inbox là trang THIẾU ~26px vì dải stats + banner vai trò); sticky/self-start 🧊 Lineage: panel này vào đây @S168 GĐ3-D theo owner slot (63) chốt D + "mở rộng
của khuôn vô hiệu ở host display:block này — by-design. */} = cả 3 trang". Nay owner thu lại ĐÚNG trang này. HAI trang GĐ3/GĐ4 còn lại
{!selectedId && <PipelineTreePanel currentStage={3} bodyMaxHClass="max-h-[28rem] lg:max-h-none" />} (`MyContractsPage` · `HardCopiesPage`) GIỮ NGUYÊN — anh chỉ đích danh Hộp thư,
không nói gỡ hết; đừng tự suy rộng. */}
{!selectedId && (
<div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-400">
Quy trình duyệt sẽ hiện khi chọn hợp đng.
</div>
)}
{selectedId && detail.data && <WorkflowHistoryPanel contract={detail.data} />} {selectedId && detail.data && <WorkflowHistoryPanel contract={detail.data} />}
</aside> </aside>
</div> </div>

View File

@ -23,19 +23,17 @@
import { useState, type FormEvent, type ReactNode } from 'react' 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 { FileCheck, Plus, X } from 'lucide-react' import { FileCheck, X } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { EmptyState } from '@/components/EmptyState' import { EmptyState } from '@/components/EmptyState'
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 { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent' import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
import { KhkkWorkListPanel } from '@/components/khkk/KhkkWorkListPanel' import { KhkkWorkListPanel } from '@/components/khkk/KhkkWorkListPanel'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError' import { getErrorMessage } from '@/lib/apiError'
import { import {
KHKK_APPROVAL_GROUPS,
KHKK_APPROVAL_GROUP_DEFAULT, KHKK_APPROVAL_GROUP_DEFAULT,
KHKK_APPROVAL_GROUP_LABELS, KHKK_APPROVAL_GROUP_LABELS,
khkkGroupMenuLabel, khkkGroupMenuLabel,
@ -55,11 +53,9 @@ interface WorkflowDto {
isUserSelectable: boolean isUserSelectable: boolean
} }
// Nhóm trống ĐẦU TIÊN của một PE (null = đã dùng hết 8 nhóm)để ô chọn nhóm không đứng // 🧊 [S174] `firstFreeGroup()` ĐÃ GỠ cùng lúc bỏ ô chọn nhóm — nó chỉ tồn tại để tự nhảy
// yên ở một nhóm chắc chắn 409. // sang nhóm trống, đúng hành-vi anh vừa cấm ("gán cứng theo từng nhóm, ko cho User điều
function firstFreeGroup(taken: number[]): number | null { // chỉnh"). Giữ lại thành hàm chết thì lượt sau có người gọi lại.
return KHKK_APPROVAL_GROUPS.find((g) => !taken.includes(g)) ?? null
}
export function KhkkCreatePage() { export function KhkkCreatePage() {
const navigate = useNavigate() const navigate = useNavigate()
@ -69,21 +65,17 @@ export function KhkkCreatePage() {
const presetGroup = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null const presetGroup = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null
const [purchaseEvaluationId, setPurchaseEvaluationId] = useState('') const [purchaseEvaluationId, setPurchaseEvaluationId] = useState('')
const [approvalWorkflowId, setApprovalWorkflowId] = useState('')
const [approvalGroup, setApprovalGroup] = useState<number>(presetGroup ?? KHKK_APPROVAL_GROUP_DEFAULT)
const [ghiChu, setGhiChu] = useState('') const [ghiChu, setGhiChu] = useState('')
// [S174 owner] "gán cứng luôn theo từng nhóm, ko cho User điều chỉnh chỗ này luôn"
// ⇒ nhóm duyệt KHÔNG còn là state: DẪN XUẤT thẳng từ leaf menu (`?group=n`). Không còn
// state thì không thể lệch, nên khối đồng-bộ-preset cũ (`lastPresetGroup`) cũng bỏ luôn —
// đó là thứ chỉ tồn tại để vá đúng cái lệch này.
const approvalGroup = presetGroup ?? KHKK_APPROVAL_GROUP_DEFAULT
// [S174] Phiếu KHKK đang XEM ở panel 2. Có giá trị ⇒ panel 2 hiện phiếu (read-only) thay // [S174] Phiếu KHKK đang XEM ở panel 2. Có giá trị ⇒ panel 2 hiện phiếu (read-only) thay
// cho form tạo. `null` ⇒ về form tạo. Đây là thứ thay cho việc `navigate('/khkk/:id')`. // cho form tạo. `null` ⇒ về form tạo. Đây là thứ thay cho việc `navigate('/khkk/:id')`.
const [viewPlanId, setViewPlanId] = useState<string | null>(null) const [viewPlanId, setViewPlanId] = useState<string | null>(null)
// Đổi leaf nhóm ⇒ đồng bộ ô chọn nhóm. CHỈ đổi khi CHÍNH THAM SỐ URL đổi — so với
// `approvalGroup` thì người dùng tự chọn nhóm khác sẽ bị kéo ngược về nhóm của URL.
const [lastPresetGroup, setLastPresetGroup] = useState(presetGroup)
if (presetGroup !== lastPresetGroup) {
setLastPresetGroup(presetGroup)
if (presetGroup !== null) setApprovalGroup(presetGroup)
}
// Cùng query key với `KhkkWorkListPanel` ⇒ dùng CHUNG cache, không gọi mạng 2 lần. // Cùng query key với `KhkkWorkListPanel` ⇒ dùng CHUNG cache, không gọi mạng 2 lần.
const candidates = useQuery({ const candidates = useQuery({
queryKey: ['khkk-pe-candidates'], queryKey: ['khkk-pe-candidates'],
@ -103,6 +95,16 @@ export function KhkkCreatePage() {
}, },
}) })
// [S174 owner] Quy trình duyệt cũng GÁN CỨNG theo nhóm — mỗi nhóm đúng MỘT quy trình.
// 🔴 Chuỗi mã lấy từ NGUỒN BE, không suy từ màn hình: `DbInitializer.cs:609`
// `var code = $"KHKK-N{n}"` (vòng `for n = 1..8`, seed idempotent per-Code).
// Không khớp ⇒ KHÔNG tự rơi về quy trình khác: chặn tạo + nói rõ thiếu mã nào, vì gán
// nhầm quy trình là phiếu đi sai đường duyệt — hỏng nặng hơn là không tạo được.
const wantWorkflowCode = `KHKK-N${approvalGroup}`
const autoWorkflow = workflows.data?.find(
(w) => (w.code ?? '').trim().toUpperCase() === wantWorkflowCode,
) ?? null
// Phiếu đang XEM — chỉ tải khi thật sự có cái để xem. // Phiếu đang XEM — chỉ tải khi thật sự có cái để xem.
const viewPlan = useQuery({ const viewPlan = useQuery({
queryKey: ['khkk-detail', viewPlanId], queryKey: ['khkk-detail', viewPlanId],
@ -113,10 +115,10 @@ export function KhkkCreatePage() {
const create = useMutation({ const create = useMutation({
mutationFn: async () => { mutationFn: async () => {
if (!purchaseEvaluationId) throw new Error('Vui lòng chọn phiếu Duyệt NCC nguồn') if (!purchaseEvaluationId) throw new Error('Vui lòng chọn phiếu Duyệt NCC nguồn')
if (!approvalWorkflowId) throw new Error('Vui lòng chọn Quy trình duyệt') if (!autoWorkflow) throw new Error(`Chưa có quy trình duyệt ${wantWorkflowCode}`)
const body: CreateKhkkInput = { const body: CreateKhkkInput = {
peId: purchaseEvaluationId, // [F-2] BE Command field = PeId peId: purchaseEvaluationId, // [F-2] BE Command field = PeId
approvalWorkflowId, approvalWorkflowId: autoWorkflow.id,
ghiChu: ghiChu.trim() || null, ghiChu: ghiChu.trim() || null,
approvalGroup, // [K2] gửi TƯỜNG MINH, không dựa default ngầm của BE approvalGroup, // [K2] gửi TƯỜNG MINH, không dựa default ngầm của BE
} }
@ -127,7 +129,6 @@ export function KhkkCreatePage() {
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')
setPurchaseEvaluationId('') setPurchaseEvaluationId('')
setApprovalWorkflowId('')
setGhiChu('') setGhiChu('')
setViewPlanId(data.id) setViewPlanId(data.id)
}, },
@ -141,21 +142,17 @@ export function KhkkCreatePage() {
const missingCreate = [ const missingCreate = [
!purchaseEvaluationId && 'Phiếu Duyệt NCC nguồn', !purchaseEvaluationId && 'Phiếu Duyệt NCC nguồn',
!approvalWorkflowId && 'Quy trình duyệt', !workflows.isLoading && !autoWorkflow && `Quy trình ${wantWorkflowCode} (chưa cấu hình)`,
groupTaken && `Nhóm duyệt còn trống (N${approvalGroup} đã có kế hoạch)`, groupTaken && `Phiếu này đã có kế hoạch nhóm N${approvalGroup}`,
].filter(Boolean) as string[] ].filter(Boolean) as string[]
const canSubmit = missingCreate.length === 0 && !create.isPending const canSubmit = missingCreate.length === 0 && !!autoWorkflow && !create.isPending
// Bấm PE ở panel 1: nạp vào form + rời chế-độ xem. Nhóm đang chọn đã bị PE đó dùng thì // [S174] Bấm PE ở panel 1 = nạp vào form + rời chế-độ xem. 🧊 Khối "nhảy sang nhóm trống
// nhảy sang nhóm trống đầu tiên ⇒ người dùng không phải tự dò. // đầu tiên" ĐÃ BỎ: nhóm nay gán cứng theo leaf menu, tự nhảy nhóm = đúng thứ anh vừa cấm.
// PE đã có kế hoạch ở nhóm này ⇒ `groupTaken` chặn + nói rõ, người dùng tự sang leaf khác.
const pickPe = (peId: string) => { const pickPe = (peId: string) => {
setPurchaseEvaluationId(peId) setPurchaseEvaluationId(peId)
setViewPlanId(null) setViewPlanId(null)
const taken = list.find((p) => p.id === peId)?.existingGroups ?? []
if (taken.includes(approvalGroup)) {
const free = firstFreeGroup(taken)
if (free !== null) setApprovalGroup(free)
}
} }
const onSubmit = (e: FormEvent) => { const onSubmit = (e: FormEvent) => {
@ -185,22 +182,6 @@ export function KhkkCreatePage() {
onPickPlan={setViewPlanId} onPickPlan={setViewPlanId}
selectedPeId={purchaseEvaluationId || null} selectedPeId={purchaseEvaluationId || null}
selectedPlanId={viewPlanId} selectedPlanId={viewPlanId}
footer={
<Button
type="button"
className="w-full"
// "Thêm mới" = trả panel 2 về form TRẮNG. Không điều hướng đi đâu.
onClick={() => {
setViewPlanId(null)
setPurchaseEvaluationId('')
setApprovalWorkflowId('')
setGhiChu('')
}}
>
<Plus className="mr-2 h-4 w-4" />
Thêm mới
</Button>
}
/> />
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block"> <main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
@ -294,55 +275,59 @@ export function KhkkCreatePage() {
</div> </div>
</Section> </Section>
<Section title="2. Nhóm duyệt & quy trình"> {/* [S174 owner] "gán cứng luôn theo từng nhóm, ko cho User điều chỉnh chỗ này
luôn" ⇒ CẢ HAI ô thành GIÁ-TRỊ CỐ ĐỊNH, không còn `Select`. Cố ý KHÔNG
dùng `<Select disabled>`: ô xám vẫn trông như "bấm được nhưng đang khoá"
và vẫn nằm trong tab-order — hiển thị thẳng giá trị mới nói đúng rằng
đây là dữ-kiện, không phải lựa-chọn. Muốn nhóm khác thì đổi leaf ở menu. */}
<Section title="2. Nhóm duyệt & quy trình (theo menu, không đổi được)">
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
<div> <div>
<Label htmlFor="approvalGroup" className="text-[11px]">Nhóm duyệt (N1N8) *</Label> <Label className="text-[11px]">Nhóm duyệt</Label>
<Select <div className="flex h-9 items-center gap-2 rounded-md border border-slate-200 bg-slate-50 px-3 text-sm text-slate-700">
id="approvalGroup" <span className="rounded border border-violet-200 bg-violet-50 px-1.5 py-0.5 text-[10px] font-semibold text-violet-700">
value={approvalGroup} N{approvalGroup}
onChange={(e) => setApprovalGroup(Number(e.target.value))} </span>
> <span className="truncate">
{KHKK_APPROVAL_GROUPS.map((g) => { {KHKK_APPROVAL_GROUP_LABELS[approvalGroup] ?? `Nhóm ${approvalGroup}`}
const used = takenGroups.includes(g) </span>
return ( </div>
<option key={g} value={g} disabled={used}>
N{g} - {KHKK_APPROVAL_GROUP_LABELS[g] ?? `Nhóm ${g}`}
{used ? ' (đã có kế hoạch)' : ''}
</option>
)
})}
</Select>
{groupTaken ? ( {groupTaken ? (
<p className="mt-1 text-[11px] font-medium text-red-600"> <p className="mt-1 text-[11px] font-medium text-red-600">
Phiếu này đã kế hoạch cho nhóm N{approvalGroup} chọn nhóm khác. Phiếu này đã kế hoạch cho nhóm N{approvalGroup} chọn phiếu khác,
hoặc sang leaf nhóm khác menu bên trái.
</p> </p>
) : ( ) : (
<p className="mt-1 text-[11px] text-slate-500"> <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 Lấy theo mục đang mở menu đi nhóm bằng ch sang mục khác
</p> </p>
)} )}
</div> </div>
<div> <div>
<Label htmlFor="workflow" className="text-[11px]">Quy trình duyệt *</Label> <Label className="text-[11px]">Quy trình duyệt</Label>
<Select <div className="flex h-9 items-center rounded-md border border-slate-200 bg-slate-50 px-3 text-sm text-slate-700">
id="workflow" {workflows.isLoading ? (
value={approvalWorkflowId} <span className="text-slate-400">Đang tải</span>
onChange={(e) => setApprovalWorkflowId(e.target.value)} ) : autoWorkflow ? (
> <span className="truncate">
<option value=""> Chọn quy trình duyệt </option> <span className="font-mono text-[12px] text-brand-700">{autoWorkflow.code}</span>
{workflows.data?.map((w) => ( <span className="text-slate-400"> · </span>
<option key={w.id} value={w.id}>{w.code} - {w.name}</option> {autoWorkflow.name}
))} </span>
</Select> ) : (
{workflows.data && workflows.data.length === 0 ? ( <span className="text-red-600">Không tìm thấy {wantWorkflowCode}</span>
)}
</div>
{!workflows.isLoading && !autoWorkflow ? (
<p className="mt-1 text-[11px] text-amber-700"> <p className="mt-1 text-[11px] text-amber-700">
Chưa quy trình duyệt "Kế hoạch ký kết HĐ" nào đưc ghim cho user chọn. Liên hệ admin tạo trước. Chưa quy trình <strong>{wantWorkflowCode}</strong> (hoặc chưa đưc ghim
cho user chọn). Nhờ admin mở trong màn Quy trình duyệt rồi quay lại hệ thống
KHÔNG tự dùng quy trình của nhóm khác.
</p> </p>
) : ( ) : (
<p className="mt-1 text-[11px] text-slate-500"> <p className="mt-1 text-[11px] text-slate-500">
Chỉ hiển thị quy trình admin đã ghim cho user chọn Mỗi nhóm duyệt gắn đúng một quy trình hệ thống tự chọn theo nhóm
</p> </p>
)} )}
</div> </div>
@ -380,7 +365,7 @@ export function KhkkCreatePage() {
type="button" type="button"
variant="ghost" variant="ghost"
className="text-xs" className="text-xs"
onClick={() => { setPurchaseEvaluationId(''); setApprovalWorkflowId(''); setGhiChu('') }} onClick={() => { setPurchaseEvaluationId(''); setGhiChu('') }}
> >
Hủy Hủy
</Button> </Button>