All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m11s
Anh review truc tiep tren prod, khoanh do tung cho. Land tron cum:
=== 1. GOM NUT THAO TAC (anh: "Bo het di" + "phia duoi them cac nut thao tac"
+ "cho menu dang duyet -> Duyet/Tra lai vay thoi") ===
· THANH NUT DAY moi o `KhkkDetailContent` (sticky bottom-0, khuon PE workspace):
[Xoa phieu] · dong "Cac thay doi da tu dong luu" · [Luu & Gui duyet ->]
· Panel 3 GO "Gui duyet" + "Xoa phieu" => nay CHI con **Duyet / Tra lai**.
· GO nut "+ Lap ke hoach" (header) · nut "Sua" (muc 1) · dai banner "Dang xem ke hoach".
🔴 THU TU CO CHU DICH: KHONG go "Gui duyet" o panel 3 TRUOC khi thanh day co mat — go truoc
la nguoi soan mat duong trinh duyet, phieu ket o Nhap. Vi vay ca cum di CHUNG 1 commit.
🔴 Thanh day chi om nhanh **Submit** — nhanh Approve/Return (o-tich level-finalize + upload
file sau transition) O LAI panel 3. Nhan ban nhanh do sang day la de su-that thu hai cho
duong DUYET.
🔸 KHONG lam nut "Luu" roi: moi o o man nay luu NGAY khi roi o. Them nut khong lam gi la
hua hao — noi thang bang dong chu ben canh thay vi ve nut gia.
🔸 Giu lai dung 1 dong nho "Ve form tao" — bo han thi mo 1 phieu la ket, khong co duong lui.
· `onDeleted` noi vao ca 3 host (List / Create / Detail) de xoa xong khong dung o phieu ma.
=== 2. HANG MUC CUA DU AN (anh: "hang muc cua Du an -. Duyet NCC ay") ===
🔴 Lead bay NHAM o luot truoc: bay `line.tenHangMuc` = danh-muc **SP-002** (86 dong, dung
phan loai HOP DONG). Anh muon **hang muc CONG VIEC cua du an** tu phieu Duyet NCC
(`PE.WorkItemId`). Hai thu trung ten tieng Viet nhung KHAC TRUC — tron la chi sai goi thau.
· BE: `ContractSigningPlanDetailDto` +`PeWorkItemName`; handler join `WorkItems` (loose-Guid
Mig 49, KHONG FK vat ly => join tay null-safe).
· FE: o "Hang muc (du an)" doc `plan.peWorkItemName`; go het khoi liet-ke SP-002 khoi muc 1.
=== 3. MUC 2 RUT VE "HANG MUC + GIA -> TONG" ===
Anh: "Cho nay doi lai la hang muc la dc roi, chon hang muc tien -> thanh tong con lai bo het"
+ "cho chon nhieu dong nhap gia thanh gia tong, ko can so sanh j voi so duyet PRO hay CCM".
· Tieu de: "2. Gia de xuat theo NCC trung thau" -> "2. Hang muc".
· Cot: 6 -> 3 (HANG MUC | GIA | thao-tac). GO **Nha cung cap · Gia tham chieu · Gia duyet**.
· Ghi chu xuong DONG PHU duoi o hang muc (van nhap duoc, khong chiem cot).
· +hang TONG = Sigma gia cac dong.
🔸 Du-lieu 2 cot go di VAN trong DTO; `peReferenceAmount` con dung lam DAU phan biet
dong-goc / dong-them-tay o nut xoa.
=== 4. LOC THEO NHOM (anh: "Loc theo quy trinh luon") ===
Tang duoi man Thao tac hien phieu N1/N7 trong khi dang dung o leaf N2 => doc nham "nhom nay
dang co viec". Nay loc theo `approvalGroup` cua leaf menu. `group == null` => khong loc
(giu duong cu cho host khong truyen).
=== 5. GO DAI ACCENT + 2 O LINK HO SO/GHI CHU ===
· `card-accent` boc `KhkkDetailContent` GO HAN (dai xanh doc man Thao tac). GO CLASS chu
KHONG de `border-0` — class nay ngoai `@layer` nen thang utility (gotcha #66).
· Khoi "Link ho so + Ghi chu" GO TRON ca 2 nhanh ternary. Luot truoc lead moi go o luoi tren
nen 2 o van hien o khoi duoi — anh chup lai dung cho do.
=== CON NO ===
🔴 Cay man Thao tac: anh muon **Nam > Du an > Hang muc > 4 giai doan** (giong cay o man Danh
sach). Hien moi co Du an > Hang muc (gap/mo duoc). Chua land.
🔴 CHUA soi mat prod 375/1366/1440.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
436 lines
24 KiB
TypeScript
436 lines
24 KiB
TypeScript
// [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.
|
|
//
|
|
// [S174 owner] ĐỔI TỪ TRANG-GIẤY-CUỘN SANG HOST 2-PANEL — anh chốt: **"làm giống giao diện
|
|
// thao tác của Duyệt NCC"** (`pages/pe/PurchaseEvaluationWorkspacePage.tsx`). Khuôn bê về:
|
|
// · header 1 dòng + grid `lg:grid-cols-[260px_1fr] xl:grid-cols-[320px_1fr]` — ĐÚNG BYTE
|
|
// khuôn PE (`PurchaseEvaluationWorkspacePage.tsx:80`). 🧊 Comment cũ ghi `300px` là số
|
|
// của bản nháp đầu, KHÔNG bao giờ khớp code sau khi đợt A land 260/320 (S175 vá).
|
|
// · Panel 1 = danh sách (`KhkkWorkListPanel`) — 2 tầng, MỖI tầng có header dính ĐỈNH
|
|
// (`sticky top-0`) gom theo dự án. 🧊 Comment cũ ghi "nút hành-động dính đáy" là MÔ TẢ
|
|
// CỦA PE, không phải của đây: `grep -c Button KhkkWorkListPanel` = **0** (control-dương
|
|
// `PeListPanel` = 8). Bê nguyên câu mô tả từ khuôn nguồn sang là tự khai một thứ chưa
|
|
// dựng — S175 vá theo L3 H-5.
|
|
// · 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`.
|
|
//
|
|
// [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
|
|
// 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
|
|
// "PE không đủ điều kiện" với "PE đã dùng hết nhó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.
|
|
// Khối reset dưới là SET-STATE-TRONG-RENDER CÓ CHỦ ĐÍCH; refactor thành `useEffect` sẽ
|
|
// 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.
|
|
import { useState, type FormEvent, type ReactNode } from 'react'
|
|
import { useMutation, useQuery } from '@tanstack/react-query'
|
|
import { useSearchParams } from 'react-router-dom'
|
|
import { FileCheck, Pencil, X } from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
import { EmptyState } from '@/components/EmptyState'
|
|
import { Button } from '@/components/ui/Button'
|
|
import { Label } from '@/components/ui/Label'
|
|
import { Textarea } from '@/components/ui/Textarea'
|
|
import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
|
|
import { KhkkWorkListPanel } from '@/components/khkk/KhkkWorkListPanel'
|
|
import { api } from '@/lib/api'
|
|
import { getErrorMessage } from '@/lib/apiError'
|
|
import { useAuth } from '@/contexts/AuthContext'
|
|
import {
|
|
KHKK_APPROVAL_GROUP_DEFAULT,
|
|
KHKK_APPROVAL_GROUP_LABELS,
|
|
KhkkPhase,
|
|
khkkGroupMenuLabel,
|
|
type CreateKhkkInput,
|
|
type KhkkDetailDto,
|
|
type KhkkPeCandidateDto,
|
|
} from '@/types/khkk'
|
|
|
|
// ApplicableType của workflow V2 dành cho Kế hoạch ký kết (mirror BE
|
|
// `ApplicableType.ContractSigningPlan = 10`).
|
|
const APPLICABLE_TYPE_KHKK = 10
|
|
|
|
interface WorkflowDto {
|
|
id: string
|
|
code: string
|
|
name: string
|
|
isUserSelectable: boolean
|
|
}
|
|
|
|
// 🧊 [S174] `firstFreeGroup()` ĐÃ GỠ cùng lúc bỏ ô chọn nhóm — nó chỉ tồn tại để tự nhảy
|
|
// sang nhóm trống, đúng hành-vi anh vừa cấm ("gán cứng theo từng nhóm, ko cho User điều
|
|
// chỉnh"). Giữ lại thành hàm chết thì lượt sau có người gọi lại.
|
|
|
|
export function KhkkCreatePage() {
|
|
const [searchParams] = useSearchParams()
|
|
|
|
const groupParam = searchParams.get('group')
|
|
const presetGroup = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null
|
|
|
|
const [purchaseEvaluationId, setPurchaseEvaluationId] = useState('')
|
|
const [ghiChu, setGhiChu] = useState('')
|
|
|
|
// [S174 owner] "gán cứng luôn theo từng nhóm, ko cho User điều chỉnh chỗ này luôn"
|
|
// ⇒ nhóm duyệt KHÔNG còn là state: DẪN XUẤT thẳng từ leaf menu (`?group=n`). Không còn
|
|
// state thì không thể lệch, nên khối đồng-bộ-preset cũ (`lastPresetGroup`) cũng bỏ luôn —
|
|
// đó là thứ chỉ tồn tại để vá đúng cái lệch này.
|
|
const approvalGroup = presetGroup ?? KHKK_APPROVAL_GROUP_DEFAULT
|
|
// [S174] Phiếu KHKK đang XEM ở panel 2. Có giá trị ⇒ panel 2 hiện phiếu (read-only) thay
|
|
// 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)
|
|
// [S175 · O7] Bấm bút chì = mở thẳng chế-độ SỬA; bấm thân card = chỉ xem như cũ.
|
|
const [editPlan, setEditPlan] = useState(false)
|
|
const { user } = useAuth()
|
|
|
|
// Cùng query key với `KhkkWorkListPanel` ⇒ dùng CHUNG cache, không gọi mạng 2 lần.
|
|
const candidates = useQuery({
|
|
queryKey: ['khkk-pe-candidates'],
|
|
queryFn: async () =>
|
|
(await api.get<KhkkPeCandidateDto[]>('/contract-signing-plans/approved-pe-awaiting-plan')).data,
|
|
})
|
|
|
|
const workflows = useQuery({
|
|
queryKey: ['approval-workflows-v2', { applicableType: APPLICABLE_TYPE_KHKK, isUserSelectable: true }],
|
|
queryFn: async () => {
|
|
const res = await api.get<{ types: { applicableType: number; history: WorkflowDto[] }[] }>(
|
|
'/approval-workflows-v2',
|
|
{ params: { applicableType: APPLICABLE_TYPE_KHKK } },
|
|
)
|
|
const bucket = res.data.types.find((t) => t.applicableType === APPLICABLE_TYPE_KHKK)
|
|
return (bucket?.history ?? []).filter((w) => w.isUserSelectable)
|
|
},
|
|
})
|
|
|
|
// [S174 owner] Quy trình duyệt cũng GÁN CỨNG theo nhóm — mỗi nhóm đúng MỘT quy trình.
|
|
// 🔴 Chuỗi mã lấy từ NGUỒN BE, không suy từ màn hình: `DbInitializer.cs:609`
|
|
// `var code = $"KHKK-N{n}"` (vòng `for n = 1..8`, seed idempotent per-Code).
|
|
// Không khớp ⇒ KHÔNG tự rơi về quy trình khác: chặn tạo + nói rõ thiếu mã nào, vì gán
|
|
// nhầm quy trình là phiếu đi sai đường duyệt — hỏng nặng hơn là không tạo được.
|
|
const wantWorkflowCode = `KHKK-N${approvalGroup}`
|
|
const autoWorkflow = workflows.data?.find(
|
|
(w) => (w.code ?? '').trim().toUpperCase() === wantWorkflowCode,
|
|
) ?? null
|
|
|
|
// Phiếu đang XEM — chỉ tải khi thật sự có cái để xem.
|
|
const viewPlan = useQuery({
|
|
queryKey: ['khkk-detail', viewPlanId],
|
|
queryFn: async () => (await api.get<KhkkDetailDto>(`/contract-signing-plans/${viewPlanId}`)).data,
|
|
enabled: !!viewPlanId,
|
|
})
|
|
|
|
const create = useMutation({
|
|
mutationFn: async () => {
|
|
if (!purchaseEvaluationId) throw new Error('Vui lòng chọn phiếu Duyệt NCC nguồn')
|
|
if (!autoWorkflow) throw new Error(`Chưa có quy trình duyệt ${wantWorkflowCode}`)
|
|
const body: CreateKhkkInput = {
|
|
peId: purchaseEvaluationId, // [F-2] BE Command field = PeId
|
|
approvalWorkflowId: autoWorkflow.id,
|
|
ghiChu: ghiChu.trim() || null,
|
|
approvalGroup, // [K2] gửi TƯỜNG MINH, không dựa default ngầm của BE
|
|
}
|
|
return (await api.post<{ id: string; maKeHoach: string | null }>('/contract-signing-plans', body)).data
|
|
},
|
|
// [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) => {
|
|
toast.success(data.maKeHoach ? `Đã lập kế hoạch ${data.maKeHoach}` : 'Đã lập kế hoạch ký kết')
|
|
setPurchaseEvaluationId('')
|
|
setGhiChu('')
|
|
setViewPlanId(data.id)
|
|
},
|
|
onError: (e) => toast.error(getErrorMessage(e)),
|
|
})
|
|
|
|
const list = candidates.data ?? []
|
|
const selectedPe = list.find((p) => p.id === purchaseEvaluationId)
|
|
const takenGroups = selectedPe?.existingGroups ?? []
|
|
const groupTaken = takenGroups.includes(approvalGroup)
|
|
|
|
const missingCreate = [
|
|
!purchaseEvaluationId && 'Phiếu Duyệt NCC nguồn',
|
|
!workflows.isLoading && !autoWorkflow && `Quy trình ${wantWorkflowCode} (chưa cấu hình)`,
|
|
groupTaken && `Phiếu này đã có kế hoạch nhóm N${approvalGroup}`,
|
|
].filter(Boolean) as string[]
|
|
const canSubmit = missingCreate.length === 0 && !!autoWorkflow && !create.isPending
|
|
|
|
// [S174] Bấm PE ở panel 1 = nạp vào form + rời chế-độ xem. 🧊 Khối "nhảy sang nhóm trống
|
|
// đầu tiên" ĐÃ BỎ: nhóm nay gán cứng theo leaf menu, tự nhảy nhóm = đúng thứ anh vừa cấm.
|
|
// PE đã có kế hoạch ở nhóm này ⇒ `groupTaken` chặn + nói rõ, người dùng tự sang leaf khác.
|
|
const pickPe = (peId: string) => {
|
|
setPurchaseEvaluationId(peId)
|
|
setViewPlanId(null)
|
|
}
|
|
|
|
const onSubmit = (e: FormEvent) => {
|
|
e.preventDefault()
|
|
create.mutate()
|
|
}
|
|
|
|
const headerTitle = presetGroup !== null
|
|
? `Kế hoạch ký kết HĐ — Thao tác · ${khkkGroupMenuLabel(presetGroup)}`
|
|
: 'Kế hoạch ký kết HĐ — Thao tác'
|
|
|
|
return (
|
|
<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>
|
|
|
|
{/* [S174 · B4] Bề ngang cột về ĐÚNG số của Duyệt NCC (`PurchaseEvaluationWorkspacePage.tsx:80`
|
|
`260px` / `xl:320px`) — trước đó lead tự đặt 300/340, lệch chuẩn không lý do.
|
|
[S174 · A2 — VÁ REGRESSION DO CHÍNH LEAD GÂY RA TRONG PHIÊN NÀY] `<main>` từng mang
|
|
`hidden … lg:block` ⇒ dưới `lg` chỉ còn panel-1, KHÔNG có form ⇒ **mobile không tạo
|
|
được phiếu**: bấm phiếu ở cột trái xong thì không có chỗ nào để bấm "Tạo kế hoạch".
|
|
🔴 Vá bằng 2 vế, thiếu vế nào cũng hỏng: (1) `<main>` bỏ `hidden`, hiện mọi khổ;
|
|
(2) mobile xếp DỌC với panel-1 chốt trần `14rem` — không chốt thì panel-1 (danh sách
|
|
dài) đẩy form rơi khỏi màn, đúng cái ngõ cụt vừa vá. Từ `lg` trở lên về 1 hàng như cũ. */}
|
|
<div className="grid flex-1 grid-cols-1 grid-rows-[minmax(0,14rem)_minmax(0,1fr)] overflow-hidden lg:grid-cols-[260px_1fr] lg:grid-rows-1 xl:grid-cols-[320px_1fr]">
|
|
<KhkkWorkListPanel
|
|
onPickPe={pickPe}
|
|
onPickPlan={(id) => { setViewPlanId(id); setEditPlan(false) }}
|
|
selectedPeId={purchaseEvaluationId || null}
|
|
selectedPlanId={viewPlanId}
|
|
onEditPlan={(id) => { setViewPlanId(id); setEditPlan(true) }}
|
|
group={approvalGroup}
|
|
/>
|
|
|
|
<main className="min-h-0 overflow-y-auto bg-slate-50 p-4 lg:p-6">
|
|
{/* (a) Đang XEM một kế hoạch — read-only, có đường quay về form tạo. */}
|
|
{viewPlanId && (() => {
|
|
// [S175 · O7] `readOnly` = KHÔNG bấm bút chì, HOẶC phiếu không đủ điều-kiện sửa.
|
|
// 🔴 Guard y hệt `KhkkListPage.tsx:147-150` — cùng một câu hỏi "ai được sửa phiếu
|
|
// này" thì phải cho cùng một câu trả lời ở mọi màn; lệch nhau là đẻ lỗ hổng đúng
|
|
// kiểu gotcha #82 (một màn khoá, màn kia hở).
|
|
// 🔸 Đây là rào HIỂN THỊ. Rào THẬT vẫn ở BE (`KeHoachKyKet.Update` + phase guard).
|
|
const p = viewPlan.data
|
|
const canEdit = !!p
|
|
&& (p.phase === KhkkPhase.DangSoanThao || p.phase === KhkkPhase.TraLai)
|
|
&& (!user?.id || p.drafterUserId === user.id)
|
|
const readOnly = !editPlan || !canEdit
|
|
return (
|
|
<div className="space-y-3">
|
|
{/* 🧊 [S175 — anh: "bỏ mấy dòng này đi"] Dải banner "Đang xem kế hoạch…" ĐÃ GỠ.
|
|
Nút của nó đi đâu: **Sửa** → bút chì ở danh sách (đường duy nhất, đúng như
|
|
banner cũ vẫn dặn) · **Xóa phiếu / Gửi duyệt** → THANH NÚT ĐÁY của
|
|
`KhkkDetailContent`. Chỉ giữ đúng 1 dòng nhỏ để quay về form tạo — bỏ hẳn
|
|
thì người dùng mở 1 phiếu xong là kẹt, không có đường lùi. */}
|
|
<div className="flex items-center justify-end gap-2">
|
|
{readOnly && canEdit && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditPlan(true)}
|
|
className="inline-flex items-center gap-1 rounded px-2 py-1 text-[11px] font-medium text-brand-700 transition hover:bg-brand-50"
|
|
>
|
|
<Pencil className="h-3 w-3" /> Sửa
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => { setViewPlanId(null); setEditPlan(false) }}
|
|
className="inline-flex items-center gap-1 rounded px-2 py-1 text-[11px] text-slate-500 transition hover:bg-slate-100 hover:text-slate-700"
|
|
>
|
|
<X className="h-3 w-3" /> Về form tạo
|
|
</button>
|
|
</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={readOnly} onChanged={() => viewPlan.refetch()} onDeleted={() => { setViewPlanId(null); setEditPlan(false) }} />
|
|
)}
|
|
</div>
|
|
)
|
|
})()}
|
|
|
|
{/* (b) Chưa chọn gì — nói rõ phải bấm ở đâu, thay vì để trống một mảng trắng. */}
|
|
{!viewPlanId && !purchaseEvaluationId && (
|
|
<EmptyState
|
|
icon={FileCheck}
|
|
title="Chọn phiếu Duyệt NCC để lập kế hoạch"
|
|
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."
|
|
/>
|
|
)}
|
|
|
|
{/* (c) Đã chọn PE — form tạo. */}
|
|
{!viewPlanId && purchaseEvaluationId && (
|
|
<form onSubmit={onSubmit} className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
|
<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">
|
|
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>
|
|
|
|
{/* [S174 owner] "gán cứng luôn theo từng nhóm, ko cho User điều chỉnh chỗ này
|
|
luôn" ⇒ CẢ HAI ô thành GIÁ-TRỊ CỐ ĐỊNH, không còn `Select`. Cố ý KHÔNG
|
|
dùng `<Select disabled>`: ô xám vẫn trông như "bấm được nhưng đang khoá"
|
|
và vẫn nằm trong tab-order — hiển thị thẳng giá trị mới nói đúng rằng
|
|
đây là dữ-kiện, không phải lựa-chọn. Muốn nhóm khác thì đổi leaf ở menu. */}
|
|
<Section title="2. Nhóm duyệt & quy trình (theo menu, không đổi được)">
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
<div>
|
|
<Label className="text-[11px]">Nhóm duyệt</Label>
|
|
<div className="flex h-9 items-center gap-2 rounded-md border border-slate-200 bg-slate-50 px-3 text-sm text-slate-700">
|
|
<span className="rounded border border-violet-200 bg-violet-50 px-1.5 py-0.5 text-[10px] font-semibold text-violet-700">
|
|
N{approvalGroup}
|
|
</span>
|
|
<span className="truncate">
|
|
{KHKK_APPROVAL_GROUP_LABELS[approvalGroup] ?? `Nhóm ${approvalGroup}`}
|
|
</span>
|
|
</div>
|
|
{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 phiếu khác,
|
|
hoặc sang leaf nhóm khác ở menu bên trái.
|
|
</p>
|
|
) : (
|
|
<p className="mt-1 text-[11px] text-slate-500">
|
|
Lấy theo mục đang mở ở menu — đổi nhóm bằng cách sang mục khác
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-[11px]">Quy trình duyệt</Label>
|
|
<div className="flex h-9 items-center rounded-md border border-slate-200 bg-slate-50 px-3 text-sm text-slate-700">
|
|
{workflows.isLoading ? (
|
|
<span className="text-slate-400">Đang tải…</span>
|
|
) : autoWorkflow ? (
|
|
<span className="truncate">
|
|
<span className="font-mono text-[12px] text-brand-700">{autoWorkflow.code}</span>
|
|
<span className="text-slate-400"> · </span>
|
|
{autoWorkflow.name}
|
|
</span>
|
|
) : (
|
|
<span className="text-red-600">Không tìm thấy {wantWorkflowCode}</span>
|
|
)}
|
|
</div>
|
|
{!workflows.isLoading && !autoWorkflow ? (
|
|
<p className="mt-1 text-[11px] text-amber-700">
|
|
⚠ Chưa có quy trình mã <strong>{wantWorkflowCode}</strong> (hoặc chưa được ghim
|
|
cho user chọn). Nhờ admin mở trong màn Quy trình duyệt rồi quay lại — hệ thống
|
|
KHÔNG tự dùng quy trình của nhóm khác.
|
|
</p>
|
|
) : (
|
|
<p className="mt-1 text-[11px] text-slate-500">
|
|
Mỗi nhóm duyệt gắn đúng một quy trình — hệ thống tự chọn theo nhóm
|
|
</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 className="flex shrink-0 items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
className="text-xs"
|
|
onClick={() => { setPurchaseEvaluationId(''); setGhiChu('') }}
|
|
>
|
|
Hủy
|
|
</Button>
|
|
<Button type="submit" disabled={!canSubmit}>
|
|
{create.isPending ? 'Đang tạo…' : 'Tạo kế hoạch'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
)}
|
|
</main>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Helper — duplicate từ `PeWorkspaceCreateView` để tránh circular import (cùng lý do vì sao
|
|
// bên đó cũng tự dựng lại thay vì import từ `PeDetailTabs`).
|
|
function Section({ title, children }: { title: string; children: ReactNode }) {
|
|
return (
|
|
<section className="px-5 py-4">
|
|
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-500">{title}</h3>
|
|
{children}
|
|
</section>
|
|
)
|
|
}
|