[CLAUDE] Contract: man KHKK Thao tac thanh host 2-panel giong Duyet NCC + REVERT cot da chen nham vao man PE
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m40s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m40s
Anh bac ban truoc: "Bo phan nay di cho duyet NCC da golive va da tot roi, y tao la dang muon may lam giong giao dien thao tac cua Duyet NCC". REVERT (quan trong nhat) - `PurchaseEvaluationWorkspacePage` x2 app tro ve DUNG BYTE cua1b3bcaa(grid 2 panel, 0 import, 0 markup thua). Verify bang sha256 vs `git show 1b3bcaa:` + control-duong grep 0 hit ten panel trong pages/pe/. - Bai hoc ghi vao dau file panel: "lam giong man X" != "sua man X". Ban dau doc anh 2/3 (anh khoanh o man PE) thanh "chen them cot vao man PE"; anh 7 moi lo cho dat that la cot TRAI cua man Thao tac KHKK. DI DOI - `components/pe/PeKhkkSidePanel.tsx` -> `components/khkk/KhkkWorkListPanel.tsx` (git mv, giu lich su) — no phuc vu KHKK chu khong phai PE. MAN KHKK — THAO TAC (`/khkk/create`, 8 leaf nhom) doi tu trang-giay-cuon sang HOST 2-PANEL - Khuon be nguyen tu `PurchaseEvaluationWorkspacePage`: header 1 dong + grid `lg:grid-cols-[300px_1fr]` + Panel1 danh sach + nut dinh day + Panel2 noi dung. - Panel 1 = 2 TANG dung anh anh khoanh: tang TREN "Cot danh sach Duyet NCC" — PE da duyet CHUA lap KHKK, gom theo DU AN; bam = nap thang vao form tao (tao luon KHKK). tang DUOI "Cot KHKK da tao" — bam = XEM tai cho, read-only. - 🔴 KHONG re-direct o BAT KY duong nao (anh chot "cho hien phieu do luon, chi view la dc, ko re-direct ve phieu goc"): panel nhan CALLBACK onPickPe/onPickPlan thay vi tu navigate. Van con nut phu "Mo trang day du" cho ai muon roi man — la lua chon, khong phai ep buoc. - Section 1 cua form doi tu DANH SACH CHON thanh KHOI XAC NHAN phieu dang chon — danh sach da o Panel 1, de 2 cho chon cung 1 thu = 2 nguon su that tren cung mot man. - Tao xong KHONG navigate('/khkk/:id') nua ma mo luon phieu vua tao o Panel 2 + tra form ve trang. Cung tinh than "khong roi man". - Nut "Them moi" (dinh day Panel 1) = tra Panel 2 ve form trang. - Gate `KeHoachKyKet.Read` giu nguyen trong panel (gotcha #85); thieu quyen thi NOI RA, va nut hanh-dong VAN hien de man khong thanh ngo cut. Verify: build 2 app PASS; mirror SHA256 3/3 cap KHOP; PE workspace 0 du luong (byte-exact vs1b3bcaa) + control-duong grep 0 hit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@ -1,9 +1,15 @@
|
|||||||
// [S174 owner] Cột PHẢI của màn "Duyệt NCC — Thao tác" — 1 cột, 2 TẦNG 50/50:
|
// [S174 owner] PANEL 1 (danh sách) của màn "Kế hoạch ký kết HĐ — Thao tác" — 1 cột, 2 TẦNG:
|
||||||
// · 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,
|
// · 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";
|
// gom theo DỰ ÁN — "việc còn phải làm"; bấm = nạp thẳng vào form tạo;
|
||||||
// · tầng DƯỚI = kế hoạch ký kết ĐÃ TẠO — đây là "việc đã làm rồi".
|
// · tầng DƯỚI = kế hoạch ký kết ĐÃ TẠO — "việc đã làm rồi"; bấm = XEM tại chỗ (read-only).
|
||||||
// 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
|
// File MIRROR SHA256 identical với fe-user counterpart.
|
||||||
// là KHKK". File MIRROR SHA256 identical với fe-user counterpart.
|
//
|
||||||
|
// 🔴 LỊCH SỬ ĐẶT SAI CHỖ — ghi để không tái phạm: bản đầu S174 gắn panel này thành cột 3 của
|
||||||
|
// màn *Duyệt NCC — Thao tác* (`PurchaseEvaluationWorkspacePage`). Anh bác: **"chỗ duyệt NCC
|
||||||
|
// đã golive và đã tốt rồi"** — ý anh từ đầu là làm màn Thao tác của KHKK **GIỐNG GIAO DIỆN**
|
||||||
|
// Thao tác của Duyệt NCC, chứ không phải chèn thêm thứ vào màn Duyệt NCC. Đã revert
|
||||||
|
// `PurchaseEvaluationWorkspacePage` về đúng byte của `1b3bcaa`. Bài: "làm giống màn X" ≠
|
||||||
|
// "sửa màn X".
|
||||||
//
|
//
|
||||||
// 🔴 0 ENDPOINT MỚI — dùng lại đúng 2 cửa đã sống:
|
// 🔴 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`
|
// · `GET /contract-signing-plans/approved-pe-awaiting-plan` (chính cửa `KhkkCreatePage`
|
||||||
@ -18,7 +24,6 @@
|
|||||||
// 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
|
// 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ì".
|
// 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 { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
import { FileCheck, FileSignature, Lock } from 'lucide-react'
|
import { FileCheck, FileSignature, Lock } from 'lucide-react'
|
||||||
import { usePermission } from '@/hooks/usePermission'
|
import { usePermission } from '@/hooks/usePermission'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
@ -68,8 +73,23 @@ function Tier({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PeKhkkSidePanel() {
|
export function KhkkWorkListPanel({
|
||||||
const navigate = useNavigate()
|
onPickPe,
|
||||||
|
onPickPlan,
|
||||||
|
selectedPeId,
|
||||||
|
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). */
|
||||||
|
onPickPe: (peId: string) => void
|
||||||
|
/** Bấm KHKK ở tầng dưới → host mở phiếu đó ở panel 2, CHỈ XEM. Anh chốt: "click vào phiếu
|
||||||
|
* nào thì cho hiện phiếu đó luôn, chỉ view là đc, ko re-direct về phiếu gốc". */
|
||||||
|
onPickPlan: (planId: string) => void
|
||||||
|
selectedPeId?: 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ị).
|
||||||
const canKhkk = can('KeHoachKyKet', 'Read')
|
const canKhkk = can('KeHoachKyKet', 'Read')
|
||||||
@ -109,7 +129,7 @@ export function PeKhkkSidePanel() {
|
|||||||
const planItems = plans.data?.items ?? []
|
const planItems = plans.data?.items ?? []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="hidden min-h-0 flex-col overflow-hidden border-l border-slate-200 bg-white xl:flex">
|
<aside className="flex min-h-0 flex-col overflow-hidden border-r border-slate-200 bg-white">
|
||||||
{!canKhkk ? (
|
{!canKhkk ? (
|
||||||
// Cột trắng đọc thành "chưa có gì" — phải nói ĐÚNG lý do là thiếu quyền.
|
// 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">
|
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-4 text-center">
|
||||||
@ -158,11 +178,14 @@ export function PeKhkkSidePanel() {
|
|||||||
<li key={pe.id}>
|
<li key={pe.id}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
// Bấm = đi thẳng tới màn lập kế hoạch. Không preset `?group=` vì
|
// Bấm = nạp THẲNG phiếu này vào form tạo bên panel 2. Không điều hướng,
|
||||||
// PE này chưa dùng nhóm nào ⇒ để màn tạo giữ default của nó.
|
// không mở lại màn khác — đúng "click vào cột này để tạo luôn KHKK".
|
||||||
onClick={() => navigate('/khkk/create')}
|
onClick={() => onPickPe(pe.id)}
|
||||||
title="Lập kế hoạch ký kết cho phiếu này"
|
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"
|
className={cn(
|
||||||
|
'block w-full px-3 py-2 text-left transition hover:bg-amber-50/60',
|
||||||
|
selectedPeId === pe.id && 'bg-amber-50 ring-1 ring-inset ring-amber-300',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="font-mono text-[10px] text-brand-700">{pe.maPhieu ?? '—'}</span>
|
<span className="font-mono text-[10px] text-brand-700">{pe.maPhieu ?? '—'}</span>
|
||||||
@ -212,9 +235,14 @@ export function PeKhkkSidePanel() {
|
|||||||
<li key={k.id}>
|
<li key={k.id}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate(`/khkk/${k.id}`)}
|
// 🔴 CHỈ XEM tại chỗ — KHÔNG `navigate('/khkk/:id')`. Anh chốt: "cho hiện
|
||||||
title="Mở kế hoạch ký kết"
|
// phiếu đó luôn, chỉ view là đc, ko re-direct về phiếu gốc".
|
||||||
className="block w-full px-3 py-2 text-left transition hover:bg-violet-50/60"
|
onClick={() => onPickPlan(k.id)}
|
||||||
|
title="Xem kế hoạch ký kết này (chỉ xem)"
|
||||||
|
className={cn(
|
||||||
|
'block w-full px-3 py-2 text-left transition hover:bg-violet-50/60',
|
||||||
|
selectedPlanId === k.id && 'bg-violet-50 ring-1 ring-inset ring-violet-300',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5">
|
<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>
|
<span className="font-mono text-[10px] text-violet-700">{k.maKeHoach ?? '(chưa cấp mã)'}</span>
|
||||||
@ -247,6 +275,11 @@ export function PeKhkkSidePanel() {
|
|||||||
</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
|
||||||
|
`PeListPanel` (màn Duyệt NCC — Thao tác). Hiện KỂ CẢ khi thiếu quyền KHKK: cột
|
||||||
|
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. */}
|
||||||
|
{footer && <div className="shrink-0 border-t border-slate-200 p-2">{footer}</div>}
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -1,46 +1,51 @@
|
|||||||
// [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] Màn "Kế hoạch ký kết HĐ — Thao tác" (route `/khkk/create`, 8 leaf nhóm).
|
||||||
// File MIRROR SHA256 identical với fe-user counterpart.
|
// File MIRROR SHA256 identical với fe-user counterpart.
|
||||||
//
|
//
|
||||||
// 3 section: (1) chọn phiếu PE nguồn — phiếu DaDuyet có NCC trúng thầu (BE lọc, FE
|
// [S174 owner] ĐỔI TỪ TRANG-GIẤY-CUỘN SANG HOST 2-PANEL — anh chốt: **"làm giống giao diện
|
||||||
// không tự đoán); (2) NHÓM DUYỆT N1..N8 + quy trình duyệt V2 ApplicableType=10 admin
|
// thao tác của Duyệt NCC"** (`pages/pe/PurchaseEvaluationWorkspacePage.tsx`). Khuôn bê về:
|
||||||
// đã ghim; (3) ghi chú. Dòng giá per-NCC do BE tự sinh từ winner của phiếu PE
|
// · header 1 dòng + grid `lg:grid-cols-[300px_1fr]`;
|
||||||
// (snapshot `PeReferenceAmount`) — FE KHÔNG gửi lines.
|
// · Panel 1 = danh sách (`KhkkWorkListPanel`) + nút hành-động dính đáy;
|
||||||
|
// · Panel 2 = EmptyState | form tạo | phiếu đang XEM.
|
||||||
|
// 🔴 Panel 1 chia 2 TẦNG theo đúng ảnh anh khoanh: trên = "Cột danh sách Duyệt NCC"
|
||||||
|
// (bấm ⇒ *tạo luôn KHKK*), dưới = "Cột KHKK đã tạo" (bấm ⇒ *chỉ xem*).
|
||||||
|
// 🔴 Bấm gì cũng Ở LẠI MÀN — anh chốt "click vào phiếu nào thì cho hiện phiếu đó luôn, chỉ
|
||||||
|
// view là đc, ko re-direct về phiếu gốc". Vì vậy panel nhận CALLBACK, không tự `navigate`.
|
||||||
|
// 🧊 Bản S174 đầu gắn 2 tầng này thành cột 3 của màn *Duyệt NCC* — anh bác ("chỗ duyệt NCC
|
||||||
|
// đã golive và đã tốt rồi"); `PurchaseEvaluationWorkspacePage` đã revert về byte `1b3bcaa`.
|
||||||
//
|
//
|
||||||
// [S174 owner] ĐỔI KHUÔN: bỏ `PageHeader` + 3 khối `.card-accent` rời, chuyển sang ĐÚNG
|
// [K2 S164 — OG-7] Rào là "1 PE = 1 kế hoạch MỖI NHÓM" ⇒ PE đã dùng vài nhóm VẪN hiện, kèm
|
||||||
// hình "Tạo phiếu Duyệt NCC mới" (`components/pe/PeWorkspaceCreateView.tsx:152-352`) —
|
// nhóm đã dùng + nút "Tạo thêm cho nhóm khác". Ẩn đi thì người dùng không phân biệt được
|
||||||
// anh chốt "Tạo Header → tương tự NCC → sau khi Header tạo xong thì cho sang chi tiết":
|
// "PE không đủ điều kiện" với "PE đã dùng hết nhóm".
|
||||||
// · MỘT card `rounded-lg border bg-white shadow-sm`, các section ngăn bằng `divide-y`;
|
|
||||||
// · thanh đầu = tiêu-đề + 1 dòng kể LUỒNG (nhập gì → bấm gì → cái gì mở khoá) + "← Hủy";
|
|
||||||
// · thanh cuối = hint TRÁI liệt kê ĐÍCH DANH trường còn thiếu, nút PHẢI.
|
|
||||||
// 🔸 Vế "sang chi tiết" VỐN ĐÃ ĐÚNG từ S161 (`onSuccess` → `navigate('/khkk/:id')`) — đợt
|
|
||||||
// này KHÔNG đụng luồng đó, chỉ đổi hình. Ghi ra để người sau khỏi tưởng vừa thêm mới.
|
|
||||||
//
|
//
|
||||||
// [K2 S164 — OG-7 UI-hướng-1] Rào là "1 PE = 1 kế hoạch MỖI NHÓM" ⇒ PE vẫn hiện, kèm
|
// [K4b S166 — R-5] 8 nhóm dùng CHUNG một route ⇒ mọi thứ lấy từ URL phải ĐỌC LẠI MỖI RENDER.
|
||||||
// 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).
|
// Khối reset dưới là SET-STATE-TRONG-RENDER CÓ CHỦ ĐÍCH; refactor thành `useEffect` sẽ
|
||||||
// Ẩ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
|
// tái-hiện vá-5 (đi từ leaf nhóm 2 sang leaf nhóm 5 phải F5 mới đúng). ĐỪNG ĐỘNG.
|
||||||
// hết nhóm".
|
|
||||||
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 { CheckCircle2, Plus } from 'lucide-react'
|
import { FileCheck, Plus, X } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
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 { Select } from '@/components/ui/Select'
|
||||||
import { Textarea } from '@/components/ui/Textarea'
|
import { Textarea } from '@/components/ui/Textarea'
|
||||||
|
import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
|
||||||
|
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 { cn } from '@/lib/cn'
|
|
||||||
import {
|
import {
|
||||||
KHKK_APPROVAL_GROUPS,
|
KHKK_APPROVAL_GROUPS,
|
||||||
KHKK_APPROVAL_GROUP_DEFAULT,
|
KHKK_APPROVAL_GROUP_DEFAULT,
|
||||||
KHKK_APPROVAL_GROUP_LABELS,
|
KHKK_APPROVAL_GROUP_LABELS,
|
||||||
|
khkkGroupMenuLabel,
|
||||||
type CreateKhkkInput,
|
type CreateKhkkInput,
|
||||||
|
type KhkkDetailDto,
|
||||||
type KhkkPeCandidateDto,
|
type KhkkPeCandidateDto,
|
||||||
} from '@/types/khkk'
|
} from '@/types/khkk'
|
||||||
|
|
||||||
// ApplicableType của workflow V2 dành cho Kế hoạch ký kết (mirror BE
|
// ApplicableType của workflow V2 dành cho Kế hoạch ký kết (mirror BE
|
||||||
// `ApplicableType.ContractSigningPlan = 10`, Designer fe-admin đã mở từ W1).
|
// `ApplicableType.ContractSigningPlan = 10`).
|
||||||
const APPLICABLE_TYPE_KHKK = 10
|
const APPLICABLE_TYPE_KHKK = 10
|
||||||
|
|
||||||
interface WorkflowDto {
|
interface WorkflowDto {
|
||||||
@ -50,14 +55,8 @@ interface WorkflowDto {
|
|||||||
isUserSelectable: boolean
|
isUserSelectable: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
// 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
|
||||||
const d = new Date(iso)
|
// yên ở một nhóm chắc chắn 409.
|
||||||
return d.toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nhóm trống ĐẦU TIÊN của một PE (null = đã dùng hết 8 nhóm). Dùng cả cho nút
|
|
||||||
// "Tạo thêm cho nhóm khác" lẫn lúc bấm chọn PE — để ô chọn nhóm không đứng yên ở
|
|
||||||
// một nhóm chắc chắn 409.
|
|
||||||
function firstFreeGroup(taken: number[]): number | null {
|
function firstFreeGroup(taken: number[]): number | null {
|
||||||
return KHKK_APPROVAL_GROUPS.find((g) => !taken.includes(g)) ?? null
|
return KHKK_APPROVAL_GROUPS.find((g) => !taken.includes(g)) ?? null
|
||||||
}
|
}
|
||||||
@ -66,10 +65,6 @@ export function KhkkCreatePage() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
|
|
||||||
// [K4b S166 — vá-6] Vào từ leaf "Thao tác" của một nhóm (`/khkk/create?group={n}`)
|
|
||||||
// hoặc từ nút "Lập kế hoạch" trên màn danh sách đang lọc nhóm ⇒ ô chọn nhóm mở sẵn
|
|
||||||
// đúng nhóm đó. Không có tham số (vào thẳng /khkk/create) thì giữ nguyên default cũ.
|
|
||||||
// Chỉ nhận 1..8, rác coi như không có — ô chọn không được phép mang giá trị ngoài dải.
|
|
||||||
const groupParam = searchParams.get('group')
|
const groupParam = searchParams.get('group')
|
||||||
const presetGroup = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null
|
const presetGroup = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null
|
||||||
|
|
||||||
@ -77,29 +72,25 @@ export function KhkkCreatePage() {
|
|||||||
const [approvalWorkflowId, setApprovalWorkflowId] = useState('')
|
const [approvalWorkflowId, setApprovalWorkflowId] = useState('')
|
||||||
const [approvalGroup, setApprovalGroup] = useState<number>(presetGroup ?? KHKK_APPROVAL_GROUP_DEFAULT)
|
const [approvalGroup, setApprovalGroup] = useState<number>(presetGroup ?? KHKK_APPROVAL_GROUP_DEFAULT)
|
||||||
const [ghiChu, setGhiChu] = useState('')
|
const [ghiChu, setGhiChu] = useState('')
|
||||||
|
// [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')`.
|
||||||
|
const [viewPlanId, setViewPlanId] = useState<string | null>(null)
|
||||||
|
|
||||||
// Cùng lý do với `KhkkListPage` (vá-5): 8 leaf "Thao tác" dùng chung route
|
// Đổi leaf nhóm ⇒ đồng bộ ô chọn nhóm. CHỈ đổi khi CHÍNH THAM SỐ URL đổi — so với
|
||||||
// `/khkk/create`, nhảy leaf→leaf không dựng lại component nên giá trị khởi tạo ở
|
// `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.
|
||||||
// trên KHÔNG chạy lại. Đồng bộ lại ngay trong render khi tham số nhóm đổi.
|
|
||||||
// 🔴 Chỉ đổi khi CHÍNH THAM SỐ URL đổi — nếu 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, không bấm đi đâu được.
|
|
||||||
const [lastPresetGroup, setLastPresetGroup] = useState(presetGroup)
|
const [lastPresetGroup, setLastPresetGroup] = useState(presetGroup)
|
||||||
if (presetGroup !== lastPresetGroup) {
|
if (presetGroup !== lastPresetGroup) {
|
||||||
setLastPresetGroup(presetGroup)
|
setLastPresetGroup(presetGroup)
|
||||||
if (presetGroup !== null) setApprovalGroup(presetGroup)
|
if (presetGroup !== null) setApprovalGroup(presetGroup)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phiếu PE đủ điều kiện: Phase=DaDuyet VÀ có ≥1 NCC trúng thầu (BE lọc — khớp
|
// Cùng query key với `KhkkWorkListPanel` ⇒ dùng CHUNG cache, không gọi mạng 2 lần.
|
||||||
// ĐÚNG 2 rào còn lại của Create nên picker không mời bấm vào thứ chắc chắn 409).
|
|
||||||
// [K2 S164] Vị ngữ "chưa có kế hoạch sống" ĐÃ BỎ (`Features.cs:1291-1295`).
|
|
||||||
const candidates = useQuery({
|
const candidates = useQuery({
|
||||||
queryKey: ['khkk-pe-candidates'],
|
queryKey: ['khkk-pe-candidates'],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
(await api.get<KhkkPeCandidateDto[]>('/contract-signing-plans/approved-pe-awaiting-plan')).data,
|
(await api.get<KhkkPeCandidateDto[]>('/contract-signing-plans/approved-pe-awaiting-plan')).data,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Endpoint trả AwAdminOverviewDto { types: [{ applicableType, history: [...] }] } —
|
|
||||||
// KHÔNG phải flat array. Lọc isUserSelectable = 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 () => {
|
||||||
@ -112,6 +103,13 @@ export function KhkkCreatePage() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Phiếu đang XEM — chỉ tải khi thật sự có cái để xem.
|
||||||
|
const viewPlan = useQuery({
|
||||||
|
queryKey: ['khkk-detail', viewPlanId],
|
||||||
|
queryFn: async () => (await api.get<KhkkDetailDto>(`/contract-signing-plans/${viewPlanId}`)).data,
|
||||||
|
enabled: !!viewPlanId,
|
||||||
|
})
|
||||||
|
|
||||||
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')
|
||||||
@ -120,37 +118,27 @@ export function KhkkCreatePage() {
|
|||||||
peId: purchaseEvaluationId, // [F-2] BE Command field = PeId
|
peId: purchaseEvaluationId, // [F-2] BE Command field = PeId
|
||||||
approvalWorkflowId,
|
approvalWorkflowId,
|
||||||
ghiChu: ghiChu.trim() || null,
|
ghiChu: ghiChu.trim() || null,
|
||||||
// [K2 S164] Gửi TƯỜNG MINH (BE cho phép bỏ trống = nhóm 1, nhưng ô chọn đã
|
approvalGroup, // [K2] gửi TƯỜNG MINH, không dựa default ngầm của BE
|
||||||
// hiện số nào thì phiếu phải vào đúng số đó — không dựa default ngầm).
|
|
||||||
approvalGroup,
|
|
||||||
}
|
}
|
||||||
const res = await api.post<{ id: string; maKeHoach: string | null }>('/contract-signing-plans', body)
|
return (await api.post<{ id: string; maKeHoach: string | null }>('/contract-signing-plans', body)).data
|
||||||
return res.data
|
|
||||||
},
|
},
|
||||||
// 🔴 Vế "Header tạo xong thì sang chi tiết" nằm ĐÚNG ở đây — giữ nguyên từ S161.
|
// [S174] "Header tạo xong thì cho sang chi tiết" — nay KHÔNG rời màn nữa: mở luôn phiếu
|
||||||
|
// vừa tạo ở panel 2 (chỉ xem) và trả form về trắng. Cùng tinh thần "không re-direct".
|
||||||
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}`)
|
setPurchaseEvaluationId('')
|
||||||
|
setApprovalWorkflowId('')
|
||||||
|
setGhiChu('')
|
||||||
|
setViewPlanId(data.id)
|
||||||
},
|
},
|
||||||
onError: (e) => toast.error(getErrorMessage(e)),
|
onError: (e) => toast.error(getErrorMessage(e)),
|
||||||
})
|
})
|
||||||
|
|
||||||
const onSubmit = (e: FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
create.mutate()
|
|
||||||
}
|
|
||||||
|
|
||||||
const list = candidates.data ?? []
|
const list = candidates.data ?? []
|
||||||
|
|
||||||
// [K2 S164] Nhóm đã có kế hoạch SỐNG trên PE đang chọn — nguồn DUY NHẤT là DTO picker
|
|
||||||
// (`existingGroups`), FE không tự suy từ đâu khác. `?? []` giữ trang sống với payload
|
|
||||||
// cũ còn nằm trong cache React Query trước khi K2 deploy.
|
|
||||||
const selectedPe = list.find((p) => p.id === purchaseEvaluationId)
|
const selectedPe = list.find((p) => p.id === purchaseEvaluationId)
|
||||||
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 = [
|
const missingCreate = [
|
||||||
!purchaseEvaluationId && 'Phiếu Duyệt NCC nguồn',
|
!purchaseEvaluationId && 'Phiếu Duyệt NCC nguồn',
|
||||||
!approvalWorkflowId && 'Quy trình duyệt',
|
!approvalWorkflowId && 'Quy trình duyệt',
|
||||||
@ -158,221 +146,259 @@ export function KhkkCreatePage() {
|
|||||||
].filter(Boolean) as string[]
|
].filter(Boolean) as string[]
|
||||||
const canSubmit = missingCreate.length === 0 && !create.isPending
|
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 PE ở panel 1: nạp vào form + rời chế-độ xem. Nhóm đang chọn đã bị PE đó dùng thì
|
||||||
// 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ò.
|
// 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 pickPe = (peId: string) => {
|
||||||
setPurchaseEvaluationId(pe.id)
|
setPurchaseEvaluationId(peId)
|
||||||
const taken = pe.existingGroups ?? []
|
setViewPlanId(null)
|
||||||
|
const taken = list.find((p) => p.id === peId)?.existingGroups ?? []
|
||||||
if (taken.includes(approvalGroup)) {
|
if (taken.includes(approvalGroup)) {
|
||||||
const free = firstFreeGroup(taken)
|
const free = firstFreeGroup(taken)
|
||||||
if (free !== null) setApprovalGroup(free)
|
if (free !== null) setApprovalGroup(free)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const onSubmit = (e: FormEvent) => {
|
||||||
<form onSubmit={onSubmit} className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
e.preventDefault()
|
||||||
{/* Thanh đầu — mirror `PeWorkspaceCreateView.tsx:155-165` */}
|
create.mutate()
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-5 py-3">
|
}
|
||||||
<div>
|
|
||||||
<h2 className="text-base font-semibold text-slate-900">Lập kế hoạch ký kết HĐ mới</h2>
|
|
||||||
<p className="mt-0.5 text-[12px] text-slate-500">
|
|
||||||
Nhập Section 1 + 2 → bấm <strong>“Tạo kế hoạch”</strong> → sau đó dòng giá NCC / căn cứ hồ sơ / ý kiến mở khóa.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button type="button" variant="ghost" onClick={() => navigate('/khkk/list')} className="text-xs">
|
|
||||||
← Hủy
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="divide-y divide-slate-200">
|
const headerTitle = presetGroup !== null
|
||||||
<Section title="1. Phiếu Duyệt NCC nguồn *">
|
? `Kế hoạch ký kết HĐ — Thao tác · ${khkkGroupMenuLabel(presetGroup)}`
|
||||||
{candidates.isLoading && <div className="text-sm text-muted-foreground">Đang tải danh sách phiếu...</div>}
|
: 'Kế hoạch ký kết HĐ — Thao tác'
|
||||||
{!candidates.isLoading && list.length === 0 && (
|
|
||||||
<div className="rounded-lg border border-dashed border-slate-300 p-6 text-center text-sm text-muted-foreground">
|
return (
|
||||||
Không có phiếu Duyệt NCC nào đã duyệt và có NCC trúng thầu.
|
<div className="flex h-[calc(100vh-4rem)] flex-col">
|
||||||
|
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-slate-200 bg-white px-6 py-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<FileCheck className="h-5 w-5 text-slate-500" />
|
||||||
|
<h1 className="truncate text-base font-semibold tracking-tight text-slate-900">{headerTitle}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="text-[12px] text-slate-500">
|
||||||
|
Bấm phiếu ở cột trái — tầng trên lập kế hoạch, tầng dưới chỉ xem.
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[300px_1fr] xl:grid-cols-[340px_1fr]">
|
||||||
|
<KhkkWorkListPanel
|
||||||
|
onPickPe={pickPe}
|
||||||
|
onPickPlan={setViewPlanId}
|
||||||
|
selectedPeId={purchaseEvaluationId || null}
|
||||||
|
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">
|
||||||
|
{/* (a) Đang XEM một kế hoạch — read-only, có đường quay về form tạo. */}
|
||||||
|
{viewPlanId && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-violet-200 bg-violet-50 px-4 py-2">
|
||||||
|
<span className="text-[12px] font-medium text-violet-800">
|
||||||
|
Đang xem kế hoạch — chỉ xem, không sửa ở màn Thao tác.
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => navigate(`/khkk/${viewPlanId}`)}>
|
||||||
|
Mở trang đầy đủ
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setViewPlanId(null)}>
|
||||||
|
<X className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Về form tạo
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{viewPlan.isLoading && <div className="text-sm text-slate-500">Đang tải…</div>}
|
||||||
|
{!viewPlan.isLoading && !viewPlan.data && (
|
||||||
|
<div className="text-sm text-red-600">Không tìm thấy phiếu.</div>
|
||||||
|
)}
|
||||||
|
{viewPlan.data && (
|
||||||
|
<KhkkDetailContent plan={viewPlan.data} readOnly onChanged={() => viewPlan.refetch()} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="max-h-96 space-y-2 overflow-y-auto">
|
|
||||||
{list.map((pe) => {
|
|
||||||
const selected = pe.id === purchaseEvaluationId
|
|
||||||
// [K2 S164 — OG-7] `?? []` = phòng payload cũ trong cache (trước K2 DTO
|
|
||||||
// không có field này) — thiếu guard thì `.length` nổ trắng trang.
|
|
||||||
const taken = pe.existingGroups ?? []
|
|
||||||
const free = firstFreeGroup(taken)
|
|
||||||
return (
|
|
||||||
<div key={pe.id} className="space-y-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => selectPe(pe)}
|
|
||||||
className={cn(
|
|
||||||
'flex w-full items-start gap-3 rounded-lg border p-3 text-left transition',
|
|
||||||
selected
|
|
||||||
? 'border-brand-400 bg-brand-50/60 ring-1 ring-brand-300'
|
|
||||||
: 'border-slate-200 hover:border-brand-300 hover:bg-slate-50',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CheckCircle2
|
|
||||||
className={cn('mt-0.5 h-4 w-4 shrink-0', selected ? 'text-brand-600' : 'text-slate-300')}
|
|
||||||
/>
|
|
||||||
<span className="min-w-0 flex-1">
|
|
||||||
<span className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="font-mono text-xs text-brand-700">{pe.maPhieu ?? '—'}</span>
|
|
||||||
<span className="font-medium text-brand-800">{pe.tenGoiThau}</span>
|
|
||||||
</span>
|
|
||||||
<span className="mt-0.5 block text-xs text-slate-500">
|
|
||||||
{pe.projectName ?? '—'}
|
|
||||||
{pe.workItemName ? ` · ${pe.workItemName}` : ''} · {formatDate(pe.createdAt)}
|
|
||||||
</span>
|
|
||||||
<span className="mt-1 flex flex-wrap gap-1">
|
|
||||||
{pe.winnerSupplierNames.length === 0 ? (
|
|
||||||
<span className="text-xs italic text-slate-400">Chưa có NCC trúng thầu</span>
|
|
||||||
) : (
|
|
||||||
pe.winnerSupplierNames.map((n) => (
|
|
||||||
<span
|
|
||||||
key={n}
|
|
||||||
className="inline-flex items-center rounded-md border border-emerald-300 bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-800"
|
|
||||||
>
|
|
||||||
{n}
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* [K2 S164 — OG-7] PE đã có kế hoạch: KHÔNG ẩn dòng nữa, hiện nhóm
|
{/* (b) Chưa chọn gì — nói rõ phải bấm ở đâu, thay vì để trống một mảng trắng. */}
|
||||||
đã dùng + lối đi tiếp. Nút nằm NGOÀI <button> ở trên (button lồng
|
{!viewPlanId && !purchaseEvaluationId && (
|
||||||
button là HTML không hợp lệ). */}
|
<EmptyState
|
||||||
{taken.length > 0 && (
|
icon={FileCheck}
|
||||||
<div className="flex flex-wrap items-center gap-2 pl-8 text-xs">
|
title="Chọn phiếu Duyệt NCC để lập kế hoạch"
|
||||||
<span className="text-slate-500">Đã có kế hoạch nhóm:</span>
|
description="Bấm một phiếu ở tầng trên cột trái để lập kế hoạch ký kết, hoặc bấm một kế hoạch ở tầng dưới để xem."
|
||||||
{taken.map((g) => (
|
/>
|
||||||
<span
|
)}
|
||||||
key={g}
|
|
||||||
className="inline-flex items-center rounded-md border border-amber-300 bg-amber-100 px-1.5 py-0.5 text-[11px] font-semibold text-amber-800"
|
{/* (c) Đã chọn PE — form tạo. */}
|
||||||
title={KHKK_APPROVAL_GROUP_LABELS[g] ?? `Nhóm ${g}`}
|
{!viewPlanId && purchaseEvaluationId && (
|
||||||
>
|
<form onSubmit={onSubmit} className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||||
N{g}
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-5 py-3">
|
||||||
</span>
|
<div>
|
||||||
))}
|
<h2 className="text-base font-semibold text-slate-900">Lập kế hoạch ký kết HĐ mới</h2>
|
||||||
{free === null ? (
|
<p className="mt-0.5 text-[12px] text-slate-500">
|
||||||
<span className="italic text-slate-400">Đã dùng hết 8 nhóm duyệt</span>
|
Kiểm Section 1 + 2 → bấm <strong>“Tạo kế hoạch”</strong> → sau đó dòng giá NCC / căn cứ hồ sơ / ý kiến mở khóa.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-slate-200">
|
||||||
|
<Section title="1. Phiếu Duyệt NCC nguồn *">
|
||||||
|
{/* Danh sách chọn nằm ở PANEL 1 — ở đây chỉ XÁC NHẬN cái đang chọn, để
|
||||||
|
không có hai chỗ chọn cùng một thứ (2 nguồn sự thật trên cùng màn). */}
|
||||||
|
<div className="rounded-lg border border-brand-300 bg-brand-50/60 p-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-mono text-xs text-brand-700">{selectedPe?.maPhieu ?? '—'}</span>
|
||||||
|
<span className="font-medium text-brand-800">{selectedPe?.tenGoiThau ?? '(đang tải…)'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-xs text-slate-500">
|
||||||
|
{selectedPe?.projectName ?? '—'}
|
||||||
|
{selectedPe?.workItemName ? ` · ${selectedPe.workItemName}` : ''}
|
||||||
|
</div>
|
||||||
|
{(selectedPe?.winnerSupplierNames?.length ?? 0) > 0 && (
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{selectedPe!.winnerSupplierNames.map((n) => (
|
||||||
|
<span
|
||||||
|
key={n}
|
||||||
|
className="inline-flex items-center rounded-md border border-emerald-300 bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-800"
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{takenGroups.length > 0 && (
|
||||||
|
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-xs">
|
||||||
|
<span className="text-slate-500">Đã có kế hoạch nhóm:</span>
|
||||||
|
{takenGroups.map((g) => (
|
||||||
|
<span
|
||||||
|
key={g}
|
||||||
|
className="inline-flex items-center rounded-md border border-amber-300 bg-amber-100 px-1.5 py-0.5 text-[11px] font-semibold text-amber-800"
|
||||||
|
title={KHKK_APPROVAL_GROUP_LABELS[g] ?? `Nhóm ${g}`}
|
||||||
|
>
|
||||||
|
N{g}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="2. Nhóm duyệt & quy trình">
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="approvalGroup" className="text-[11px]">Nhóm duyệt (N1–N8) *</Label>
|
||||||
|
<Select
|
||||||
|
id="approvalGroup"
|
||||||
|
value={approvalGroup}
|
||||||
|
onChange={(e) => setApprovalGroup(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
{KHKK_APPROVAL_GROUPS.map((g) => {
|
||||||
|
const used = takenGroups.includes(g)
|
||||||
|
return (
|
||||||
|
<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 ? (
|
||||||
|
<p className="mt-1 text-[11px] font-medium text-red-600">
|
||||||
|
Phiếu này đã có kế hoạch cho nhóm N{approvalGroup} — chọn nhóm khác.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => selectPe(pe)}>
|
<p className="mt-1 text-[11px] text-slate-500">
|
||||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
Mỗi phiếu Duyệt NCC lập được 1 kế hoạch cho MỖI nhóm duyệt
|
||||||
Tạo thêm cho nhóm khác
|
</p>
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="workflow" className="text-[11px]">Quy trình duyệt *</Label>
|
||||||
|
<Select
|
||||||
|
id="workflow"
|
||||||
|
value={approvalWorkflowId}
|
||||||
|
onChange={(e) => setApprovalWorkflowId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">— Chọn quy trình duyệt —</option>
|
||||||
|
{workflows.data?.map((w) => (
|
||||||
|
<option key={w.id} value={w.id}>{w.code} - {w.name}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
{workflows.data && workflows.data.length === 0 ? (
|
||||||
|
<p className="mt-1 text-[11px] text-amber-700">
|
||||||
|
⚠ Chưa có 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 admin đã ghim cho user chọn
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="3. Ghi chú">
|
||||||
|
<Label htmlFor="ghiChu" className="text-[11px]">Ghi chú kế hoạch</Label>
|
||||||
|
<Textarea
|
||||||
|
id="ghiChu"
|
||||||
|
value={ghiChu}
|
||||||
|
onChange={(e) => setGhiChu(e.target.value)}
|
||||||
|
maxLength={2000}
|
||||||
|
rows={4}
|
||||||
|
placeholder="Lưu ý về tiến độ ký kết, điều kiện thương thảo..."
|
||||||
|
/>
|
||||||
|
<div className="mt-1 text-[11px] text-slate-500">{ghiChu.length}/2000</div>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hint TRÁI kể rõ còn thiếu gì, nút PHẢI. `groupTaken` chặn ở FE lượt bấm chắc
|
||||||
|
chắn ăn 409 của BE (`Features.cs:395-402`) — BE vẫn là rào thật. */}
|
||||||
|
<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 className="font-medium text-emerald-600">✓ Đủ thông tin bắt buộc — có thể tạo kế hoạch</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
})}
|
<Button
|
||||||
</div>
|
type="button"
|
||||||
</Section>
|
variant="ghost"
|
||||||
|
className="text-xs"
|
||||||
<Section title="2. Nhóm duyệt & quy trình">
|
onClick={() => { setPurchaseEvaluationId(''); setApprovalWorkflowId(''); setGhiChu('') }}
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
>
|
||||||
{/* [K2 S164 — vá-4] Nhóm duyệt N1..N8. Mặc định nhóm 1 (UI-hướng-1: ca HẸP,
|
Hủy
|
||||||
user cần nhóm khác thì đổi ngay tại đây). Nhóm PE đã dùng bị disable —
|
</Button>
|
||||||
rào cứng 409 vẫn ở BE, ô này chỉ để không mời bấm nhầm. */}
|
<Button type="submit" disabled={!canSubmit}>
|
||||||
<div>
|
{create.isPending ? 'Đang tạo…' : 'Tạo kế hoạch'}
|
||||||
<Label htmlFor="approvalGroup" className="text-[11px]">Nhóm duyệt (N1–N8) *</Label>
|
</Button>
|
||||||
<Select
|
</div>
|
||||||
id="approvalGroup"
|
</div>
|
||||||
value={approvalGroup}
|
</form>
|
||||||
onChange={(e) => setApprovalGroup(Number(e.target.value))}
|
|
||||||
>
|
|
||||||
{KHKK_APPROVAL_GROUPS.map((g) => {
|
|
||||||
const used = takenGroups.includes(g)
|
|
||||||
return (
|
|
||||||
<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 ? (
|
|
||||||
<p className="mt-1 text-[11px] font-medium text-red-600">
|
|
||||||
Phiếu Duyệt NCC này đã có kế hoạch ký kết cho nhóm N{approvalGroup} — chọn nhóm khác.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<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
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="workflow" className="text-[11px]">Quy trình duyệt *</Label>
|
|
||||||
<Select
|
|
||||||
id="workflow"
|
|
||||||
value={approvalWorkflowId}
|
|
||||||
onChange={(e) => setApprovalWorkflowId(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">— Chọn quy trình duyệt —</option>
|
|
||||||
{workflows.data?.map((w) => (
|
|
||||||
<option key={w.id} value={w.id}>
|
|
||||||
{w.code} - {w.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
{workflows.data && workflows.data.length === 0 ? (
|
|
||||||
<p className="mt-1 text-[11px] text-amber-700">
|
|
||||||
⚠ Chưa có quy trình duyệt "Kế hoạch ký kết HĐ" nào được ghim cho user chọn. Liên hệ admin tạo trước.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="mt-1 text-[11px] text-slate-500">
|
|
||||||
Chỉ hiển thị quy trình loại "Kế hoạch ký kết HĐ" admin đã ghim cho user chọn
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="3. Ghi chú">
|
|
||||||
<Label htmlFor="ghiChu" className="text-[11px]">Ghi chú kế hoạch</Label>
|
|
||||||
<Textarea
|
|
||||||
id="ghiChu"
|
|
||||||
value={ghiChu}
|
|
||||||
onChange={(e) => setGhiChu(e.target.value)}
|
|
||||||
maxLength={2000}
|
|
||||||
rows={4}
|
|
||||||
placeholder="Lưu ý về tiến độ ký kết, điều kiện thương thảo..."
|
|
||||||
/>
|
|
||||||
<div className="mt-1 text-[11px] text-slate-500">{ghiChu.length}/2000</div>
|
|
||||||
</Section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Thanh cuối — hint TRÁI kể rõ còn thiếu gì, nút PHẢI (mirror `:318-337`).
|
|
||||||
`groupTaken` chặn ngay ở FE lượt bấm chắc chắn ăn 409 của BE
|
|
||||||
(`Features.cs:395-402`) — vẫn để BE là rào thật, đây chỉ đỡ 1 vòng mạng. */}
|
|
||||||
<div className="flex items-center justify-between gap-3 border-t border-slate-200 bg-slate-50 px-5 py-3">
|
|
||||||
<div className="min-w-0 text-[11px]">
|
|
||||||
{missingCreate.length > 0 ? (
|
|
||||||
<span className="text-red-600">
|
|
||||||
⚠ Chưa đủ để tạo kế hoạch — còn thiếu: <strong>{missingCreate.join(' · ')}</strong>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="font-medium text-emerald-600">✓ Đủ thông tin bắt buộc — có thể tạo kế hoạch</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</main>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
|
||||||
<Button type="button" variant="ghost" onClick={() => navigate('/khkk/list')} className="text-xs">
|
|
||||||
Hủy
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={!canSubmit}>
|
|
||||||
{create.isPending ? 'Đang tạo…' : 'Tạo kế hoạch'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper — duplicate từ `PeWorkspaceCreateView` để tránh circular import (cùng lý do
|
// Helper — duplicate từ `PeWorkspaceCreateView` để tránh circular import (cùng lý do vì sao
|
||||||
// vì sao bên đó cũng tự dựng lại thay vì import từ `PeDetailTabs`).
|
// bên đó cũng tự dựng lại thay vì import từ `PeDetailTabs`).
|
||||||
function Section({ title, children }: { title: string; children: ReactNode }) {
|
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<section className="px-5 py-4">
|
<section className="px-5 py-4">
|
||||||
|
|||||||
@ -15,7 +15,6 @@ 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'
|
||||||
@ -78,10 +77,7 @@ export function PurchaseEvaluationWorkspacePage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* [S174 owner] Cột 3 (`PeKhkkSidePanel`, 2 tầng 50/50) CHỈ mọc ở `xl` — thêm nó vào
|
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[260px_1fr] xl:grid-cols-[320px_1fr]">
|
||||||
`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. */}
|
||||||
@ -134,10 +130,6 @@ 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 */}
|
||||||
|
|||||||
@ -1,9 +1,15 @@
|
|||||||
// [S174 owner] Cột PHẢI của màn "Duyệt NCC — Thao tác" — 1 cột, 2 TẦNG 50/50:
|
// [S174 owner] PANEL 1 (danh sách) của màn "Kế hoạch ký kết HĐ — Thao tác" — 1 cột, 2 TẦNG:
|
||||||
// · 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,
|
// · 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";
|
// gom theo DỰ ÁN — "việc còn phải làm"; bấm = nạp thẳng vào form tạo;
|
||||||
// · tầng DƯỚI = kế hoạch ký kết ĐÃ TẠO — đây là "việc đã làm rồi".
|
// · tầng DƯỚI = kế hoạch ký kết ĐÃ TẠO — "việc đã làm rồi"; bấm = XEM tại chỗ (read-only).
|
||||||
// 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
|
// File MIRROR SHA256 identical với fe-user counterpart.
|
||||||
// là KHKK". File MIRROR SHA256 identical với fe-user counterpart.
|
//
|
||||||
|
// 🔴 LỊCH SỬ ĐẶT SAI CHỖ — ghi để không tái phạm: bản đầu S174 gắn panel này thành cột 3 của
|
||||||
|
// màn *Duyệt NCC — Thao tác* (`PurchaseEvaluationWorkspacePage`). Anh bác: **"chỗ duyệt NCC
|
||||||
|
// đã golive và đã tốt rồi"** — ý anh từ đầu là làm màn Thao tác của KHKK **GIỐNG GIAO DIỆN**
|
||||||
|
// Thao tác của Duyệt NCC, chứ không phải chèn thêm thứ vào màn Duyệt NCC. Đã revert
|
||||||
|
// `PurchaseEvaluationWorkspacePage` về đúng byte của `1b3bcaa`. Bài: "làm giống màn X" ≠
|
||||||
|
// "sửa màn X".
|
||||||
//
|
//
|
||||||
// 🔴 0 ENDPOINT MỚI — dùng lại đúng 2 cửa đã sống:
|
// 🔴 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`
|
// · `GET /contract-signing-plans/approved-pe-awaiting-plan` (chính cửa `KhkkCreatePage`
|
||||||
@ -18,7 +24,6 @@
|
|||||||
// 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
|
// 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ì".
|
// 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 { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
import { FileCheck, FileSignature, Lock } from 'lucide-react'
|
import { FileCheck, FileSignature, Lock } from 'lucide-react'
|
||||||
import { usePermission } from '@/hooks/usePermission'
|
import { usePermission } from '@/hooks/usePermission'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
@ -68,8 +73,23 @@ function Tier({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PeKhkkSidePanel() {
|
export function KhkkWorkListPanel({
|
||||||
const navigate = useNavigate()
|
onPickPe,
|
||||||
|
onPickPlan,
|
||||||
|
selectedPeId,
|
||||||
|
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). */
|
||||||
|
onPickPe: (peId: string) => void
|
||||||
|
/** Bấm KHKK ở tầng dưới → host mở phiếu đó ở panel 2, CHỈ XEM. Anh chốt: "click vào phiếu
|
||||||
|
* nào thì cho hiện phiếu đó luôn, chỉ view là đc, ko re-direct về phiếu gốc". */
|
||||||
|
onPickPlan: (planId: string) => void
|
||||||
|
selectedPeId?: 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ị).
|
||||||
const canKhkk = can('KeHoachKyKet', 'Read')
|
const canKhkk = can('KeHoachKyKet', 'Read')
|
||||||
@ -109,7 +129,7 @@ export function PeKhkkSidePanel() {
|
|||||||
const planItems = plans.data?.items ?? []
|
const planItems = plans.data?.items ?? []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="hidden min-h-0 flex-col overflow-hidden border-l border-slate-200 bg-white xl:flex">
|
<aside className="flex min-h-0 flex-col overflow-hidden border-r border-slate-200 bg-white">
|
||||||
{!canKhkk ? (
|
{!canKhkk ? (
|
||||||
// Cột trắng đọc thành "chưa có gì" — phải nói ĐÚNG lý do là thiếu quyền.
|
// 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">
|
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-4 text-center">
|
||||||
@ -158,11 +178,14 @@ export function PeKhkkSidePanel() {
|
|||||||
<li key={pe.id}>
|
<li key={pe.id}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
// Bấm = đi thẳng tới màn lập kế hoạch. Không preset `?group=` vì
|
// Bấm = nạp THẲNG phiếu này vào form tạo bên panel 2. Không điều hướng,
|
||||||
// PE này chưa dùng nhóm nào ⇒ để màn tạo giữ default của nó.
|
// không mở lại màn khác — đúng "click vào cột này để tạo luôn KHKK".
|
||||||
onClick={() => navigate('/khkk/create')}
|
onClick={() => onPickPe(pe.id)}
|
||||||
title="Lập kế hoạch ký kết cho phiếu này"
|
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"
|
className={cn(
|
||||||
|
'block w-full px-3 py-2 text-left transition hover:bg-amber-50/60',
|
||||||
|
selectedPeId === pe.id && 'bg-amber-50 ring-1 ring-inset ring-amber-300',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="font-mono text-[10px] text-brand-700">{pe.maPhieu ?? '—'}</span>
|
<span className="font-mono text-[10px] text-brand-700">{pe.maPhieu ?? '—'}</span>
|
||||||
@ -212,9 +235,14 @@ export function PeKhkkSidePanel() {
|
|||||||
<li key={k.id}>
|
<li key={k.id}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate(`/khkk/${k.id}`)}
|
// 🔴 CHỈ XEM tại chỗ — KHÔNG `navigate('/khkk/:id')`. Anh chốt: "cho hiện
|
||||||
title="Mở kế hoạch ký kết"
|
// phiếu đó luôn, chỉ view là đc, ko re-direct về phiếu gốc".
|
||||||
className="block w-full px-3 py-2 text-left transition hover:bg-violet-50/60"
|
onClick={() => onPickPlan(k.id)}
|
||||||
|
title="Xem kế hoạch ký kết này (chỉ xem)"
|
||||||
|
className={cn(
|
||||||
|
'block w-full px-3 py-2 text-left transition hover:bg-violet-50/60',
|
||||||
|
selectedPlanId === k.id && 'bg-violet-50 ring-1 ring-inset ring-violet-300',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5">
|
<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>
|
<span className="font-mono text-[10px] text-violet-700">{k.maKeHoach ?? '(chưa cấp mã)'}</span>
|
||||||
@ -247,6 +275,11 @@ export function PeKhkkSidePanel() {
|
|||||||
</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
|
||||||
|
`PeListPanel` (màn Duyệt NCC — Thao tác). Hiện KỂ CẢ khi thiếu quyền KHKK: cột
|
||||||
|
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. */}
|
||||||
|
{footer && <div className="shrink-0 border-t border-slate-200 p-2">{footer}</div>}
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -1,46 +1,51 @@
|
|||||||
// [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] Màn "Kế hoạch ký kết HĐ — Thao tác" (route `/khkk/create`, 8 leaf nhóm).
|
||||||
// File MIRROR SHA256 identical với fe-user counterpart.
|
// File MIRROR SHA256 identical với fe-user counterpart.
|
||||||
//
|
//
|
||||||
// 3 section: (1) chọn phiếu PE nguồn — phiếu DaDuyet có NCC trúng thầu (BE lọc, FE
|
// [S174 owner] ĐỔI TỪ TRANG-GIẤY-CUỘN SANG HOST 2-PANEL — anh chốt: **"làm giống giao diện
|
||||||
// không tự đoán); (2) NHÓM DUYỆT N1..N8 + quy trình duyệt V2 ApplicableType=10 admin
|
// thao tác của Duyệt NCC"** (`pages/pe/PurchaseEvaluationWorkspacePage.tsx`). Khuôn bê về:
|
||||||
// đã ghim; (3) ghi chú. Dòng giá per-NCC do BE tự sinh từ winner của phiếu PE
|
// · header 1 dòng + grid `lg:grid-cols-[300px_1fr]`;
|
||||||
// (snapshot `PeReferenceAmount`) — FE KHÔNG gửi lines.
|
// · Panel 1 = danh sách (`KhkkWorkListPanel`) + nút hành-động dính đáy;
|
||||||
|
// · Panel 2 = EmptyState | form tạo | phiếu đang XEM.
|
||||||
|
// 🔴 Panel 1 chia 2 TẦNG theo đúng ảnh anh khoanh: trên = "Cột danh sách Duyệt NCC"
|
||||||
|
// (bấm ⇒ *tạo luôn KHKK*), dưới = "Cột KHKK đã tạo" (bấm ⇒ *chỉ xem*).
|
||||||
|
// 🔴 Bấm gì cũng Ở LẠI MÀN — anh chốt "click vào phiếu nào thì cho hiện phiếu đó luôn, chỉ
|
||||||
|
// view là đc, ko re-direct về phiếu gốc". Vì vậy panel nhận CALLBACK, không tự `navigate`.
|
||||||
|
// 🧊 Bản S174 đầu gắn 2 tầng này thành cột 3 của màn *Duyệt NCC* — anh bác ("chỗ duyệt NCC
|
||||||
|
// đã golive và đã tốt rồi"); `PurchaseEvaluationWorkspacePage` đã revert về byte `1b3bcaa`.
|
||||||
//
|
//
|
||||||
// [S174 owner] ĐỔI KHUÔN: bỏ `PageHeader` + 3 khối `.card-accent` rời, chuyển sang ĐÚNG
|
// [K2 S164 — OG-7] Rào là "1 PE = 1 kế hoạch MỖI NHÓM" ⇒ PE đã dùng vài nhóm VẪN hiện, kèm
|
||||||
// hình "Tạo phiếu Duyệt NCC mới" (`components/pe/PeWorkspaceCreateView.tsx:152-352`) —
|
// nhóm đã dùng + nút "Tạo thêm cho nhóm khác". Ẩn đi thì người dùng không phân biệt được
|
||||||
// anh chốt "Tạo Header → tương tự NCC → sau khi Header tạo xong thì cho sang chi tiết":
|
// "PE không đủ điều kiện" với "PE đã dùng hết nhóm".
|
||||||
// · MỘT card `rounded-lg border bg-white shadow-sm`, các section ngăn bằng `divide-y`;
|
|
||||||
// · thanh đầu = tiêu-đề + 1 dòng kể LUỒNG (nhập gì → bấm gì → cái gì mở khoá) + "← Hủy";
|
|
||||||
// · thanh cuối = hint TRÁI liệt kê ĐÍCH DANH trường còn thiếu, nút PHẢI.
|
|
||||||
// 🔸 Vế "sang chi tiết" VỐN ĐÃ ĐÚNG từ S161 (`onSuccess` → `navigate('/khkk/:id')`) — đợt
|
|
||||||
// này KHÔNG đụng luồng đó, chỉ đổi hình. Ghi ra để người sau khỏi tưởng vừa thêm mới.
|
|
||||||
//
|
//
|
||||||
// [K2 S164 — OG-7 UI-hướng-1] Rào là "1 PE = 1 kế hoạch MỖI NHÓM" ⇒ PE vẫn hiện, kèm
|
// [K4b S166 — R-5] 8 nhóm dùng CHUNG một route ⇒ mọi thứ lấy từ URL phải ĐỌC LẠI MỖI RENDER.
|
||||||
// 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).
|
// Khối reset dưới là SET-STATE-TRONG-RENDER CÓ CHỦ ĐÍCH; refactor thành `useEffect` sẽ
|
||||||
// Ẩ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
|
// tái-hiện vá-5 (đi từ leaf nhóm 2 sang leaf nhóm 5 phải F5 mới đúng). ĐỪNG ĐỘNG.
|
||||||
// hết nhóm".
|
|
||||||
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 { CheckCircle2, Plus } from 'lucide-react'
|
import { FileCheck, Plus, X } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
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 { Select } from '@/components/ui/Select'
|
||||||
import { Textarea } from '@/components/ui/Textarea'
|
import { Textarea } from '@/components/ui/Textarea'
|
||||||
|
import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
|
||||||
|
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 { cn } from '@/lib/cn'
|
|
||||||
import {
|
import {
|
||||||
KHKK_APPROVAL_GROUPS,
|
KHKK_APPROVAL_GROUPS,
|
||||||
KHKK_APPROVAL_GROUP_DEFAULT,
|
KHKK_APPROVAL_GROUP_DEFAULT,
|
||||||
KHKK_APPROVAL_GROUP_LABELS,
|
KHKK_APPROVAL_GROUP_LABELS,
|
||||||
|
khkkGroupMenuLabel,
|
||||||
type CreateKhkkInput,
|
type CreateKhkkInput,
|
||||||
|
type KhkkDetailDto,
|
||||||
type KhkkPeCandidateDto,
|
type KhkkPeCandidateDto,
|
||||||
} from '@/types/khkk'
|
} from '@/types/khkk'
|
||||||
|
|
||||||
// ApplicableType của workflow V2 dành cho Kế hoạch ký kết (mirror BE
|
// ApplicableType của workflow V2 dành cho Kế hoạch ký kết (mirror BE
|
||||||
// `ApplicableType.ContractSigningPlan = 10`, Designer fe-admin đã mở từ W1).
|
// `ApplicableType.ContractSigningPlan = 10`).
|
||||||
const APPLICABLE_TYPE_KHKK = 10
|
const APPLICABLE_TYPE_KHKK = 10
|
||||||
|
|
||||||
interface WorkflowDto {
|
interface WorkflowDto {
|
||||||
@ -50,14 +55,8 @@ interface WorkflowDto {
|
|||||||
isUserSelectable: boolean
|
isUserSelectable: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
// 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
|
||||||
const d = new Date(iso)
|
// yên ở một nhóm chắc chắn 409.
|
||||||
return d.toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nhóm trống ĐẦU TIÊN của một PE (null = đã dùng hết 8 nhóm). Dùng cả cho nút
|
|
||||||
// "Tạo thêm cho nhóm khác" lẫn lúc bấm chọn PE — để ô chọn nhóm không đứng yên ở
|
|
||||||
// một nhóm chắc chắn 409.
|
|
||||||
function firstFreeGroup(taken: number[]): number | null {
|
function firstFreeGroup(taken: number[]): number | null {
|
||||||
return KHKK_APPROVAL_GROUPS.find((g) => !taken.includes(g)) ?? null
|
return KHKK_APPROVAL_GROUPS.find((g) => !taken.includes(g)) ?? null
|
||||||
}
|
}
|
||||||
@ -66,10 +65,6 @@ export function KhkkCreatePage() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
|
|
||||||
// [K4b S166 — vá-6] Vào từ leaf "Thao tác" của một nhóm (`/khkk/create?group={n}`)
|
|
||||||
// hoặc từ nút "Lập kế hoạch" trên màn danh sách đang lọc nhóm ⇒ ô chọn nhóm mở sẵn
|
|
||||||
// đúng nhóm đó. Không có tham số (vào thẳng /khkk/create) thì giữ nguyên default cũ.
|
|
||||||
// Chỉ nhận 1..8, rác coi như không có — ô chọn không được phép mang giá trị ngoài dải.
|
|
||||||
const groupParam = searchParams.get('group')
|
const groupParam = searchParams.get('group')
|
||||||
const presetGroup = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null
|
const presetGroup = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null
|
||||||
|
|
||||||
@ -77,29 +72,25 @@ export function KhkkCreatePage() {
|
|||||||
const [approvalWorkflowId, setApprovalWorkflowId] = useState('')
|
const [approvalWorkflowId, setApprovalWorkflowId] = useState('')
|
||||||
const [approvalGroup, setApprovalGroup] = useState<number>(presetGroup ?? KHKK_APPROVAL_GROUP_DEFAULT)
|
const [approvalGroup, setApprovalGroup] = useState<number>(presetGroup ?? KHKK_APPROVAL_GROUP_DEFAULT)
|
||||||
const [ghiChu, setGhiChu] = useState('')
|
const [ghiChu, setGhiChu] = useState('')
|
||||||
|
// [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')`.
|
||||||
|
const [viewPlanId, setViewPlanId] = useState<string | null>(null)
|
||||||
|
|
||||||
// Cùng lý do với `KhkkListPage` (vá-5): 8 leaf "Thao tác" dùng chung route
|
// Đổi leaf nhóm ⇒ đồng bộ ô chọn nhóm. CHỈ đổi khi CHÍNH THAM SỐ URL đổi — so với
|
||||||
// `/khkk/create`, nhảy leaf→leaf không dựng lại component nên giá trị khởi tạo ở
|
// `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.
|
||||||
// trên KHÔNG chạy lại. Đồng bộ lại ngay trong render khi tham số nhóm đổi.
|
|
||||||
// 🔴 Chỉ đổi khi CHÍNH THAM SỐ URL đổi — nếu 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, không bấm đi đâu được.
|
|
||||||
const [lastPresetGroup, setLastPresetGroup] = useState(presetGroup)
|
const [lastPresetGroup, setLastPresetGroup] = useState(presetGroup)
|
||||||
if (presetGroup !== lastPresetGroup) {
|
if (presetGroup !== lastPresetGroup) {
|
||||||
setLastPresetGroup(presetGroup)
|
setLastPresetGroup(presetGroup)
|
||||||
if (presetGroup !== null) setApprovalGroup(presetGroup)
|
if (presetGroup !== null) setApprovalGroup(presetGroup)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phiếu PE đủ điều kiện: Phase=DaDuyet VÀ có ≥1 NCC trúng thầu (BE lọc — khớp
|
// Cùng query key với `KhkkWorkListPanel` ⇒ dùng CHUNG cache, không gọi mạng 2 lần.
|
||||||
// ĐÚNG 2 rào còn lại của Create nên picker không mời bấm vào thứ chắc chắn 409).
|
|
||||||
// [K2 S164] Vị ngữ "chưa có kế hoạch sống" ĐÃ BỎ (`Features.cs:1291-1295`).
|
|
||||||
const candidates = useQuery({
|
const candidates = useQuery({
|
||||||
queryKey: ['khkk-pe-candidates'],
|
queryKey: ['khkk-pe-candidates'],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
(await api.get<KhkkPeCandidateDto[]>('/contract-signing-plans/approved-pe-awaiting-plan')).data,
|
(await api.get<KhkkPeCandidateDto[]>('/contract-signing-plans/approved-pe-awaiting-plan')).data,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Endpoint trả AwAdminOverviewDto { types: [{ applicableType, history: [...] }] } —
|
|
||||||
// KHÔNG phải flat array. Lọc isUserSelectable = 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 () => {
|
||||||
@ -112,6 +103,13 @@ export function KhkkCreatePage() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Phiếu đang XEM — chỉ tải khi thật sự có cái để xem.
|
||||||
|
const viewPlan = useQuery({
|
||||||
|
queryKey: ['khkk-detail', viewPlanId],
|
||||||
|
queryFn: async () => (await api.get<KhkkDetailDto>(`/contract-signing-plans/${viewPlanId}`)).data,
|
||||||
|
enabled: !!viewPlanId,
|
||||||
|
})
|
||||||
|
|
||||||
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')
|
||||||
@ -120,37 +118,27 @@ export function KhkkCreatePage() {
|
|||||||
peId: purchaseEvaluationId, // [F-2] BE Command field = PeId
|
peId: purchaseEvaluationId, // [F-2] BE Command field = PeId
|
||||||
approvalWorkflowId,
|
approvalWorkflowId,
|
||||||
ghiChu: ghiChu.trim() || null,
|
ghiChu: ghiChu.trim() || null,
|
||||||
// [K2 S164] Gửi TƯỜNG MINH (BE cho phép bỏ trống = nhóm 1, nhưng ô chọn đã
|
approvalGroup, // [K2] gửi TƯỜNG MINH, không dựa default ngầm của BE
|
||||||
// hiện số nào thì phiếu phải vào đúng số đó — không dựa default ngầm).
|
|
||||||
approvalGroup,
|
|
||||||
}
|
}
|
||||||
const res = await api.post<{ id: string; maKeHoach: string | null }>('/contract-signing-plans', body)
|
return (await api.post<{ id: string; maKeHoach: string | null }>('/contract-signing-plans', body)).data
|
||||||
return res.data
|
|
||||||
},
|
},
|
||||||
// 🔴 Vế "Header tạo xong thì sang chi tiết" nằm ĐÚNG ở đây — giữ nguyên từ S161.
|
// [S174] "Header tạo xong thì cho sang chi tiết" — nay KHÔNG rời màn nữa: mở luôn phiếu
|
||||||
|
// vừa tạo ở panel 2 (chỉ xem) và trả form về trắng. Cùng tinh thần "không re-direct".
|
||||||
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}`)
|
setPurchaseEvaluationId('')
|
||||||
|
setApprovalWorkflowId('')
|
||||||
|
setGhiChu('')
|
||||||
|
setViewPlanId(data.id)
|
||||||
},
|
},
|
||||||
onError: (e) => toast.error(getErrorMessage(e)),
|
onError: (e) => toast.error(getErrorMessage(e)),
|
||||||
})
|
})
|
||||||
|
|
||||||
const onSubmit = (e: FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
create.mutate()
|
|
||||||
}
|
|
||||||
|
|
||||||
const list = candidates.data ?? []
|
const list = candidates.data ?? []
|
||||||
|
|
||||||
// [K2 S164] Nhóm đã có kế hoạch SỐNG trên PE đang chọn — nguồn DUY NHẤT là DTO picker
|
|
||||||
// (`existingGroups`), FE không tự suy từ đâu khác. `?? []` giữ trang sống với payload
|
|
||||||
// cũ còn nằm trong cache React Query trước khi K2 deploy.
|
|
||||||
const selectedPe = list.find((p) => p.id === purchaseEvaluationId)
|
const selectedPe = list.find((p) => p.id === purchaseEvaluationId)
|
||||||
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 = [
|
const missingCreate = [
|
||||||
!purchaseEvaluationId && 'Phiếu Duyệt NCC nguồn',
|
!purchaseEvaluationId && 'Phiếu Duyệt NCC nguồn',
|
||||||
!approvalWorkflowId && 'Quy trình duyệt',
|
!approvalWorkflowId && 'Quy trình duyệt',
|
||||||
@ -158,221 +146,259 @@ export function KhkkCreatePage() {
|
|||||||
].filter(Boolean) as string[]
|
].filter(Boolean) as string[]
|
||||||
const canSubmit = missingCreate.length === 0 && !create.isPending
|
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 PE ở panel 1: nạp vào form + rời chế-độ xem. Nhóm đang chọn đã bị PE đó dùng thì
|
||||||
// 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ò.
|
// 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 pickPe = (peId: string) => {
|
||||||
setPurchaseEvaluationId(pe.id)
|
setPurchaseEvaluationId(peId)
|
||||||
const taken = pe.existingGroups ?? []
|
setViewPlanId(null)
|
||||||
|
const taken = list.find((p) => p.id === peId)?.existingGroups ?? []
|
||||||
if (taken.includes(approvalGroup)) {
|
if (taken.includes(approvalGroup)) {
|
||||||
const free = firstFreeGroup(taken)
|
const free = firstFreeGroup(taken)
|
||||||
if (free !== null) setApprovalGroup(free)
|
if (free !== null) setApprovalGroup(free)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const onSubmit = (e: FormEvent) => {
|
||||||
<form onSubmit={onSubmit} className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
e.preventDefault()
|
||||||
{/* Thanh đầu — mirror `PeWorkspaceCreateView.tsx:155-165` */}
|
create.mutate()
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-5 py-3">
|
}
|
||||||
<div>
|
|
||||||
<h2 className="text-base font-semibold text-slate-900">Lập kế hoạch ký kết HĐ mới</h2>
|
|
||||||
<p className="mt-0.5 text-[12px] text-slate-500">
|
|
||||||
Nhập Section 1 + 2 → bấm <strong>“Tạo kế hoạch”</strong> → sau đó dòng giá NCC / căn cứ hồ sơ / ý kiến mở khóa.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button type="button" variant="ghost" onClick={() => navigate('/khkk/list')} className="text-xs">
|
|
||||||
← Hủy
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="divide-y divide-slate-200">
|
const headerTitle = presetGroup !== null
|
||||||
<Section title="1. Phiếu Duyệt NCC nguồn *">
|
? `Kế hoạch ký kết HĐ — Thao tác · ${khkkGroupMenuLabel(presetGroup)}`
|
||||||
{candidates.isLoading && <div className="text-sm text-muted-foreground">Đang tải danh sách phiếu...</div>}
|
: 'Kế hoạch ký kết HĐ — Thao tác'
|
||||||
{!candidates.isLoading && list.length === 0 && (
|
|
||||||
<div className="rounded-lg border border-dashed border-slate-300 p-6 text-center text-sm text-muted-foreground">
|
return (
|
||||||
Không có phiếu Duyệt NCC nào đã duyệt và có NCC trúng thầu.
|
<div className="flex h-[calc(100vh-4rem)] flex-col">
|
||||||
|
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-slate-200 bg-white px-6 py-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<FileCheck className="h-5 w-5 text-slate-500" />
|
||||||
|
<h1 className="truncate text-base font-semibold tracking-tight text-slate-900">{headerTitle}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="text-[12px] text-slate-500">
|
||||||
|
Bấm phiếu ở cột trái — tầng trên lập kế hoạch, tầng dưới chỉ xem.
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[300px_1fr] xl:grid-cols-[340px_1fr]">
|
||||||
|
<KhkkWorkListPanel
|
||||||
|
onPickPe={pickPe}
|
||||||
|
onPickPlan={setViewPlanId}
|
||||||
|
selectedPeId={purchaseEvaluationId || null}
|
||||||
|
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">
|
||||||
|
{/* (a) Đang XEM một kế hoạch — read-only, có đường quay về form tạo. */}
|
||||||
|
{viewPlanId && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-violet-200 bg-violet-50 px-4 py-2">
|
||||||
|
<span className="text-[12px] font-medium text-violet-800">
|
||||||
|
Đang xem kế hoạch — chỉ xem, không sửa ở màn Thao tác.
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => navigate(`/khkk/${viewPlanId}`)}>
|
||||||
|
Mở trang đầy đủ
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setViewPlanId(null)}>
|
||||||
|
<X className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Về form tạo
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{viewPlan.isLoading && <div className="text-sm text-slate-500">Đang tải…</div>}
|
||||||
|
{!viewPlan.isLoading && !viewPlan.data && (
|
||||||
|
<div className="text-sm text-red-600">Không tìm thấy phiếu.</div>
|
||||||
|
)}
|
||||||
|
{viewPlan.data && (
|
||||||
|
<KhkkDetailContent plan={viewPlan.data} readOnly onChanged={() => viewPlan.refetch()} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="max-h-96 space-y-2 overflow-y-auto">
|
|
||||||
{list.map((pe) => {
|
|
||||||
const selected = pe.id === purchaseEvaluationId
|
|
||||||
// [K2 S164 — OG-7] `?? []` = phòng payload cũ trong cache (trước K2 DTO
|
|
||||||
// không có field này) — thiếu guard thì `.length` nổ trắng trang.
|
|
||||||
const taken = pe.existingGroups ?? []
|
|
||||||
const free = firstFreeGroup(taken)
|
|
||||||
return (
|
|
||||||
<div key={pe.id} className="space-y-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => selectPe(pe)}
|
|
||||||
className={cn(
|
|
||||||
'flex w-full items-start gap-3 rounded-lg border p-3 text-left transition',
|
|
||||||
selected
|
|
||||||
? 'border-brand-400 bg-brand-50/60 ring-1 ring-brand-300'
|
|
||||||
: 'border-slate-200 hover:border-brand-300 hover:bg-slate-50',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CheckCircle2
|
|
||||||
className={cn('mt-0.5 h-4 w-4 shrink-0', selected ? 'text-brand-600' : 'text-slate-300')}
|
|
||||||
/>
|
|
||||||
<span className="min-w-0 flex-1">
|
|
||||||
<span className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="font-mono text-xs text-brand-700">{pe.maPhieu ?? '—'}</span>
|
|
||||||
<span className="font-medium text-brand-800">{pe.tenGoiThau}</span>
|
|
||||||
</span>
|
|
||||||
<span className="mt-0.5 block text-xs text-slate-500">
|
|
||||||
{pe.projectName ?? '—'}
|
|
||||||
{pe.workItemName ? ` · ${pe.workItemName}` : ''} · {formatDate(pe.createdAt)}
|
|
||||||
</span>
|
|
||||||
<span className="mt-1 flex flex-wrap gap-1">
|
|
||||||
{pe.winnerSupplierNames.length === 0 ? (
|
|
||||||
<span className="text-xs italic text-slate-400">Chưa có NCC trúng thầu</span>
|
|
||||||
) : (
|
|
||||||
pe.winnerSupplierNames.map((n) => (
|
|
||||||
<span
|
|
||||||
key={n}
|
|
||||||
className="inline-flex items-center rounded-md border border-emerald-300 bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-800"
|
|
||||||
>
|
|
||||||
{n}
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* [K2 S164 — OG-7] PE đã có kế hoạch: KHÔNG ẩn dòng nữa, hiện nhóm
|
{/* (b) Chưa chọn gì — nói rõ phải bấm ở đâu, thay vì để trống một mảng trắng. */}
|
||||||
đã dùng + lối đi tiếp. Nút nằm NGOÀI <button> ở trên (button lồng
|
{!viewPlanId && !purchaseEvaluationId && (
|
||||||
button là HTML không hợp lệ). */}
|
<EmptyState
|
||||||
{taken.length > 0 && (
|
icon={FileCheck}
|
||||||
<div className="flex flex-wrap items-center gap-2 pl-8 text-xs">
|
title="Chọn phiếu Duyệt NCC để lập kế hoạch"
|
||||||
<span className="text-slate-500">Đã có kế hoạch nhóm:</span>
|
description="Bấm một phiếu ở tầng trên cột trái để lập kế hoạch ký kết, hoặc bấm một kế hoạch ở tầng dưới để xem."
|
||||||
{taken.map((g) => (
|
/>
|
||||||
<span
|
)}
|
||||||
key={g}
|
|
||||||
className="inline-flex items-center rounded-md border border-amber-300 bg-amber-100 px-1.5 py-0.5 text-[11px] font-semibold text-amber-800"
|
{/* (c) Đã chọn PE — form tạo. */}
|
||||||
title={KHKK_APPROVAL_GROUP_LABELS[g] ?? `Nhóm ${g}`}
|
{!viewPlanId && purchaseEvaluationId && (
|
||||||
>
|
<form onSubmit={onSubmit} className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||||
N{g}
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-5 py-3">
|
||||||
</span>
|
<div>
|
||||||
))}
|
<h2 className="text-base font-semibold text-slate-900">Lập kế hoạch ký kết HĐ mới</h2>
|
||||||
{free === null ? (
|
<p className="mt-0.5 text-[12px] text-slate-500">
|
||||||
<span className="italic text-slate-400">Đã dùng hết 8 nhóm duyệt</span>
|
Kiểm Section 1 + 2 → bấm <strong>“Tạo kế hoạch”</strong> → sau đó dòng giá NCC / căn cứ hồ sơ / ý kiến mở khóa.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-slate-200">
|
||||||
|
<Section title="1. Phiếu Duyệt NCC nguồn *">
|
||||||
|
{/* Danh sách chọn nằm ở PANEL 1 — ở đây chỉ XÁC NHẬN cái đang chọn, để
|
||||||
|
không có hai chỗ chọn cùng một thứ (2 nguồn sự thật trên cùng màn). */}
|
||||||
|
<div className="rounded-lg border border-brand-300 bg-brand-50/60 p-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-mono text-xs text-brand-700">{selectedPe?.maPhieu ?? '—'}</span>
|
||||||
|
<span className="font-medium text-brand-800">{selectedPe?.tenGoiThau ?? '(đang tải…)'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-xs text-slate-500">
|
||||||
|
{selectedPe?.projectName ?? '—'}
|
||||||
|
{selectedPe?.workItemName ? ` · ${selectedPe.workItemName}` : ''}
|
||||||
|
</div>
|
||||||
|
{(selectedPe?.winnerSupplierNames?.length ?? 0) > 0 && (
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{selectedPe!.winnerSupplierNames.map((n) => (
|
||||||
|
<span
|
||||||
|
key={n}
|
||||||
|
className="inline-flex items-center rounded-md border border-emerald-300 bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-800"
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{takenGroups.length > 0 && (
|
||||||
|
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-xs">
|
||||||
|
<span className="text-slate-500">Đã có kế hoạch nhóm:</span>
|
||||||
|
{takenGroups.map((g) => (
|
||||||
|
<span
|
||||||
|
key={g}
|
||||||
|
className="inline-flex items-center rounded-md border border-amber-300 bg-amber-100 px-1.5 py-0.5 text-[11px] font-semibold text-amber-800"
|
||||||
|
title={KHKK_APPROVAL_GROUP_LABELS[g] ?? `Nhóm ${g}`}
|
||||||
|
>
|
||||||
|
N{g}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="2. Nhóm duyệt & quy trình">
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="approvalGroup" className="text-[11px]">Nhóm duyệt (N1–N8) *</Label>
|
||||||
|
<Select
|
||||||
|
id="approvalGroup"
|
||||||
|
value={approvalGroup}
|
||||||
|
onChange={(e) => setApprovalGroup(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
{KHKK_APPROVAL_GROUPS.map((g) => {
|
||||||
|
const used = takenGroups.includes(g)
|
||||||
|
return (
|
||||||
|
<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 ? (
|
||||||
|
<p className="mt-1 text-[11px] font-medium text-red-600">
|
||||||
|
Phiếu này đã có kế hoạch cho nhóm N{approvalGroup} — chọn nhóm khác.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => selectPe(pe)}>
|
<p className="mt-1 text-[11px] text-slate-500">
|
||||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
Mỗi phiếu Duyệt NCC lập được 1 kế hoạch cho MỖI nhóm duyệt
|
||||||
Tạo thêm cho nhóm khác
|
</p>
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="workflow" className="text-[11px]">Quy trình duyệt *</Label>
|
||||||
|
<Select
|
||||||
|
id="workflow"
|
||||||
|
value={approvalWorkflowId}
|
||||||
|
onChange={(e) => setApprovalWorkflowId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">— Chọn quy trình duyệt —</option>
|
||||||
|
{workflows.data?.map((w) => (
|
||||||
|
<option key={w.id} value={w.id}>{w.code} - {w.name}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
{workflows.data && workflows.data.length === 0 ? (
|
||||||
|
<p className="mt-1 text-[11px] text-amber-700">
|
||||||
|
⚠ Chưa có 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 admin đã ghim cho user chọn
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="3. Ghi chú">
|
||||||
|
<Label htmlFor="ghiChu" className="text-[11px]">Ghi chú kế hoạch</Label>
|
||||||
|
<Textarea
|
||||||
|
id="ghiChu"
|
||||||
|
value={ghiChu}
|
||||||
|
onChange={(e) => setGhiChu(e.target.value)}
|
||||||
|
maxLength={2000}
|
||||||
|
rows={4}
|
||||||
|
placeholder="Lưu ý về tiến độ ký kết, điều kiện thương thảo..."
|
||||||
|
/>
|
||||||
|
<div className="mt-1 text-[11px] text-slate-500">{ghiChu.length}/2000</div>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hint TRÁI kể rõ còn thiếu gì, nút PHẢI. `groupTaken` chặn ở FE lượt bấm chắc
|
||||||
|
chắn ăn 409 của BE (`Features.cs:395-402`) — BE vẫn là rào thật. */}
|
||||||
|
<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 className="font-medium text-emerald-600">✓ Đủ thông tin bắt buộc — có thể tạo kế hoạch</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
})}
|
<Button
|
||||||
</div>
|
type="button"
|
||||||
</Section>
|
variant="ghost"
|
||||||
|
className="text-xs"
|
||||||
<Section title="2. Nhóm duyệt & quy trình">
|
onClick={() => { setPurchaseEvaluationId(''); setApprovalWorkflowId(''); setGhiChu('') }}
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
>
|
||||||
{/* [K2 S164 — vá-4] Nhóm duyệt N1..N8. Mặc định nhóm 1 (UI-hướng-1: ca HẸP,
|
Hủy
|
||||||
user cần nhóm khác thì đổi ngay tại đây). Nhóm PE đã dùng bị disable —
|
</Button>
|
||||||
rào cứng 409 vẫn ở BE, ô này chỉ để không mời bấm nhầm. */}
|
<Button type="submit" disabled={!canSubmit}>
|
||||||
<div>
|
{create.isPending ? 'Đang tạo…' : 'Tạo kế hoạch'}
|
||||||
<Label htmlFor="approvalGroup" className="text-[11px]">Nhóm duyệt (N1–N8) *</Label>
|
</Button>
|
||||||
<Select
|
</div>
|
||||||
id="approvalGroup"
|
</div>
|
||||||
value={approvalGroup}
|
</form>
|
||||||
onChange={(e) => setApprovalGroup(Number(e.target.value))}
|
|
||||||
>
|
|
||||||
{KHKK_APPROVAL_GROUPS.map((g) => {
|
|
||||||
const used = takenGroups.includes(g)
|
|
||||||
return (
|
|
||||||
<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 ? (
|
|
||||||
<p className="mt-1 text-[11px] font-medium text-red-600">
|
|
||||||
Phiếu Duyệt NCC này đã có kế hoạch ký kết cho nhóm N{approvalGroup} — chọn nhóm khác.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<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
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="workflow" className="text-[11px]">Quy trình duyệt *</Label>
|
|
||||||
<Select
|
|
||||||
id="workflow"
|
|
||||||
value={approvalWorkflowId}
|
|
||||||
onChange={(e) => setApprovalWorkflowId(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">— Chọn quy trình duyệt —</option>
|
|
||||||
{workflows.data?.map((w) => (
|
|
||||||
<option key={w.id} value={w.id}>
|
|
||||||
{w.code} - {w.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
{workflows.data && workflows.data.length === 0 ? (
|
|
||||||
<p className="mt-1 text-[11px] text-amber-700">
|
|
||||||
⚠ Chưa có quy trình duyệt "Kế hoạch ký kết HĐ" nào được ghim cho user chọn. Liên hệ admin tạo trước.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="mt-1 text-[11px] text-slate-500">
|
|
||||||
Chỉ hiển thị quy trình loại "Kế hoạch ký kết HĐ" admin đã ghim cho user chọn
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="3. Ghi chú">
|
|
||||||
<Label htmlFor="ghiChu" className="text-[11px]">Ghi chú kế hoạch</Label>
|
|
||||||
<Textarea
|
|
||||||
id="ghiChu"
|
|
||||||
value={ghiChu}
|
|
||||||
onChange={(e) => setGhiChu(e.target.value)}
|
|
||||||
maxLength={2000}
|
|
||||||
rows={4}
|
|
||||||
placeholder="Lưu ý về tiến độ ký kết, điều kiện thương thảo..."
|
|
||||||
/>
|
|
||||||
<div className="mt-1 text-[11px] text-slate-500">{ghiChu.length}/2000</div>
|
|
||||||
</Section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Thanh cuối — hint TRÁI kể rõ còn thiếu gì, nút PHẢI (mirror `:318-337`).
|
|
||||||
`groupTaken` chặn ngay ở FE lượt bấm chắc chắn ăn 409 của BE
|
|
||||||
(`Features.cs:395-402`) — vẫn để BE là rào thật, đây chỉ đỡ 1 vòng mạng. */}
|
|
||||||
<div className="flex items-center justify-between gap-3 border-t border-slate-200 bg-slate-50 px-5 py-3">
|
|
||||||
<div className="min-w-0 text-[11px]">
|
|
||||||
{missingCreate.length > 0 ? (
|
|
||||||
<span className="text-red-600">
|
|
||||||
⚠ Chưa đủ để tạo kế hoạch — còn thiếu: <strong>{missingCreate.join(' · ')}</strong>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="font-medium text-emerald-600">✓ Đủ thông tin bắt buộc — có thể tạo kế hoạch</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</main>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
|
||||||
<Button type="button" variant="ghost" onClick={() => navigate('/khkk/list')} className="text-xs">
|
|
||||||
Hủy
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={!canSubmit}>
|
|
||||||
{create.isPending ? 'Đang tạo…' : 'Tạo kế hoạch'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper — duplicate từ `PeWorkspaceCreateView` để tránh circular import (cùng lý do
|
// Helper — duplicate từ `PeWorkspaceCreateView` để tránh circular import (cùng lý do vì sao
|
||||||
// vì sao bên đó cũng tự dựng lại thay vì import từ `PeDetailTabs`).
|
// bên đó cũng tự dựng lại thay vì import từ `PeDetailTabs`).
|
||||||
function Section({ title, children }: { title: string; children: ReactNode }) {
|
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<section className="px-5 py-4">
|
<section className="px-5 py-4">
|
||||||
|
|||||||
@ -15,7 +15,6 @@ 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'
|
||||||
@ -78,10 +77,7 @@ export function PurchaseEvaluationWorkspacePage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* [S174 owner] Cột 3 (`PeKhkkSidePanel`, 2 tầng 50/50) CHỈ mọc ở `xl` — thêm nó vào
|
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[260px_1fr] xl:grid-cols-[320px_1fr]">
|
||||||
`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. */}
|
||||||
@ -134,10 +130,6 @@ 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 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user