[CLAUDE] Contract: KHKK UAT S175 dot-F — gom nut thao tac + hang muc du an + loc nhom (0-mig)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m11s
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>
This commit is contained in:
@ -21,7 +21,7 @@
|
|||||||
import { useState, type ReactNode } from 'react'
|
import { useState, type ReactNode } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
ClipboardList, Download, Eye, FileCheck, Lock, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
|
ClipboardList, Download, Eye, FileCheck, Lock, Paperclip, Pencil, Plus, Trash2, Upload, Users,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
// [S175] Dùng LẠI dialog xem-trước của PE (đã có prop `basePath` từ đợt B) thay vì dựng bản
|
// [S175] Dùng LẠI dialog xem-trước của PE (đã có prop `basePath` từ đợt B) thay vì dựng bản
|
||||||
// thứ hai cho KHKK — 2 bản = 2 sự thật, sửa 1 chỗ quên chỗ kia.
|
// thứ hai cho KHKK — 2 bản = 2 sự thật, sửa 1 chỗ quên chỗ kia.
|
||||||
@ -40,7 +40,7 @@ import {
|
|||||||
DOSSIER_KIND_LABELS, DOSSIER_STATUS_BADGE, DOSSIER_STATUS_LABELS,
|
DOSSIER_KIND_LABELS, DOSSIER_STATUS_BADGE, DOSSIER_STATUS_LABELS,
|
||||||
DossierItemKind, DossierItemStatus,
|
DossierItemKind, DossierItemStatus,
|
||||||
KHKK_APPROVAL_GROUP_LABELS, KHKK_PHASE_BADGE, KHKK_PHASE_LABELS,
|
KHKK_APPROVAL_GROUP_LABELS, KHKK_PHASE_BADGE, KHKK_PHASE_LABELS,
|
||||||
KhkkAttachmentPurpose, KhkkPhase,
|
KhkkAttachmentPurpose, KhkkPhase, KhkkTransitionAction, type KhkkTransitionInput,
|
||||||
type DossierItemKindValue, type DossierItemStatusValue, type KhkkAttachmentDto,
|
type DossierItemKindValue, type DossierItemStatusValue, type KhkkAttachmentDto,
|
||||||
type KhkkDetailDto, type KhkkDossierItemDto, type KhkkPhaseValue,
|
type KhkkDetailDto, type KhkkDossierItemDto, type KhkkPhaseValue,
|
||||||
type UpsertKhkkDossierItemInput,
|
type UpsertKhkkDossierItemInput,
|
||||||
@ -149,17 +149,17 @@ export function KhkkDetailContent({
|
|||||||
plan,
|
plan,
|
||||||
readOnly,
|
readOnly,
|
||||||
onChanged,
|
onChanged,
|
||||||
|
onDeleted,
|
||||||
}: {
|
}: {
|
||||||
plan: KhkkDetailDto
|
plan: KhkkDetailDto
|
||||||
/** [P2-7] Host quyết: `true` khi phiếu ≠ Nháp/Trả lại HOẶC user ≠ người soạn. */
|
/** [P2-7] Host quyết: `true` khi phiếu ≠ Nháp/Trả lại HOẶC user ≠ người soạn. */
|
||||||
readOnly: boolean
|
readOnly: boolean
|
||||||
onChanged?: () => void
|
onChanged?: () => void
|
||||||
|
/** [S175] Gọi sau khi XÓA phiếu ở thanh nút đáy — host bỏ chọn / quay về danh sách. */
|
||||||
|
onDeleted?: () => void
|
||||||
}) {
|
}) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const id = plan.id
|
const id = plan.id
|
||||||
const [editHeader, setEditHeader] = useState(false)
|
|
||||||
const [ghiChu, setGhiChu] = useState('')
|
|
||||||
const [hoSoLink, setHoSoLink] = useState('')
|
|
||||||
const [itemForm, setItemForm] = useState<UpsertKhkkDossierItemInput | null>(null)
|
const [itemForm, setItemForm] = useState<UpsertKhkkDossierItemInput | null>(null)
|
||||||
// [S175] File đang xem-trước inline trong khối "File đính kèm" đã gộp.
|
// [S175] File đang xem-trước inline trong khối "File đính kèm" đã gộp.
|
||||||
const [previewAtt, setPreviewAtt] = useState<KhkkAttachmentDto | null>(null)
|
const [previewAtt, setPreviewAtt] = useState<KhkkAttachmentDto | null>(null)
|
||||||
@ -180,16 +180,6 @@ export function KhkkDetailContent({
|
|||||||
onChanged?.()
|
onChanged?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveHeader = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
await api.put(`/contract-signing-plans/${id}`, {
|
|
||||||
ghiChu: ghiChu.trim() || null,
|
|
||||||
hoSoLink: hoSoLink.trim() || null,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
onSuccess: () => { toast.success('Đã lưu'); setEditHeader(false); invalidate() },
|
|
||||||
onError: (e) => toast.error(getErrorMessage(e)),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Danh mục hạng mục SP-002 — chỉ tải khi phiếu còn sửa được (tránh 1 request thừa).
|
// Danh mục hạng mục SP-002 — chỉ tải khi phiếu còn sửa được (tránh 1 request thừa).
|
||||||
const catalogOptions = useQuery({
|
const catalogOptions = useQuery({
|
||||||
@ -234,6 +224,26 @@ export function KhkkDetailContent({
|
|||||||
onError: (e) => toast.error(getErrorMessage(e)),
|
onError: (e) => toast.error(getErrorMessage(e)),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// [S175 — anh: "phía dưới thêm các nút thao tác"] 2 mutation cho THANH NÚT ĐÁY.
|
||||||
|
// 🔴 CỐ Ý chỉ ôm nhánh **Submit** — nhánh Approve/Return có máy phức tạp (ô-tích
|
||||||
|
// level-finalize + upload file đính kèm SAU khi transition thành công) và nó ở LẠI
|
||||||
|
// `KhkkWorkflowPanel`. Nhân bản nhánh đó sang đây là đẻ sự-thật thứ hai cho đường DUYỆT;
|
||||||
|
// nhánh Submit thì chỉ có đúng `action` + `comment: null` nên bê là an toàn.
|
||||||
|
const submitPlan = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const body: KhkkTransitionInput = { action: KhkkTransitionAction.Submit, comment: null }
|
||||||
|
return api.post(`/contract-signing-plans/${id}/transitions`, body)
|
||||||
|
},
|
||||||
|
onSuccess: () => { toast.success('Đã gửi duyệt'); invalidate(); onChanged?.() },
|
||||||
|
onError: (e) => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deletePlan = useMutation({
|
||||||
|
mutationFn: async () => api.delete(`/contract-signing-plans/${id}`),
|
||||||
|
onSuccess: () => { toast.success('Đã xóa phiếu'); onDeleted?.() },
|
||||||
|
onError: (e) => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
|
||||||
const upsertItem = useMutation({
|
const upsertItem = useMutation({
|
||||||
mutationFn: async (body: UpsertKhkkDossierItemInput) => {
|
mutationFn: async (body: UpsertKhkkDossierItemInput) => {
|
||||||
if (!body.name.trim()) throw new Error('Vui lòng nhập tên căn cứ')
|
if (!body.name.trim()) throw new Error('Vui lòng nhập tên căn cứ')
|
||||||
@ -306,11 +316,6 @@ export function KhkkDetailContent({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const startEditHeader = () => {
|
|
||||||
setGhiChu(plan.ghiChu ?? '')
|
|
||||||
setHoSoLink(plan.hoSoLink ?? '')
|
|
||||||
setEditHeader(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// File "đính kèm khi duyệt" thuộc panel 3 (mục 5) — Section 4 chỉ liệt kê hồ-sơ của phiếu.
|
// File "đính kèm khi duyệt" thuộc panel 3 (mục 5) — Section 4 chỉ liệt kê hồ-sơ của phiếu.
|
||||||
// [S175 · O4] Chia 2 rổ: file GẮN VÀO một căn cứ hiện ngay dưới dòng căn cứ đó; Section 4
|
// [S175 · O4] Chia 2 rổ: file GẮN VÀO một căn cứ hiện ngay dưới dòng căn cứ đó; Section 4
|
||||||
@ -403,6 +408,11 @@ export function KhkkDetailContent({
|
|||||||
|
|
||||||
<div className="divide-y divide-slate-200">
|
<div className="divide-y divide-slate-200">
|
||||||
{/* ─────────────── 1 ─────────────── */}
|
{/* ─────────────── 1 ─────────────── */}
|
||||||
|
{/* 🧊 [S175 — anh: "Bỏ hết đi"] Nút "Sửa" ĐÃ GỠ khỏi `actions`. Nó chỉ mở form 2 trường
|
||||||
|
**Link hồ sơ + Ghi chú** — mà chính 2 trường đó anh cũng bảo bỏ khỏi màn ở cùng lượt.
|
||||||
|
Giữ nút mở form sửa một thứ không còn hiển thị ở đâu là mâu thuẫn.
|
||||||
|
🔸 Máy KHÔNG mất: `hoSoLink`/`ghiChu` vẫn nằm trong DTO + BE
|
||||||
|
(`UpdateContractSigningPlanDraftCommand`), muốn bày lại chỉ là thêm chỗ vẽ. */}
|
||||||
<Section
|
<Section
|
||||||
title="1. Thông tin kế hoạch"
|
title="1. Thông tin kế hoạch"
|
||||||
icon={<FileCheck className="h-4 w-4" />}
|
icon={<FileCheck className="h-4 w-4" />}
|
||||||
@ -410,13 +420,6 @@ export function KhkkDetailContent({
|
|||||||
head="text-brand-800"
|
head="text-brand-800"
|
||||||
chipBg="var(--color-brand-50)"
|
chipBg="var(--color-brand-50)"
|
||||||
chipFg="var(--color-brand-600)"
|
chipFg="var(--color-brand-600)"
|
||||||
actions={
|
|
||||||
isDraft && !editHeader ? (
|
|
||||||
<Button variant="outline" size="sm" onClick={startEditHeader}>
|
|
||||||
<Pencil className="mr-2 h-3.5 w-3.5" /> Sửa
|
|
||||||
</Button>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{/* [S175 — anh chốt trên ảnh khoanh đỏ] Mục 1 RÚT GỌN còn 4 ô:
|
{/* [S175 — anh chốt trên ảnh khoanh đỏ] Mục 1 RÚT GỌN còn 4 ô:
|
||||||
· "Bỏ hết đi" → GỠ **Phòng ban · Quy trình · Hạn xử lý**
|
· "Bỏ hết đi" → GỠ **Phòng ban · Quy trình · Hạn xử lý**
|
||||||
@ -428,68 +431,25 @@ export function KhkkDetailContent({
|
|||||||
<div className="grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2">
|
||||||
<Field label="Mã kế hoạch" value={plan.maKeHoach ?? '—'} mono />
|
<Field label="Mã kế hoạch" value={plan.maKeHoach ?? '—'} mono />
|
||||||
<Field label="Dự án" value={plan.projectName ?? '—'} />
|
<Field label="Dự án" value={plan.projectName ?? '—'} />
|
||||||
{/* 1 phiếu nay được N hạng mục (S175) ⇒ liệt kê DUY-NHẤT, giữ thứ tự dòng.
|
{/* [S175 — anh: "hạng mục của Dự án, Duyệt NCC ấy"] Đây là HẠNG MỤC CÔNG VIỆC của
|
||||||
Dòng chưa gán KHÔNG bịa tên — đếm riêng để người soạn biết còn nợ mấy dòng,
|
dự án, lấy từ phiếu Duyệt NCC nguồn (`PE.WorkItemId`).
|
||||||
vì submit-guard sẽ chặn trình duyệt khi còn dòng chưa gán. */}
|
🔴 KHÁC hẳn `line.tenHangMuc` — cái đó là danh-mục SP-002 dùng phân loại HỢP
|
||||||
<Field label="Hạng mục" full>
|
ĐỒNG (86 dòng, chọn ở mục 2). Hai thứ trùng tên tiếng Việt nhưng khác trục;
|
||||||
{(() => {
|
lượt trước lead bày nhầm cái SP-002 ở đây nên anh bảo "Bỏ đi". */}
|
||||||
const names = [...new Set(plan.lines.map(l => l.tenHangMuc).filter(Boolean))] as string[]
|
<Field label="Hạng mục (dự án)" value={plan.peWorkItemName ?? '—'} />
|
||||||
const unassigned = plan.lines.filter(l => !l.catalogEntryId).length
|
|
||||||
if (names.length === 0 && unassigned === 0) return DASH
|
|
||||||
return (
|
|
||||||
<span className="flex flex-wrap items-center gap-1">
|
|
||||||
{names.map(n => (
|
|
||||||
<span key={n} className="inline-flex items-center rounded bg-brand-50 px-1.5 py-0.5 text-[11px] font-medium text-brand-700">
|
|
||||||
{n}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{unassigned > 0 && (
|
|
||||||
<span className="inline-flex items-center rounded bg-amber-100 px-1.5 py-0.5 text-[11px] font-medium text-amber-800">
|
|
||||||
còn {unassigned} dòng chưa gán
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</Field>
|
|
||||||
<Field label="Người soạn" value={plan.drafterFullName ?? '—'} />
|
<Field label="Người soạn" value={plan.drafterFullName ?? '—'} />
|
||||||
<Field label="Ngày tạo" value={formatDateTime(plan.createdAt)} />
|
<Field label="Ngày tạo" value={formatDateTime(plan.createdAt)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editHeader ? (
|
{/* 🧊 [S175 — anh: "Bỏ đi"] Khối **Link hồ sơ + Ghi chú** (cả ô XEM lẫn form SỬA)
|
||||||
<div className="mt-4 space-y-3 rounded-lg border border-slate-200 bg-slate-50/60 p-4">
|
ĐÃ GỠ. Lượt trước lead mới gỡ ở lưới trên nên 2 ô này VẪN HIỆN ở khối dưới —
|
||||||
<div>
|
anh chụp lại đúng chỗ đó. Nay gỡ trọn cả 2 nhánh của ternary.
|
||||||
<Label htmlFor="hoSoLink">Link hồ sơ (NAS)</Label>
|
🔸 Máy KHÔNG mất: hoSoLink / ghiChu còn nguyên trong DTO + BE. */}
|
||||||
<Input id="hoSoLink" value={hoSoLink} onChange={(e) => setHoSoLink(e.target.value)} maxLength={1000} placeholder="\\\\nas\\du-an\\..." />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="ghiChu">Ghi chú</Label>
|
|
||||||
<Textarea id="ghiChu" value={ghiChu} onChange={(e) => setGhiChu(e.target.value)} maxLength={2000} rows={4} />
|
|
||||||
<div className="mt-1 text-xs text-muted-foreground">{ghiChu.length}/2000</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button variant="outline" size="sm" onClick={() => setEditHeader(false)}>Huỷ</Button>
|
|
||||||
<Button size="sm" onClick={() => saveHeader.mutate()} disabled={saveHeader.isPending}>
|
|
||||||
<Save className="mr-2 h-3.5 w-3.5" />
|
|
||||||
{saveHeader.isPending ? 'Đang lưu...' : 'Lưu'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2">
|
|
||||||
<Field label="Link hồ sơ" full>
|
|
||||||
{plan.hoSoLink ? <span className="break-all font-normal text-slate-700">{plan.hoSoLink}</span> : <span className="text-slate-300">—</span>}
|
|
||||||
</Field>
|
|
||||||
<Field label="Ghi chú" full>
|
|
||||||
{plan.ghiChu ? <span className="whitespace-pre-wrap font-normal text-slate-700">{plan.ghiChu}</span> : <span className="text-slate-300">—</span>}
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* ─────────────── 2 ─────────────── */}
|
{/* ─────────────── 2 ─────────────── */}
|
||||||
<Section
|
<Section
|
||||||
title={`2. Giá đề xuất theo NCC trúng thầu (${plan.lines.length} dòng)`}
|
title={`2. Hạng mục (${plan.lines.length} dòng)`}
|
||||||
icon={<Users className="h-4 w-4" />}
|
icon={<Users className="h-4 w-4" />}
|
||||||
accent="var(--color-teal-500)"
|
accent="var(--color-teal-500)"
|
||||||
head="text-teal-700"
|
head="text-teal-700"
|
||||||
@ -520,34 +480,20 @@ export function KhkkDetailContent({
|
|||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[880px] table-fixed text-xs">
|
<table className="w-full min-w-[880px] table-fixed text-xs">
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col className="w-[22%]" />
|
<col className="w-[52%]" />
|
||||||
<col className="w-[22%]" />
|
<col className="w-[32%]" />
|
||||||
<col className="w-[14%]" />
|
<col className="w-[16%]" />
|
||||||
<col className="w-[14%]" />
|
|
||||||
<col className="w-[14%]" />
|
|
||||||
<col className="w-[14%]" />
|
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<thead className="border-b border-slate-200 bg-slate-50/70">
|
<thead className="border-b border-slate-200 bg-slate-50/70">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="label-eyebrow px-3 py-2 text-left">Nhà cung cấp</th>
|
|
||||||
<th className="label-eyebrow px-3 py-2 text-left">Hạng mục</th>
|
<th className="label-eyebrow px-3 py-2 text-left">Hạng mục</th>
|
||||||
<th className="label-eyebrow px-3 py-2 text-right">Giá tham chiếu</th>
|
<th className="label-eyebrow px-3 py-2 text-right">Giá</th>
|
||||||
<th className="label-eyebrow px-3 py-2 text-right">Giá đề xuất</th>
|
<th className="label-eyebrow px-3 py-2 text-right"></th>
|
||||||
<th className="label-eyebrow px-3 py-2 text-right">Giá duyệt</th>
|
|
||||||
<th className="label-eyebrow px-3 py-2 text-left">Ghi chú</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{plan.lines.map((l) => (
|
{plan.lines.map((l) => (
|
||||||
<tr key={l.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/60">
|
<tr key={l.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/60">
|
||||||
<td className="px-3 py-2 font-medium text-brand-800">
|
|
||||||
{orDash(l.supplierName)}
|
|
||||||
{l.contractId && (
|
|
||||||
<a href={`/contracts/${l.contractId}`} className="ml-2 text-[10px] font-medium text-brand-600 hover:underline" title="Dòng này đã được đưa vào hợp đồng">
|
|
||||||
✓ HĐ
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-slate-700">
|
<td className="px-3 py-2 text-slate-700">
|
||||||
{isDraft && !l.contractId ? (
|
{isDraft && !l.contractId ? (
|
||||||
<>
|
<>
|
||||||
@ -555,12 +501,10 @@ export function KhkkDetailContent({
|
|||||||
value={l.catalogEntryId ?? ''}
|
value={l.catalogEntryId ?? ''}
|
||||||
onChange={(e) => e.target.value && assignLine.mutate({ lineId: l.id, catalogEntryId: e.target.value })}
|
onChange={(e) => e.target.value && assignLine.mutate({ lineId: l.id, catalogEntryId: e.target.value })}
|
||||||
disabled={assignLine.isPending}
|
disabled={assignLine.isPending}
|
||||||
aria-label={`Gán hạng mục SP-002 cho dòng ${l.supplierName ?? l.id}`}
|
aria-label={`Chọn hạng mục cho dòng ${l.id}`}
|
||||||
className="h-8 w-full min-w-[12rem] rounded-md border border-slate-200 bg-white px-2 text-xs text-slate-700 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
|
className="h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-xs text-slate-700 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
|
||||||
>
|
>
|
||||||
<option value="">— Chọn hạng mục —</option>
|
<option value="">— Chọn hạng mục —</option>
|
||||||
{/* Hạng mục ĐÃ LƯU mà không có trong options ⇒ chèn option đại diện,
|
|
||||||
không thì dòng đã gán TRÔNG như chưa gán. */}
|
|
||||||
{l.catalogEntryId && !(catalogOptions.data ?? []).some((c) => c.id === l.catalogEntryId) && (
|
{l.catalogEntryId && !(catalogOptions.data ?? []).some((c) => c.id === l.catalogEntryId) && (
|
||||||
<option value={l.catalogEntryId}>{l.tenHangMuc ?? '(hạng mục đã gán)'}</option>
|
<option value={l.catalogEntryId}>{l.tenHangMuc ?? '(hạng mục đã gán)'}</option>
|
||||||
)}
|
)}
|
||||||
@ -568,30 +512,30 @@ export function KhkkDetailContent({
|
|||||||
<option key={c.id} value={c.id}>{c.code} — {c.tenVi}</option>
|
<option key={c.id} value={c.id}>{c.code} — {c.tenVi}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{catalogOptions.isError && (
|
{/* Ghi chú xuống DÒNG PHỤ thay vì chiếm 1 cột — anh chốt bảng chỉ
|
||||||
<div className="mt-1 text-[11px] text-red-600">
|
còn "hạng mục + giá", nhưng ghi chú thì anh vẫn muốn nhập được. */}
|
||||||
Không tải được danh mục hạng mục —{' '}
|
<input
|
||||||
<button type="button" className="underline" onClick={() => catalogOptions.refetch()}>thử lại</button>
|
type="text"
|
||||||
</div>
|
maxLength={1000}
|
||||||
)}
|
defaultValue={l.note ?? ''}
|
||||||
{catalogOptions.isSuccess && (catalogOptions.data?.length ?? 0) === 0 && (
|
placeholder="Ghi chú (tùy chọn)…"
|
||||||
<div className="mt-1 text-[11px] text-amber-700">
|
disabled={saveLineField.isPending}
|
||||||
Nhóm duyệt N{plan.approvalGroup} chưa có hạng mục đang hiệu lực — liên hệ quản trị danh mục.
|
aria-label="Ghi chú dòng"
|
||||||
</div>
|
onBlur={(e) => {
|
||||||
)}
|
const v = e.target.value.trim()
|
||||||
|
if (v !== (l.note ?? '')) saveLineField.mutate({ lineId: l.id, note: v })
|
||||||
|
}}
|
||||||
|
className="mt-1 h-7 w-full rounded border border-transparent bg-transparent px-2 text-[11px] text-slate-500 hover:border-slate-200 focus-visible:border-brand-500 focus-visible:bg-white focus-visible:outline-none"
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
) : orDash(l.tenHangMuc)}
|
) : (
|
||||||
|
<>
|
||||||
|
<div>{orDash(l.tenHangMuc)}</div>
|
||||||
|
{l.note && <div className="mt-0.5 text-[11px] text-slate-500">{l.note}</div>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
{/* Dòng THÊM TAY có `peReferenceAmount = 0` — BE cố ý KHÔNG copy số tham
|
<td className="px-3 py-2 text-right align-top tabular-nums text-brand-800">
|
||||||
chiếu của NCC (đã nằm trọn ở dòng gốc), lặp lại là đếm đôi. Hiện
|
|
||||||
em-dash mờ thay vì "0 đ" để không đọc nhầm thành "tham chiếu bằng 0". */}
|
|
||||||
<td className="px-3 py-2 text-right tabular-nums text-slate-600">
|
|
||||||
{l.peReferenceAmount === 0 ? DASH : formatVnd(l.peReferenceAmount)}
|
|
||||||
</td>
|
|
||||||
{/* [S175] GIÁ ĐỀ XUẤT nhập tay — anh chốt "nhiều hạng mục → giá và ghi chú".
|
|
||||||
Lưu khi rời ô (onBlur) và chỉ khi SỐ ĐỔI THẬT, tránh bắn 1 request mỗi
|
|
||||||
lần click vào rồi click ra. */}
|
|
||||||
<td className="px-3 py-2 text-right tabular-nums text-brand-800">
|
|
||||||
{isDraft && !l.contractId ? (
|
{isDraft && !l.contractId ? (
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@ -599,52 +543,46 @@ export function KhkkDetailContent({
|
|||||||
step={1000}
|
step={1000}
|
||||||
defaultValue={l.proposedAmount}
|
defaultValue={l.proposedAmount}
|
||||||
disabled={saveLineField.isPending}
|
disabled={saveLineField.isPending}
|
||||||
aria-label={`Giá đề xuất dòng ${l.tenHangMuc ?? l.supplierName ?? l.id}`}
|
aria-label="Giá của hạng mục"
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
const v = Number(e.target.value)
|
const v = Number(e.target.value)
|
||||||
if (!Number.isFinite(v) || v < 0) { e.target.value = String(l.proposedAmount); return }
|
if (!Number.isFinite(v) || v < 0) { e.target.value = String(l.proposedAmount); return }
|
||||||
if (v !== l.proposedAmount) saveLineField.mutate({ lineId: l.id, proposedAmount: v })
|
if (v !== l.proposedAmount) saveLineField.mutate({ lineId: l.id, proposedAmount: v })
|
||||||
}}
|
}}
|
||||||
className="h-8 w-full min-w-[7rem] rounded-md border border-slate-200 bg-white px-2 text-right text-xs tabular-nums text-brand-800 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
|
className="h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-right text-xs tabular-nums text-brand-800 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
|
||||||
/>
|
/>
|
||||||
) : moneyOrDash(l.proposedAmount)}
|
) : moneyOrDash(l.proposedAmount)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right tabular-nums text-emerald-700">{moneyOrDash(l.approvedAmount)}</td>
|
<td className="px-3 py-2 text-right align-top">
|
||||||
<td className="px-3 py-2 text-slate-600">
|
{isDraft && !l.contractId && l.peReferenceAmount === 0 && (
|
||||||
{isDraft && !l.contractId ? (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
maxLength={1000}
|
|
||||||
defaultValue={l.note ?? ''}
|
|
||||||
placeholder="Ghi chú…"
|
|
||||||
disabled={saveLineField.isPending}
|
|
||||||
aria-label={`Ghi chú dòng ${l.tenHangMuc ?? l.supplierName ?? l.id}`}
|
|
||||||
onBlur={(e) => {
|
|
||||||
const v = e.target.value.trim()
|
|
||||||
if (v !== (l.note ?? '')) saveLineField.mutate({ lineId: l.id, note: v })
|
|
||||||
}}
|
|
||||||
className="h-8 w-full min-w-[6rem] rounded-md border border-slate-200 bg-white px-2 text-xs text-slate-700 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
|
|
||||||
/>
|
|
||||||
{/* Chỉ dòng THÊM TAY mới xoá được. Dòng gốc sinh từ phiếu Duyệt NCC
|
|
||||||
mang snapshot tham chiếu — BE cũng chặn, đây chỉ là không mời bấm. */}
|
|
||||||
{l.peReferenceAmount === 0 && (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { if (window.confirm(`Xoá hạng mục "${l.tenHangMuc ?? '(chưa gán)'}"?`)) deleteLine.mutate(l.id) }}
|
onClick={() => { if (window.confirm(`Xoá hạng mục "${l.tenHangMuc ?? '(chưa gán)'}"?`)) deleteLine.mutate(l.id) }}
|
||||||
disabled={deleteLine.isPending}
|
disabled={deleteLine.isPending}
|
||||||
title="Xoá hạng mục này"
|
title="Xoá hạng mục này"
|
||||||
className="shrink-0 rounded px-1 py-1 text-red-500 hover:bg-red-50"
|
className="rounded px-1 py-1 text-red-500 hover:bg-red-50"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
) : orDash(l.note)}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
{/* [S175 — anh: "chọn hạng mục tiền → thành tổng"] TỔNG = Σ giá các dòng.
|
||||||
|
🔴 KHÔNG có cột "giá tham chiếu" / "giá duyệt" nữa — anh chốt "ko cần so
|
||||||
|
sánh j với số duyệt PRO hay CCM j cả". Dữ-liệu 2 cột đó VẪN nằm trong DTO
|
||||||
|
(`peReferenceAmount` / `approvedAmount`), chỉ bỏ chỗ vẽ; `peReferenceAmount`
|
||||||
|
còn được dùng làm DẤU phân biệt dòng-gốc ⟂ dòng-thêm-tay ở nút xoá trên. */}
|
||||||
|
<tfoot>
|
||||||
|
<tr className="border-t-2 border-slate-300 bg-slate-50">
|
||||||
|
<td className="px-3 py-2 text-right text-xs font-semibold uppercase tracking-wide text-slate-600">Tổng</td>
|
||||||
|
<td className="px-3 py-2 text-right text-sm font-bold tabular-nums text-brand-800">
|
||||||
|
{formatVnd(plan.lines.reduce((s, l) => s + (l.proposedAmount ?? 0), 0))}
|
||||||
|
</td>
|
||||||
|
<td />
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -939,6 +877,62 @@ export function KhkkDetailContent({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* [S175 — anh: "phía dưới thêm các nút thao tác"] THANH NÚT ĐÁY, khuôn PE workspace.
|
||||||
|
Gom về ĐÂY những nút anh bảo gỡ khỏi chỗ khác: "Gửi duyệt" + "Xóa phiếu" (trước ở
|
||||||
|
khối HÀNH ĐỘNG panel 3). Panel 3 từ nay chỉ còn **Duyệt / Trả lại** — đúng câu anh
|
||||||
|
chốt "chỗ menu đang duyệt → Duyệt/Trả lại vậy thôi".
|
||||||
|
🔴 `sticky bottom-0`: phiếu dài, nút nằm cuối trang thì phải cuộn hết mới thấy —
|
||||||
|
dính đáy là để lúc nào cũng với tới được.
|
||||||
|
🔸 KHÔNG có nút "Lưu" rời: mọi ô ở màn này lưu NGAY khi rời ô (hạng mục / giá / ghi
|
||||||
|
chú / file). Thêm một nút "Lưu" không làm gì là hứa hão — nói thẳng bằng dòng chữ
|
||||||
|
bên cạnh thay vì vẽ nút giả. */}
|
||||||
|
{!readOnly && (
|
||||||
|
<div className="sticky bottom-0 z-10 flex flex-wrap items-center gap-3 border-t border-slate-200 bg-white/95 px-5 py-3 backdrop-blur">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Xóa phiếu ${plan.maKeHoach ?? ''}? Phiếu chuyển sang mục "Đã xóa".`)) deletePlan.mutate()
|
||||||
|
}}
|
||||||
|
disabled={deletePlan.isPending}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md bg-red-600 px-3 py-2 text-xs font-semibold text-white transition hover:bg-red-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
{deletePlan.isPending ? 'Đang xóa…' : 'Xóa phiếu'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="min-w-0 flex-1 text-[12px] text-emerald-700">
|
||||||
|
✓ Các thay đổi đã tự động lưu khi chỉnh sửa từng phần.
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
// Rào HIỂN THỊ mirror submit-guard BE: chưa pin quy trình, hoặc còn dòng chưa gán
|
||||||
|
// hạng mục ⇒ BE sẽ chặn. Nói TRƯỚC lý do thay vì để bấm rồi ăn 409.
|
||||||
|
const unassigned = plan.lines.filter(l => !l.catalogEntryId).length
|
||||||
|
const noWorkflow = !plan.approvalWorkflowId
|
||||||
|
const blocked = noWorkflow || unassigned > 0 || plan.lines.length === 0
|
||||||
|
const why = noWorkflow ? 'Chưa pin quy trình duyệt cho phiếu.'
|
||||||
|
: plan.lines.length === 0 ? 'Phiếu chưa có dòng NCC nào.'
|
||||||
|
: unassigned > 0 ? `Còn ${unassigned} dòng chưa gán hạng mục.`
|
||||||
|
: undefined
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { if (!blocked) submitPlan.mutate() }}
|
||||||
|
disabled={blocked || submitPlan.isPending}
|
||||||
|
title={why ?? 'Gửi phiếu vào quy trình duyệt'}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 rounded-md px-3 py-2 text-xs font-semibold text-white transition',
|
||||||
|
blocked ? 'cursor-not-allowed bg-slate-300' : 'bg-brand-600 hover:bg-brand-700',
|
||||||
|
submitPlan.isPending && 'opacity-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{submitPlan.isPending ? 'Đang gửi…' : 'Lưu & Gửi duyệt →'}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -81,6 +81,7 @@ export function KhkkWorkListPanel({
|
|||||||
selectedPeId,
|
selectedPeId,
|
||||||
selectedPlanId,
|
selectedPlanId,
|
||||||
onEditPlan,
|
onEditPlan,
|
||||||
|
group,
|
||||||
}: {
|
}: {
|
||||||
/** Bấm PE ở tầng trên → host nạp phiếu đó vào form tạo (Ở LẠI màn, không điều hướng). */
|
/** Bấm PE ở tầng trên → host nạp phiếu đó vào form tạo (Ở LẠI màn, không điều hướng). */
|
||||||
onPickPe: (peId: string) => void
|
onPickPe: (peId: string) => void
|
||||||
@ -92,6 +93,10 @@ export function KhkkWorkListPanel({
|
|||||||
/** [S175 · O7] Bấm bút chì trên card KHKK → host mở phiếu ở chế-độ SỬA. Không truyền ⇒
|
/** [S175 · O7] Bấm bút chì trên card KHKK → host mở phiếu ở chế-độ SỬA. Không truyền ⇒
|
||||||
* không vẽ bút chì (cột giữ đúng hình cũ). */
|
* không vẽ bút chì (cột giữ đúng hình cũ). */
|
||||||
onEditPlan?: (planId: string) => void
|
onEditPlan?: (planId: string) => void
|
||||||
|
/** [S175 - anh: "Loc theo quy trinh luon"] Nhom duyet cua leaf menu dang dung.
|
||||||
|
* Tang duoi CHI hien ke hoach thuoc dung nhom nay - dung o leaf N2 ma thay phieu
|
||||||
|
* N1/N7 la doc nham "nhom nay dang co viec". `null` = khong loc. */
|
||||||
|
group?: number | null
|
||||||
}) {
|
}) {
|
||||||
// [S175 — anh: "Cho hiển thị theo cây nằm dự án hạng mục"] Tập KHOÁ đang GẬP.
|
// [S175 — anh: "Cho hiển thị theo cây nằm dự án hạng mục"] Tập KHOÁ đang GẬP.
|
||||||
// Dùng tập-gập (không phải tập-mở) để mặc-định là MỞ HẾT: dự án/hạng mục mới xuất hiện sau
|
// Dùng tập-gập (không phải tập-mở) để mặc-định là MỞ HẾT: dự án/hạng mục mới xuất hiện sau
|
||||||
@ -156,7 +161,10 @@ export function KhkkWorkListPanel({
|
|||||||
// (2) cửa-sổ 30 phiếu mới nhất có thể toàn phiếu đã duyệt ⇒ tầng dưới rỗng dù còn nháp
|
// (2) cửa-sổ 30 phiếu mới nhất có thể toàn phiếu đã duyệt ⇒ tầng dưới rỗng dù còn nháp
|
||||||
// cũ hơn. Chấp nhận ở UAT; muốn chuẩn thì BE nhận `phases[]` rồi lọc SERVER-SIDE.
|
// cũ hơn. Chấp nhận ở UAT; muốn chuẩn thì BE nhận `phases[]` rồi lọc SERVER-SIDE.
|
||||||
const OPEN_PHASES: number[] = [KhkkPhase.DangSoanThao, KhkkPhase.TraLai, KhkkPhase.TuChoi]
|
const OPEN_PHASES: number[] = [KhkkPhase.DangSoanThao, KhkkPhase.TraLai, KhkkPhase.TuChoi]
|
||||||
const planItems = (plans.data?.items ?? []).filter((k) => OPEN_PHASES.includes(k.phase))
|
const planItems = (plans.data?.items ?? [])
|
||||||
|
.filter((k) => OPEN_PHASES.includes(k.phase))
|
||||||
|
// [S175] +loc NHOM. `group == null` => khong loc (giu duong cu cho host khong truyen).
|
||||||
|
.filter((k) => group == null || k.approvalGroup === group)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="flex min-h-0 flex-col overflow-hidden border-r border-slate-200 bg-white">
|
<aside className="flex min-h-0 flex-col overflow-hidden border-r border-slate-200 bg-white">
|
||||||
|
|||||||
@ -33,7 +33,7 @@
|
|||||||
import { useMemo, useRef, useState } from 'react'
|
import { useMemo, useRef, useState } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Download, Eye, History, Paperclip, Send, Trash2 } from 'lucide-react'
|
import { Download, Eye, History, Paperclip } from 'lucide-react'
|
||||||
import { AttachmentPreviewDialog, isPreviewable } from '@/components/pe/AttachmentPreviewDialog'
|
import { AttachmentPreviewDialog, isPreviewable } from '@/components/pe/AttachmentPreviewDialog'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Dialog } from '@/components/ui/Dialog'
|
import { Dialog } from '@/components/ui/Dialog'
|
||||||
@ -114,8 +114,8 @@ export function KhkkWorkflowPanel({
|
|||||||
|
|
||||||
const isWaiting = phase === KhkkPhase.ChoDuyet
|
const isWaiting = phase === KhkkPhase.ChoDuyet
|
||||||
const isApproved = phase === KhkkPhase.DaDuyet
|
const isApproved = phase === KhkkPhase.DaDuyet
|
||||||
const isDraftLike = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TraLai
|
// 🧊 [S175] `isDraftLike` / `canSubmit` / `canDeletePlan` ĐÃ GỠ cùng 2 nút "Gửi duyệt" +
|
||||||
const canDeletePlan = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TuChoi
|
// "Xóa phiếu" — panel này nay chỉ lo màn ĐANG DUYỆT (Duyệt / Trả lại).
|
||||||
|
|
||||||
const curStepIdx = plan.currentWorkflowStepIndex
|
const curStepIdx = plan.currentWorkflowStepIndex
|
||||||
const curLevelOrder = plan.currentApprovalLevelOrder
|
const curLevelOrder = plan.currentApprovalLevelOrder
|
||||||
@ -172,7 +172,6 @@ export function KhkkWorkflowPanel({
|
|||||||
// Nút "Gửi duyệt": guard THẬT ở BE (`CreatedBy ∨ DeptManager ∨ Admin`). FE KHÔNG suy được
|
// Nút "Gửi duyệt": guard THẬT ở BE (`CreatedBy ∨ DeptManager ∨ Admin`). FE KHÔNG suy được
|
||||||
// vế DeptManager ⇒ CỐ Ý không ẩn nút theo vai (ẩn = giấu nút với đúng người có quyền, im
|
// vế DeptManager ⇒ CỐ Ý không ẩn nút theo vai (ẩn = giấu nút với đúng người có quyền, im
|
||||||
// lặng — bug-class gotcha #44). Chỉ chặn ca chắc-chắn-hỏng: chưa pin quy trình.
|
// lặng — bug-class gotcha #44). Chỉ chặn ca chắc-chắn-hỏng: chưa pin quy trình.
|
||||||
const canSubmit = isDraftLike && !!plan.approvalWorkflowId
|
|
||||||
|
|
||||||
const approvalFiles: KhkkAttachmentDto[] =
|
const approvalFiles: KhkkAttachmentDto[] =
|
||||||
(plan.attachments ?? []).filter(a => a.purpose === KhkkAttachmentPurpose.ApprovalAttachment)
|
(plan.attachments ?? []).filter(a => a.purpose === KhkkAttachmentPurpose.ApprovalAttachment)
|
||||||
@ -434,24 +433,15 @@ export function KhkkWorkflowPanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 4. Hành động + nút Xóa ─────────────────────────────────────────── */}
|
{/* ── 4. Hành động + nút Xóa ─────────────────────────────────────────── */}
|
||||||
{(canSubmit || isWaiting || canDeletePlan) && (
|
{/* 🧊 [S175 — anh: "Bỏ hết đi" + "chỗ menu đang duyệt → Duyệt/Trả lại vậy thôi"]
|
||||||
|
"Gửi duyệt" và "Xóa phiếu" ĐÃ RỜI khỏi đây → nay nằm ở THANH NÚT ĐÁY của
|
||||||
|
`KhkkDetailContent`. Panel này từ nay CHỈ còn **Duyệt / Trả lại** — đúng phạm vi
|
||||||
|
"màn đang duyệt". Máy `KhkkTransitionAction.Submit` vẫn nguyên ở BE và ở thanh đáy;
|
||||||
|
gỡ ở đây chỉ là gỡ ĐƯỜNG BẤM TRÙNG, không phải bỏ chức năng. */}
|
||||||
|
{isWaiting && (
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">Hành động:</Label>
|
<Label className="text-xs">Hành động:</Label>
|
||||||
<div className="mt-1 flex flex-wrap gap-1.5">
|
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||||
{canSubmit && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => { setComment(''); setPendingFiles([]); setAction(KhkkTransitionAction.Submit) }}
|
|
||||||
className="inline-flex items-center gap-1 rounded border border-brand-300 px-2 py-1 text-[11px] font-bold text-brand-700 transition hover:bg-brand-50"
|
|
||||||
>
|
|
||||||
<Send className="h-3 w-3" /> Gửi duyệt
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{isDraftLike && !plan.approvalWorkflowId && (
|
|
||||||
<span className="rounded border border-dashed border-slate-300 px-2 py-1 text-[11px] text-slate-500">
|
|
||||||
Chưa pin quy trình duyệt — không gửi duyệt được.
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{isWaiting && (
|
{isWaiting && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@ -494,16 +484,6 @@ export function KhkkWorkflowPanel({
|
|||||||
và mở lại sau này chỉ là thêm nút. Gỡ cả máy = mất dữ-liệu lịch sử. */}
|
và mở lại sau này chỉ là thêm nút. Gỡ cả máy = mất dữ-liệu lịch sử. */}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{canDeletePlan && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setConfirmDelete(true)}
|
|
||||||
title="Xóa phiếu này (phiếu chuyển sang mục “Đã xóa”)"
|
|
||||||
className="inline-flex items-center gap-1 rounded border border-red-300 bg-white px-2 py-1 text-[11px] font-bold text-red-700 transition hover:bg-red-50"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" /> Xóa phiếu
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -28,7 +28,7 @@
|
|||||||
// 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.
|
// 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 { 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 { useSearchParams } from 'react-router-dom'
|
||||||
import { FileCheck, Pencil, X } from 'lucide-react'
|
import { FileCheck, Pencil, X } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { EmptyState } from '@/components/EmptyState'
|
import { EmptyState } from '@/components/EmptyState'
|
||||||
@ -39,7 +39,6 @@ import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
|
|||||||
import { KhkkWorkListPanel } from '@/components/khkk/KhkkWorkListPanel'
|
import { KhkkWorkListPanel } from '@/components/khkk/KhkkWorkListPanel'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { getErrorMessage } from '@/lib/apiError'
|
import { getErrorMessage } from '@/lib/apiError'
|
||||||
import { cn } from '@/lib/cn'
|
|
||||||
import { useAuth } from '@/contexts/AuthContext'
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
import {
|
import {
|
||||||
KHKK_APPROVAL_GROUP_DEFAULT,
|
KHKK_APPROVAL_GROUP_DEFAULT,
|
||||||
@ -67,7 +66,6 @@ interface WorkflowDto {
|
|||||||
// chỉnh"). Giữ lại thành hàm chết thì lượt sau có người gọi lại.
|
// 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() {
|
export function KhkkCreatePage() {
|
||||||
const navigate = useNavigate()
|
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
|
|
||||||
const groupParam = searchParams.get('group')
|
const groupParam = searchParams.get('group')
|
||||||
@ -203,6 +201,7 @@ export function KhkkCreatePage() {
|
|||||||
selectedPeId={purchaseEvaluationId || null}
|
selectedPeId={purchaseEvaluationId || null}
|
||||||
selectedPlanId={viewPlanId}
|
selectedPlanId={viewPlanId}
|
||||||
onEditPlan={(id) => { setViewPlanId(id); setEditPlan(true) }}
|
onEditPlan={(id) => { setViewPlanId(id); setEditPlan(true) }}
|
||||||
|
group={approvalGroup}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="min-h-0 overflow-y-auto bg-slate-50 p-4 lg:p-6">
|
<main className="min-h-0 overflow-y-auto bg-slate-50 p-4 lg:p-6">
|
||||||
@ -220,44 +219,35 @@ export function KhkkCreatePage() {
|
|||||||
const readOnly = !editPlan || !canEdit
|
const readOnly = !editPlan || !canEdit
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className={cn(
|
{/* 🧊 [S175 — anh: "bỏ mấy dòng này đi"] Dải banner "Đang xem kế hoạch…" ĐÃ GỠ.
|
||||||
'flex flex-wrap items-center justify-between gap-2 rounded-lg border px-4 py-2',
|
Nút của nó đi đâu: **Sửa** → bút chì ở danh sách (đường duy nhất, đúng như
|
||||||
readOnly ? 'border-violet-200 bg-violet-50' : 'border-emerald-300 bg-emerald-50',
|
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
|
||||||
<span className={cn('text-[12px] font-medium', readOnly ? 'text-violet-800' : 'text-emerald-800')}>
|
thì người dùng mở 1 phiếu xong là kẹt, không có đường lùi. */}
|
||||||
{readOnly
|
<div className="flex items-center justify-end gap-2">
|
||||||
? (editPlan && !canEdit
|
|
||||||
? 'Kế hoạch này không sửa được (đã gửi duyệt / đã duyệt, hoặc bạn không phải người soạn) — chỉ xem.'
|
|
||||||
: 'Đang xem kế hoạch — chỉ xem. Bấm bút chì ở danh sách để sửa.')
|
|
||||||
: '✎ Đang SỬA kế hoạch — thay đổi lưu thẳng vào phiếu.'}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{readOnly && canEdit && (
|
{readOnly && canEdit && (
|
||||||
<Button variant="outline" size="sm" onClick={() => setEditPlan(true)}>
|
<button
|
||||||
<Pencil className="mr-1.5 h-3.5 w-3.5" />
|
type="button"
|
||||||
Sửa
|
onClick={() => setEditPlan(true)}
|
||||||
</Button>
|
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>
|
||||||
)}
|
)}
|
||||||
{!readOnly && (
|
<button
|
||||||
<Button variant="outline" size="sm" onClick={() => setEditPlan(false)}>
|
type="button"
|
||||||
Xong
|
onClick={() => { setViewPlanId(null); setEditPlan(false) }}
|
||||||
</Button>
|
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"
|
||||||
)}
|
>
|
||||||
<Button variant="outline" size="sm" onClick={() => navigate(`/khkk/${viewPlanId}`)}>
|
<X className="h-3 w-3" /> Về form tạo
|
||||||
Mở trang đầy đủ
|
</button>
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => { setViewPlanId(null); setEditPlan(false) }}>
|
|
||||||
<X className="mr-1.5 h-3.5 w-3.5" />
|
|
||||||
Về form tạo
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{viewPlan.isLoading && <div className="text-sm text-slate-500">Đang tải…</div>}
|
{viewPlan.isLoading && <div className="text-sm text-slate-500">Đang tải…</div>}
|
||||||
{!viewPlan.isLoading && !viewPlan.data && (
|
{!viewPlan.isLoading && !viewPlan.data && (
|
||||||
<div className="text-sm text-red-600">Không tìm thấy phiếu.</div>
|
<div className="text-sm text-red-600">Không tìm thấy phiếu.</div>
|
||||||
)}
|
)}
|
||||||
{viewPlan.data && (
|
{viewPlan.data && (
|
||||||
<KhkkDetailContent plan={viewPlan.data} readOnly={readOnly} onChanged={() => viewPlan.refetch()} />
|
<KhkkDetailContent plan={viewPlan.data} readOnly={readOnly} onChanged={() => viewPlan.refetch()} onDeleted={() => { setViewPlanId(null); setEditPlan(false) }} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -170,7 +170,7 @@ export function KhkkDetailPage() {
|
|||||||
<KhkkWorkflowPanel plan={k} onChanged={invalidate} onDeleted={() => navigate(backToList)} />
|
<KhkkWorkflowPanel plan={k} onChanged={invalidate} onDeleted={() => navigate(backToList)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<KhkkDetailContent plan={k} readOnly={readOnly} onChanged={invalidate} />
|
<KhkkDetailContent plan={k} readOnly={readOnly} onChanged={invalidate} onDeleted={() => navigate("/khkk/list")} />
|
||||||
|
|
||||||
{bridgeOpen && <CreateContractFromKhkkDialog plan={k} onClose={() => setBridgeOpen(false)} />}
|
{bridgeOpen && <CreateContractFromKhkkDialog plan={k} onClose={() => setBridgeOpen(false)} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -27,9 +27,8 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { FileCheck, Plus, X } from 'lucide-react'
|
import { FileCheck, X } from 'lucide-react'
|
||||||
import { EmptyState } from '@/components/EmptyState'
|
import { EmptyState } from '@/components/EmptyState'
|
||||||
import { Button } from '@/components/ui/Button'
|
|
||||||
import { KhkkListPanel } from '@/components/khkk/KhkkListPanel'
|
import { KhkkListPanel } from '@/components/khkk/KhkkListPanel'
|
||||||
import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
|
import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
|
||||||
import { KhkkWorkflowPanel } from '@/components/khkk/KhkkWorkflowPanel'
|
import { KhkkWorkflowPanel } from '@/components/khkk/KhkkWorkflowPanel'
|
||||||
@ -162,12 +161,10 @@ export function KhkkListPage() {
|
|||||||
Tên nhóm đã có trong `headerTitle` ngay bên trái (dạng ngắn "— N2"), và dải 4 giai
|
Tên nhóm đã có trong `headerTitle` ngay bên trái (dạng ngắn "— N2"), và dải 4 giai
|
||||||
đoạn ở panel giữa đã chỉ rõ đang đứng ở GĐ2 ⇒ chip này là lần kể thứ ba. */}
|
đoạn ở panel giữa đã chỉ rõ đang đứng ở GĐ2 ⇒ chip này là lần kể thứ ba. */}
|
||||||
</div>
|
</div>
|
||||||
{!deletedView && (
|
{/* [S175 - anh: "Bo het di"] Nut "+ Lap ke hoach" o header DA GO. Duong lap ke
|
||||||
<Button onClick={() => navigate(group !== null ? `/khkk/create?group=${group}` : '/khkk/create')}>
|
hoach di tu man Thao tac (bam phieu PE o cot trai = nap thang vao form tao) —
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
do moi la duong dung nghiep-vu; nut o day de nguoi dung mo form TRONG khong
|
||||||
Lập kế hoạch
|
gan PE nao. */}
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[400px_1fr_360px]">
|
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[400px_1fr_360px]">
|
||||||
@ -239,7 +236,7 @@ export function KhkkListPage() {
|
|||||||
<div className="text-sm text-red-600">Không tìm thấy phiếu.</div>
|
<div className="text-sm text-red-600">Không tìm thấy phiếu.</div>
|
||||||
)}
|
)}
|
||||||
{selectedId && plan && (
|
{selectedId && plan && (
|
||||||
<KhkkDetailContent plan={plan} readOnly={readOnly} onChanged={() => detail.refetch()} />
|
<KhkkDetailContent plan={plan} readOnly={readOnly} onChanged={() => detail.refetch()} onDeleted={() => setId(null)} />
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@ -174,6 +174,9 @@ export interface KhkkListItemDto {
|
|||||||
purchaseEvaluationId: string
|
purchaseEvaluationId: string
|
||||||
peMaPhieu: string | null
|
peMaPhieu: string | null
|
||||||
peTenGoiThau: string | null // [F-5 S161] BE trả `PeTenGoiThau` (List :39 + Detail :124) — KHÔNG phải tenGoiThau
|
peTenGoiThau: string | null // [F-5 S161] BE trả `PeTenGoiThau` (List :39 + Detail :124) — KHÔNG phải tenGoiThau
|
||||||
|
// [S175] Hang muc CONG VIEC cua du an, tu phieu Duyet NCC nguon.
|
||||||
|
// KHAC `KhkkLineDto.tenHangMuc` (danh-muc SP-002 phan loai hop dong) - 2 truc khac nhau.
|
||||||
|
peWorkItemName: string | null
|
||||||
projectId: string
|
projectId: string
|
||||||
projectName: string | null
|
projectName: string | null
|
||||||
departmentId: string | null
|
departmentId: string | null
|
||||||
|
|||||||
@ -21,7 +21,7 @@
|
|||||||
import { useState, type ReactNode } from 'react'
|
import { useState, type ReactNode } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
ClipboardList, Download, Eye, FileCheck, Lock, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
|
ClipboardList, Download, Eye, FileCheck, Lock, Paperclip, Pencil, Plus, Trash2, Upload, Users,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
// [S175] Dùng LẠI dialog xem-trước của PE (đã có prop `basePath` từ đợt B) thay vì dựng bản
|
// [S175] Dùng LẠI dialog xem-trước của PE (đã có prop `basePath` từ đợt B) thay vì dựng bản
|
||||||
// thứ hai cho KHKK — 2 bản = 2 sự thật, sửa 1 chỗ quên chỗ kia.
|
// thứ hai cho KHKK — 2 bản = 2 sự thật, sửa 1 chỗ quên chỗ kia.
|
||||||
@ -40,7 +40,7 @@ import {
|
|||||||
DOSSIER_KIND_LABELS, DOSSIER_STATUS_BADGE, DOSSIER_STATUS_LABELS,
|
DOSSIER_KIND_LABELS, DOSSIER_STATUS_BADGE, DOSSIER_STATUS_LABELS,
|
||||||
DossierItemKind, DossierItemStatus,
|
DossierItemKind, DossierItemStatus,
|
||||||
KHKK_APPROVAL_GROUP_LABELS, KHKK_PHASE_BADGE, KHKK_PHASE_LABELS,
|
KHKK_APPROVAL_GROUP_LABELS, KHKK_PHASE_BADGE, KHKK_PHASE_LABELS,
|
||||||
KhkkAttachmentPurpose, KhkkPhase,
|
KhkkAttachmentPurpose, KhkkPhase, KhkkTransitionAction, type KhkkTransitionInput,
|
||||||
type DossierItemKindValue, type DossierItemStatusValue, type KhkkAttachmentDto,
|
type DossierItemKindValue, type DossierItemStatusValue, type KhkkAttachmentDto,
|
||||||
type KhkkDetailDto, type KhkkDossierItemDto, type KhkkPhaseValue,
|
type KhkkDetailDto, type KhkkDossierItemDto, type KhkkPhaseValue,
|
||||||
type UpsertKhkkDossierItemInput,
|
type UpsertKhkkDossierItemInput,
|
||||||
@ -149,17 +149,17 @@ export function KhkkDetailContent({
|
|||||||
plan,
|
plan,
|
||||||
readOnly,
|
readOnly,
|
||||||
onChanged,
|
onChanged,
|
||||||
|
onDeleted,
|
||||||
}: {
|
}: {
|
||||||
plan: KhkkDetailDto
|
plan: KhkkDetailDto
|
||||||
/** [P2-7] Host quyết: `true` khi phiếu ≠ Nháp/Trả lại HOẶC user ≠ người soạn. */
|
/** [P2-7] Host quyết: `true` khi phiếu ≠ Nháp/Trả lại HOẶC user ≠ người soạn. */
|
||||||
readOnly: boolean
|
readOnly: boolean
|
||||||
onChanged?: () => void
|
onChanged?: () => void
|
||||||
|
/** [S175] Gọi sau khi XÓA phiếu ở thanh nút đáy — host bỏ chọn / quay về danh sách. */
|
||||||
|
onDeleted?: () => void
|
||||||
}) {
|
}) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const id = plan.id
|
const id = plan.id
|
||||||
const [editHeader, setEditHeader] = useState(false)
|
|
||||||
const [ghiChu, setGhiChu] = useState('')
|
|
||||||
const [hoSoLink, setHoSoLink] = useState('')
|
|
||||||
const [itemForm, setItemForm] = useState<UpsertKhkkDossierItemInput | null>(null)
|
const [itemForm, setItemForm] = useState<UpsertKhkkDossierItemInput | null>(null)
|
||||||
// [S175] File đang xem-trước inline trong khối "File đính kèm" đã gộp.
|
// [S175] File đang xem-trước inline trong khối "File đính kèm" đã gộp.
|
||||||
const [previewAtt, setPreviewAtt] = useState<KhkkAttachmentDto | null>(null)
|
const [previewAtt, setPreviewAtt] = useState<KhkkAttachmentDto | null>(null)
|
||||||
@ -180,16 +180,6 @@ export function KhkkDetailContent({
|
|||||||
onChanged?.()
|
onChanged?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveHeader = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
await api.put(`/contract-signing-plans/${id}`, {
|
|
||||||
ghiChu: ghiChu.trim() || null,
|
|
||||||
hoSoLink: hoSoLink.trim() || null,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
onSuccess: () => { toast.success('Đã lưu'); setEditHeader(false); invalidate() },
|
|
||||||
onError: (e) => toast.error(getErrorMessage(e)),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Danh mục hạng mục SP-002 — chỉ tải khi phiếu còn sửa được (tránh 1 request thừa).
|
// Danh mục hạng mục SP-002 — chỉ tải khi phiếu còn sửa được (tránh 1 request thừa).
|
||||||
const catalogOptions = useQuery({
|
const catalogOptions = useQuery({
|
||||||
@ -234,6 +224,26 @@ export function KhkkDetailContent({
|
|||||||
onError: (e) => toast.error(getErrorMessage(e)),
|
onError: (e) => toast.error(getErrorMessage(e)),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// [S175 — anh: "phía dưới thêm các nút thao tác"] 2 mutation cho THANH NÚT ĐÁY.
|
||||||
|
// 🔴 CỐ Ý chỉ ôm nhánh **Submit** — nhánh Approve/Return có máy phức tạp (ô-tích
|
||||||
|
// level-finalize + upload file đính kèm SAU khi transition thành công) và nó ở LẠI
|
||||||
|
// `KhkkWorkflowPanel`. Nhân bản nhánh đó sang đây là đẻ sự-thật thứ hai cho đường DUYỆT;
|
||||||
|
// nhánh Submit thì chỉ có đúng `action` + `comment: null` nên bê là an toàn.
|
||||||
|
const submitPlan = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const body: KhkkTransitionInput = { action: KhkkTransitionAction.Submit, comment: null }
|
||||||
|
return api.post(`/contract-signing-plans/${id}/transitions`, body)
|
||||||
|
},
|
||||||
|
onSuccess: () => { toast.success('Đã gửi duyệt'); invalidate(); onChanged?.() },
|
||||||
|
onError: (e) => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deletePlan = useMutation({
|
||||||
|
mutationFn: async () => api.delete(`/contract-signing-plans/${id}`),
|
||||||
|
onSuccess: () => { toast.success('Đã xóa phiếu'); onDeleted?.() },
|
||||||
|
onError: (e) => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
|
||||||
const upsertItem = useMutation({
|
const upsertItem = useMutation({
|
||||||
mutationFn: async (body: UpsertKhkkDossierItemInput) => {
|
mutationFn: async (body: UpsertKhkkDossierItemInput) => {
|
||||||
if (!body.name.trim()) throw new Error('Vui lòng nhập tên căn cứ')
|
if (!body.name.trim()) throw new Error('Vui lòng nhập tên căn cứ')
|
||||||
@ -306,11 +316,6 @@ export function KhkkDetailContent({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const startEditHeader = () => {
|
|
||||||
setGhiChu(plan.ghiChu ?? '')
|
|
||||||
setHoSoLink(plan.hoSoLink ?? '')
|
|
||||||
setEditHeader(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// File "đính kèm khi duyệt" thuộc panel 3 (mục 5) — Section 4 chỉ liệt kê hồ-sơ của phiếu.
|
// File "đính kèm khi duyệt" thuộc panel 3 (mục 5) — Section 4 chỉ liệt kê hồ-sơ của phiếu.
|
||||||
// [S175 · O4] Chia 2 rổ: file GẮN VÀO một căn cứ hiện ngay dưới dòng căn cứ đó; Section 4
|
// [S175 · O4] Chia 2 rổ: file GẮN VÀO một căn cứ hiện ngay dưới dòng căn cứ đó; Section 4
|
||||||
@ -403,6 +408,11 @@ export function KhkkDetailContent({
|
|||||||
|
|
||||||
<div className="divide-y divide-slate-200">
|
<div className="divide-y divide-slate-200">
|
||||||
{/* ─────────────── 1 ─────────────── */}
|
{/* ─────────────── 1 ─────────────── */}
|
||||||
|
{/* 🧊 [S175 — anh: "Bỏ hết đi"] Nút "Sửa" ĐÃ GỠ khỏi `actions`. Nó chỉ mở form 2 trường
|
||||||
|
**Link hồ sơ + Ghi chú** — mà chính 2 trường đó anh cũng bảo bỏ khỏi màn ở cùng lượt.
|
||||||
|
Giữ nút mở form sửa một thứ không còn hiển thị ở đâu là mâu thuẫn.
|
||||||
|
🔸 Máy KHÔNG mất: `hoSoLink`/`ghiChu` vẫn nằm trong DTO + BE
|
||||||
|
(`UpdateContractSigningPlanDraftCommand`), muốn bày lại chỉ là thêm chỗ vẽ. */}
|
||||||
<Section
|
<Section
|
||||||
title="1. Thông tin kế hoạch"
|
title="1. Thông tin kế hoạch"
|
||||||
icon={<FileCheck className="h-4 w-4" />}
|
icon={<FileCheck className="h-4 w-4" />}
|
||||||
@ -410,13 +420,6 @@ export function KhkkDetailContent({
|
|||||||
head="text-brand-800"
|
head="text-brand-800"
|
||||||
chipBg="var(--color-brand-50)"
|
chipBg="var(--color-brand-50)"
|
||||||
chipFg="var(--color-brand-600)"
|
chipFg="var(--color-brand-600)"
|
||||||
actions={
|
|
||||||
isDraft && !editHeader ? (
|
|
||||||
<Button variant="outline" size="sm" onClick={startEditHeader}>
|
|
||||||
<Pencil className="mr-2 h-3.5 w-3.5" /> Sửa
|
|
||||||
</Button>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{/* [S175 — anh chốt trên ảnh khoanh đỏ] Mục 1 RÚT GỌN còn 4 ô:
|
{/* [S175 — anh chốt trên ảnh khoanh đỏ] Mục 1 RÚT GỌN còn 4 ô:
|
||||||
· "Bỏ hết đi" → GỠ **Phòng ban · Quy trình · Hạn xử lý**
|
· "Bỏ hết đi" → GỠ **Phòng ban · Quy trình · Hạn xử lý**
|
||||||
@ -428,68 +431,25 @@ export function KhkkDetailContent({
|
|||||||
<div className="grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2">
|
||||||
<Field label="Mã kế hoạch" value={plan.maKeHoach ?? '—'} mono />
|
<Field label="Mã kế hoạch" value={plan.maKeHoach ?? '—'} mono />
|
||||||
<Field label="Dự án" value={plan.projectName ?? '—'} />
|
<Field label="Dự án" value={plan.projectName ?? '—'} />
|
||||||
{/* 1 phiếu nay được N hạng mục (S175) ⇒ liệt kê DUY-NHẤT, giữ thứ tự dòng.
|
{/* [S175 — anh: "hạng mục của Dự án, Duyệt NCC ấy"] Đây là HẠNG MỤC CÔNG VIỆC của
|
||||||
Dòng chưa gán KHÔNG bịa tên — đếm riêng để người soạn biết còn nợ mấy dòng,
|
dự án, lấy từ phiếu Duyệt NCC nguồn (`PE.WorkItemId`).
|
||||||
vì submit-guard sẽ chặn trình duyệt khi còn dòng chưa gán. */}
|
🔴 KHÁC hẳn `line.tenHangMuc` — cái đó là danh-mục SP-002 dùng phân loại HỢP
|
||||||
<Field label="Hạng mục" full>
|
ĐỒNG (86 dòng, chọn ở mục 2). Hai thứ trùng tên tiếng Việt nhưng khác trục;
|
||||||
{(() => {
|
lượt trước lead bày nhầm cái SP-002 ở đây nên anh bảo "Bỏ đi". */}
|
||||||
const names = [...new Set(plan.lines.map(l => l.tenHangMuc).filter(Boolean))] as string[]
|
<Field label="Hạng mục (dự án)" value={plan.peWorkItemName ?? '—'} />
|
||||||
const unassigned = plan.lines.filter(l => !l.catalogEntryId).length
|
|
||||||
if (names.length === 0 && unassigned === 0) return DASH
|
|
||||||
return (
|
|
||||||
<span className="flex flex-wrap items-center gap-1">
|
|
||||||
{names.map(n => (
|
|
||||||
<span key={n} className="inline-flex items-center rounded bg-brand-50 px-1.5 py-0.5 text-[11px] font-medium text-brand-700">
|
|
||||||
{n}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{unassigned > 0 && (
|
|
||||||
<span className="inline-flex items-center rounded bg-amber-100 px-1.5 py-0.5 text-[11px] font-medium text-amber-800">
|
|
||||||
còn {unassigned} dòng chưa gán
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</Field>
|
|
||||||
<Field label="Người soạn" value={plan.drafterFullName ?? '—'} />
|
<Field label="Người soạn" value={plan.drafterFullName ?? '—'} />
|
||||||
<Field label="Ngày tạo" value={formatDateTime(plan.createdAt)} />
|
<Field label="Ngày tạo" value={formatDateTime(plan.createdAt)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editHeader ? (
|
{/* 🧊 [S175 — anh: "Bỏ đi"] Khối **Link hồ sơ + Ghi chú** (cả ô XEM lẫn form SỬA)
|
||||||
<div className="mt-4 space-y-3 rounded-lg border border-slate-200 bg-slate-50/60 p-4">
|
ĐÃ GỠ. Lượt trước lead mới gỡ ở lưới trên nên 2 ô này VẪN HIỆN ở khối dưới —
|
||||||
<div>
|
anh chụp lại đúng chỗ đó. Nay gỡ trọn cả 2 nhánh của ternary.
|
||||||
<Label htmlFor="hoSoLink">Link hồ sơ (NAS)</Label>
|
🔸 Máy KHÔNG mất: hoSoLink / ghiChu còn nguyên trong DTO + BE. */}
|
||||||
<Input id="hoSoLink" value={hoSoLink} onChange={(e) => setHoSoLink(e.target.value)} maxLength={1000} placeholder="\\\\nas\\du-an\\..." />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="ghiChu">Ghi chú</Label>
|
|
||||||
<Textarea id="ghiChu" value={ghiChu} onChange={(e) => setGhiChu(e.target.value)} maxLength={2000} rows={4} />
|
|
||||||
<div className="mt-1 text-xs text-muted-foreground">{ghiChu.length}/2000</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button variant="outline" size="sm" onClick={() => setEditHeader(false)}>Huỷ</Button>
|
|
||||||
<Button size="sm" onClick={() => saveHeader.mutate()} disabled={saveHeader.isPending}>
|
|
||||||
<Save className="mr-2 h-3.5 w-3.5" />
|
|
||||||
{saveHeader.isPending ? 'Đang lưu...' : 'Lưu'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2">
|
|
||||||
<Field label="Link hồ sơ" full>
|
|
||||||
{plan.hoSoLink ? <span className="break-all font-normal text-slate-700">{plan.hoSoLink}</span> : <span className="text-slate-300">—</span>}
|
|
||||||
</Field>
|
|
||||||
<Field label="Ghi chú" full>
|
|
||||||
{plan.ghiChu ? <span className="whitespace-pre-wrap font-normal text-slate-700">{plan.ghiChu}</span> : <span className="text-slate-300">—</span>}
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* ─────────────── 2 ─────────────── */}
|
{/* ─────────────── 2 ─────────────── */}
|
||||||
<Section
|
<Section
|
||||||
title={`2. Giá đề xuất theo NCC trúng thầu (${plan.lines.length} dòng)`}
|
title={`2. Hạng mục (${plan.lines.length} dòng)`}
|
||||||
icon={<Users className="h-4 w-4" />}
|
icon={<Users className="h-4 w-4" />}
|
||||||
accent="var(--color-teal-500)"
|
accent="var(--color-teal-500)"
|
||||||
head="text-teal-700"
|
head="text-teal-700"
|
||||||
@ -520,34 +480,20 @@ export function KhkkDetailContent({
|
|||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[880px] table-fixed text-xs">
|
<table className="w-full min-w-[880px] table-fixed text-xs">
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col className="w-[22%]" />
|
<col className="w-[52%]" />
|
||||||
<col className="w-[22%]" />
|
<col className="w-[32%]" />
|
||||||
<col className="w-[14%]" />
|
<col className="w-[16%]" />
|
||||||
<col className="w-[14%]" />
|
|
||||||
<col className="w-[14%]" />
|
|
||||||
<col className="w-[14%]" />
|
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<thead className="border-b border-slate-200 bg-slate-50/70">
|
<thead className="border-b border-slate-200 bg-slate-50/70">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="label-eyebrow px-3 py-2 text-left">Nhà cung cấp</th>
|
|
||||||
<th className="label-eyebrow px-3 py-2 text-left">Hạng mục</th>
|
<th className="label-eyebrow px-3 py-2 text-left">Hạng mục</th>
|
||||||
<th className="label-eyebrow px-3 py-2 text-right">Giá tham chiếu</th>
|
<th className="label-eyebrow px-3 py-2 text-right">Giá</th>
|
||||||
<th className="label-eyebrow px-3 py-2 text-right">Giá đề xuất</th>
|
<th className="label-eyebrow px-3 py-2 text-right"></th>
|
||||||
<th className="label-eyebrow px-3 py-2 text-right">Giá duyệt</th>
|
|
||||||
<th className="label-eyebrow px-3 py-2 text-left">Ghi chú</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{plan.lines.map((l) => (
|
{plan.lines.map((l) => (
|
||||||
<tr key={l.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/60">
|
<tr key={l.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/60">
|
||||||
<td className="px-3 py-2 font-medium text-brand-800">
|
|
||||||
{orDash(l.supplierName)}
|
|
||||||
{l.contractId && (
|
|
||||||
<a href={`/contracts/${l.contractId}`} className="ml-2 text-[10px] font-medium text-brand-600 hover:underline" title="Dòng này đã được đưa vào hợp đồng">
|
|
||||||
✓ HĐ
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-slate-700">
|
<td className="px-3 py-2 text-slate-700">
|
||||||
{isDraft && !l.contractId ? (
|
{isDraft && !l.contractId ? (
|
||||||
<>
|
<>
|
||||||
@ -555,12 +501,10 @@ export function KhkkDetailContent({
|
|||||||
value={l.catalogEntryId ?? ''}
|
value={l.catalogEntryId ?? ''}
|
||||||
onChange={(e) => e.target.value && assignLine.mutate({ lineId: l.id, catalogEntryId: e.target.value })}
|
onChange={(e) => e.target.value && assignLine.mutate({ lineId: l.id, catalogEntryId: e.target.value })}
|
||||||
disabled={assignLine.isPending}
|
disabled={assignLine.isPending}
|
||||||
aria-label={`Gán hạng mục SP-002 cho dòng ${l.supplierName ?? l.id}`}
|
aria-label={`Chọn hạng mục cho dòng ${l.id}`}
|
||||||
className="h-8 w-full min-w-[12rem] rounded-md border border-slate-200 bg-white px-2 text-xs text-slate-700 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
|
className="h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-xs text-slate-700 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
|
||||||
>
|
>
|
||||||
<option value="">— Chọn hạng mục —</option>
|
<option value="">— Chọn hạng mục —</option>
|
||||||
{/* Hạng mục ĐÃ LƯU mà không có trong options ⇒ chèn option đại diện,
|
|
||||||
không thì dòng đã gán TRÔNG như chưa gán. */}
|
|
||||||
{l.catalogEntryId && !(catalogOptions.data ?? []).some((c) => c.id === l.catalogEntryId) && (
|
{l.catalogEntryId && !(catalogOptions.data ?? []).some((c) => c.id === l.catalogEntryId) && (
|
||||||
<option value={l.catalogEntryId}>{l.tenHangMuc ?? '(hạng mục đã gán)'}</option>
|
<option value={l.catalogEntryId}>{l.tenHangMuc ?? '(hạng mục đã gán)'}</option>
|
||||||
)}
|
)}
|
||||||
@ -568,30 +512,30 @@ export function KhkkDetailContent({
|
|||||||
<option key={c.id} value={c.id}>{c.code} — {c.tenVi}</option>
|
<option key={c.id} value={c.id}>{c.code} — {c.tenVi}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{catalogOptions.isError && (
|
{/* Ghi chú xuống DÒNG PHỤ thay vì chiếm 1 cột — anh chốt bảng chỉ
|
||||||
<div className="mt-1 text-[11px] text-red-600">
|
còn "hạng mục + giá", nhưng ghi chú thì anh vẫn muốn nhập được. */}
|
||||||
Không tải được danh mục hạng mục —{' '}
|
<input
|
||||||
<button type="button" className="underline" onClick={() => catalogOptions.refetch()}>thử lại</button>
|
type="text"
|
||||||
</div>
|
maxLength={1000}
|
||||||
)}
|
defaultValue={l.note ?? ''}
|
||||||
{catalogOptions.isSuccess && (catalogOptions.data?.length ?? 0) === 0 && (
|
placeholder="Ghi chú (tùy chọn)…"
|
||||||
<div className="mt-1 text-[11px] text-amber-700">
|
disabled={saveLineField.isPending}
|
||||||
Nhóm duyệt N{plan.approvalGroup} chưa có hạng mục đang hiệu lực — liên hệ quản trị danh mục.
|
aria-label="Ghi chú dòng"
|
||||||
</div>
|
onBlur={(e) => {
|
||||||
)}
|
const v = e.target.value.trim()
|
||||||
|
if (v !== (l.note ?? '')) saveLineField.mutate({ lineId: l.id, note: v })
|
||||||
|
}}
|
||||||
|
className="mt-1 h-7 w-full rounded border border-transparent bg-transparent px-2 text-[11px] text-slate-500 hover:border-slate-200 focus-visible:border-brand-500 focus-visible:bg-white focus-visible:outline-none"
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
) : orDash(l.tenHangMuc)}
|
) : (
|
||||||
|
<>
|
||||||
|
<div>{orDash(l.tenHangMuc)}</div>
|
||||||
|
{l.note && <div className="mt-0.5 text-[11px] text-slate-500">{l.note}</div>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
{/* Dòng THÊM TAY có `peReferenceAmount = 0` — BE cố ý KHÔNG copy số tham
|
<td className="px-3 py-2 text-right align-top tabular-nums text-brand-800">
|
||||||
chiếu của NCC (đã nằm trọn ở dòng gốc), lặp lại là đếm đôi. Hiện
|
|
||||||
em-dash mờ thay vì "0 đ" để không đọc nhầm thành "tham chiếu bằng 0". */}
|
|
||||||
<td className="px-3 py-2 text-right tabular-nums text-slate-600">
|
|
||||||
{l.peReferenceAmount === 0 ? DASH : formatVnd(l.peReferenceAmount)}
|
|
||||||
</td>
|
|
||||||
{/* [S175] GIÁ ĐỀ XUẤT nhập tay — anh chốt "nhiều hạng mục → giá và ghi chú".
|
|
||||||
Lưu khi rời ô (onBlur) và chỉ khi SỐ ĐỔI THẬT, tránh bắn 1 request mỗi
|
|
||||||
lần click vào rồi click ra. */}
|
|
||||||
<td className="px-3 py-2 text-right tabular-nums text-brand-800">
|
|
||||||
{isDraft && !l.contractId ? (
|
{isDraft && !l.contractId ? (
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@ -599,52 +543,46 @@ export function KhkkDetailContent({
|
|||||||
step={1000}
|
step={1000}
|
||||||
defaultValue={l.proposedAmount}
|
defaultValue={l.proposedAmount}
|
||||||
disabled={saveLineField.isPending}
|
disabled={saveLineField.isPending}
|
||||||
aria-label={`Giá đề xuất dòng ${l.tenHangMuc ?? l.supplierName ?? l.id}`}
|
aria-label="Giá của hạng mục"
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
const v = Number(e.target.value)
|
const v = Number(e.target.value)
|
||||||
if (!Number.isFinite(v) || v < 0) { e.target.value = String(l.proposedAmount); return }
|
if (!Number.isFinite(v) || v < 0) { e.target.value = String(l.proposedAmount); return }
|
||||||
if (v !== l.proposedAmount) saveLineField.mutate({ lineId: l.id, proposedAmount: v })
|
if (v !== l.proposedAmount) saveLineField.mutate({ lineId: l.id, proposedAmount: v })
|
||||||
}}
|
}}
|
||||||
className="h-8 w-full min-w-[7rem] rounded-md border border-slate-200 bg-white px-2 text-right text-xs tabular-nums text-brand-800 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
|
className="h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-right text-xs tabular-nums text-brand-800 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
|
||||||
/>
|
/>
|
||||||
) : moneyOrDash(l.proposedAmount)}
|
) : moneyOrDash(l.proposedAmount)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right tabular-nums text-emerald-700">{moneyOrDash(l.approvedAmount)}</td>
|
<td className="px-3 py-2 text-right align-top">
|
||||||
<td className="px-3 py-2 text-slate-600">
|
{isDraft && !l.contractId && l.peReferenceAmount === 0 && (
|
||||||
{isDraft && !l.contractId ? (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
maxLength={1000}
|
|
||||||
defaultValue={l.note ?? ''}
|
|
||||||
placeholder="Ghi chú…"
|
|
||||||
disabled={saveLineField.isPending}
|
|
||||||
aria-label={`Ghi chú dòng ${l.tenHangMuc ?? l.supplierName ?? l.id}`}
|
|
||||||
onBlur={(e) => {
|
|
||||||
const v = e.target.value.trim()
|
|
||||||
if (v !== (l.note ?? '')) saveLineField.mutate({ lineId: l.id, note: v })
|
|
||||||
}}
|
|
||||||
className="h-8 w-full min-w-[6rem] rounded-md border border-slate-200 bg-white px-2 text-xs text-slate-700 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
|
|
||||||
/>
|
|
||||||
{/* Chỉ dòng THÊM TAY mới xoá được. Dòng gốc sinh từ phiếu Duyệt NCC
|
|
||||||
mang snapshot tham chiếu — BE cũng chặn, đây chỉ là không mời bấm. */}
|
|
||||||
{l.peReferenceAmount === 0 && (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { if (window.confirm(`Xoá hạng mục "${l.tenHangMuc ?? '(chưa gán)'}"?`)) deleteLine.mutate(l.id) }}
|
onClick={() => { if (window.confirm(`Xoá hạng mục "${l.tenHangMuc ?? '(chưa gán)'}"?`)) deleteLine.mutate(l.id) }}
|
||||||
disabled={deleteLine.isPending}
|
disabled={deleteLine.isPending}
|
||||||
title="Xoá hạng mục này"
|
title="Xoá hạng mục này"
|
||||||
className="shrink-0 rounded px-1 py-1 text-red-500 hover:bg-red-50"
|
className="rounded px-1 py-1 text-red-500 hover:bg-red-50"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
) : orDash(l.note)}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
{/* [S175 — anh: "chọn hạng mục tiền → thành tổng"] TỔNG = Σ giá các dòng.
|
||||||
|
🔴 KHÔNG có cột "giá tham chiếu" / "giá duyệt" nữa — anh chốt "ko cần so
|
||||||
|
sánh j với số duyệt PRO hay CCM j cả". Dữ-liệu 2 cột đó VẪN nằm trong DTO
|
||||||
|
(`peReferenceAmount` / `approvedAmount`), chỉ bỏ chỗ vẽ; `peReferenceAmount`
|
||||||
|
còn được dùng làm DẤU phân biệt dòng-gốc ⟂ dòng-thêm-tay ở nút xoá trên. */}
|
||||||
|
<tfoot>
|
||||||
|
<tr className="border-t-2 border-slate-300 bg-slate-50">
|
||||||
|
<td className="px-3 py-2 text-right text-xs font-semibold uppercase tracking-wide text-slate-600">Tổng</td>
|
||||||
|
<td className="px-3 py-2 text-right text-sm font-bold tabular-nums text-brand-800">
|
||||||
|
{formatVnd(plan.lines.reduce((s, l) => s + (l.proposedAmount ?? 0), 0))}
|
||||||
|
</td>
|
||||||
|
<td />
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -939,6 +877,62 @@ export function KhkkDetailContent({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* [S175 — anh: "phía dưới thêm các nút thao tác"] THANH NÚT ĐÁY, khuôn PE workspace.
|
||||||
|
Gom về ĐÂY những nút anh bảo gỡ khỏi chỗ khác: "Gửi duyệt" + "Xóa phiếu" (trước ở
|
||||||
|
khối HÀNH ĐỘNG panel 3). Panel 3 từ nay chỉ còn **Duyệt / Trả lại** — đúng câu anh
|
||||||
|
chốt "chỗ menu đang duyệt → Duyệt/Trả lại vậy thôi".
|
||||||
|
🔴 `sticky bottom-0`: phiếu dài, nút nằm cuối trang thì phải cuộn hết mới thấy —
|
||||||
|
dính đáy là để lúc nào cũng với tới được.
|
||||||
|
🔸 KHÔNG có nút "Lưu" rời: mọi ô ở màn này lưu NGAY khi rời ô (hạng mục / giá / ghi
|
||||||
|
chú / file). Thêm một nút "Lưu" không làm gì là hứa hão — nói thẳng bằng dòng chữ
|
||||||
|
bên cạnh thay vì vẽ nút giả. */}
|
||||||
|
{!readOnly && (
|
||||||
|
<div className="sticky bottom-0 z-10 flex flex-wrap items-center gap-3 border-t border-slate-200 bg-white/95 px-5 py-3 backdrop-blur">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Xóa phiếu ${plan.maKeHoach ?? ''}? Phiếu chuyển sang mục "Đã xóa".`)) deletePlan.mutate()
|
||||||
|
}}
|
||||||
|
disabled={deletePlan.isPending}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md bg-red-600 px-3 py-2 text-xs font-semibold text-white transition hover:bg-red-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
{deletePlan.isPending ? 'Đang xóa…' : 'Xóa phiếu'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="min-w-0 flex-1 text-[12px] text-emerald-700">
|
||||||
|
✓ Các thay đổi đã tự động lưu khi chỉnh sửa từng phần.
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
// Rào HIỂN THỊ mirror submit-guard BE: chưa pin quy trình, hoặc còn dòng chưa gán
|
||||||
|
// hạng mục ⇒ BE sẽ chặn. Nói TRƯỚC lý do thay vì để bấm rồi ăn 409.
|
||||||
|
const unassigned = plan.lines.filter(l => !l.catalogEntryId).length
|
||||||
|
const noWorkflow = !plan.approvalWorkflowId
|
||||||
|
const blocked = noWorkflow || unassigned > 0 || plan.lines.length === 0
|
||||||
|
const why = noWorkflow ? 'Chưa pin quy trình duyệt cho phiếu.'
|
||||||
|
: plan.lines.length === 0 ? 'Phiếu chưa có dòng NCC nào.'
|
||||||
|
: unassigned > 0 ? `Còn ${unassigned} dòng chưa gán hạng mục.`
|
||||||
|
: undefined
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { if (!blocked) submitPlan.mutate() }}
|
||||||
|
disabled={blocked || submitPlan.isPending}
|
||||||
|
title={why ?? 'Gửi phiếu vào quy trình duyệt'}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 rounded-md px-3 py-2 text-xs font-semibold text-white transition',
|
||||||
|
blocked ? 'cursor-not-allowed bg-slate-300' : 'bg-brand-600 hover:bg-brand-700',
|
||||||
|
submitPlan.isPending && 'opacity-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{submitPlan.isPending ? 'Đang gửi…' : 'Lưu & Gửi duyệt →'}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -81,6 +81,7 @@ export function KhkkWorkListPanel({
|
|||||||
selectedPeId,
|
selectedPeId,
|
||||||
selectedPlanId,
|
selectedPlanId,
|
||||||
onEditPlan,
|
onEditPlan,
|
||||||
|
group,
|
||||||
}: {
|
}: {
|
||||||
/** Bấm PE ở tầng trên → host nạp phiếu đó vào form tạo (Ở LẠI màn, không điều hướng). */
|
/** Bấm PE ở tầng trên → host nạp phiếu đó vào form tạo (Ở LẠI màn, không điều hướng). */
|
||||||
onPickPe: (peId: string) => void
|
onPickPe: (peId: string) => void
|
||||||
@ -92,6 +93,10 @@ export function KhkkWorkListPanel({
|
|||||||
/** [S175 · O7] Bấm bút chì trên card KHKK → host mở phiếu ở chế-độ SỬA. Không truyền ⇒
|
/** [S175 · O7] Bấm bút chì trên card KHKK → host mở phiếu ở chế-độ SỬA. Không truyền ⇒
|
||||||
* không vẽ bút chì (cột giữ đúng hình cũ). */
|
* không vẽ bút chì (cột giữ đúng hình cũ). */
|
||||||
onEditPlan?: (planId: string) => void
|
onEditPlan?: (planId: string) => void
|
||||||
|
/** [S175 - anh: "Loc theo quy trinh luon"] Nhom duyet cua leaf menu dang dung.
|
||||||
|
* Tang duoi CHI hien ke hoach thuoc dung nhom nay - dung o leaf N2 ma thay phieu
|
||||||
|
* N1/N7 la doc nham "nhom nay dang co viec". `null` = khong loc. */
|
||||||
|
group?: number | null
|
||||||
}) {
|
}) {
|
||||||
// [S175 — anh: "Cho hiển thị theo cây nằm dự án hạng mục"] Tập KHOÁ đang GẬP.
|
// [S175 — anh: "Cho hiển thị theo cây nằm dự án hạng mục"] Tập KHOÁ đang GẬP.
|
||||||
// Dùng tập-gập (không phải tập-mở) để mặc-định là MỞ HẾT: dự án/hạng mục mới xuất hiện sau
|
// Dùng tập-gập (không phải tập-mở) để mặc-định là MỞ HẾT: dự án/hạng mục mới xuất hiện sau
|
||||||
@ -156,7 +161,10 @@ export function KhkkWorkListPanel({
|
|||||||
// (2) cửa-sổ 30 phiếu mới nhất có thể toàn phiếu đã duyệt ⇒ tầng dưới rỗng dù còn nháp
|
// (2) cửa-sổ 30 phiếu mới nhất có thể toàn phiếu đã duyệt ⇒ tầng dưới rỗng dù còn nháp
|
||||||
// cũ hơn. Chấp nhận ở UAT; muốn chuẩn thì BE nhận `phases[]` rồi lọc SERVER-SIDE.
|
// cũ hơn. Chấp nhận ở UAT; muốn chuẩn thì BE nhận `phases[]` rồi lọc SERVER-SIDE.
|
||||||
const OPEN_PHASES: number[] = [KhkkPhase.DangSoanThao, KhkkPhase.TraLai, KhkkPhase.TuChoi]
|
const OPEN_PHASES: number[] = [KhkkPhase.DangSoanThao, KhkkPhase.TraLai, KhkkPhase.TuChoi]
|
||||||
const planItems = (plans.data?.items ?? []).filter((k) => OPEN_PHASES.includes(k.phase))
|
const planItems = (plans.data?.items ?? [])
|
||||||
|
.filter((k) => OPEN_PHASES.includes(k.phase))
|
||||||
|
// [S175] +loc NHOM. `group == null` => khong loc (giu duong cu cho host khong truyen).
|
||||||
|
.filter((k) => group == null || k.approvalGroup === group)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="flex min-h-0 flex-col overflow-hidden border-r border-slate-200 bg-white">
|
<aside className="flex min-h-0 flex-col overflow-hidden border-r border-slate-200 bg-white">
|
||||||
|
|||||||
@ -33,7 +33,7 @@
|
|||||||
import { useMemo, useRef, useState } from 'react'
|
import { useMemo, useRef, useState } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Download, Eye, History, Paperclip, Send, Trash2 } from 'lucide-react'
|
import { Download, Eye, History, Paperclip } from 'lucide-react'
|
||||||
import { AttachmentPreviewDialog, isPreviewable } from '@/components/pe/AttachmentPreviewDialog'
|
import { AttachmentPreviewDialog, isPreviewable } from '@/components/pe/AttachmentPreviewDialog'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Dialog } from '@/components/ui/Dialog'
|
import { Dialog } from '@/components/ui/Dialog'
|
||||||
@ -114,8 +114,8 @@ export function KhkkWorkflowPanel({
|
|||||||
|
|
||||||
const isWaiting = phase === KhkkPhase.ChoDuyet
|
const isWaiting = phase === KhkkPhase.ChoDuyet
|
||||||
const isApproved = phase === KhkkPhase.DaDuyet
|
const isApproved = phase === KhkkPhase.DaDuyet
|
||||||
const isDraftLike = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TraLai
|
// 🧊 [S175] `isDraftLike` / `canSubmit` / `canDeletePlan` ĐÃ GỠ cùng 2 nút "Gửi duyệt" +
|
||||||
const canDeletePlan = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TuChoi
|
// "Xóa phiếu" — panel này nay chỉ lo màn ĐANG DUYỆT (Duyệt / Trả lại).
|
||||||
|
|
||||||
const curStepIdx = plan.currentWorkflowStepIndex
|
const curStepIdx = plan.currentWorkflowStepIndex
|
||||||
const curLevelOrder = plan.currentApprovalLevelOrder
|
const curLevelOrder = plan.currentApprovalLevelOrder
|
||||||
@ -172,7 +172,6 @@ export function KhkkWorkflowPanel({
|
|||||||
// Nút "Gửi duyệt": guard THẬT ở BE (`CreatedBy ∨ DeptManager ∨ Admin`). FE KHÔNG suy được
|
// Nút "Gửi duyệt": guard THẬT ở BE (`CreatedBy ∨ DeptManager ∨ Admin`). FE KHÔNG suy được
|
||||||
// vế DeptManager ⇒ CỐ Ý không ẩn nút theo vai (ẩn = giấu nút với đúng người có quyền, im
|
// vế DeptManager ⇒ CỐ Ý không ẩn nút theo vai (ẩn = giấu nút với đúng người có quyền, im
|
||||||
// lặng — bug-class gotcha #44). Chỉ chặn ca chắc-chắn-hỏng: chưa pin quy trình.
|
// lặng — bug-class gotcha #44). Chỉ chặn ca chắc-chắn-hỏng: chưa pin quy trình.
|
||||||
const canSubmit = isDraftLike && !!plan.approvalWorkflowId
|
|
||||||
|
|
||||||
const approvalFiles: KhkkAttachmentDto[] =
|
const approvalFiles: KhkkAttachmentDto[] =
|
||||||
(plan.attachments ?? []).filter(a => a.purpose === KhkkAttachmentPurpose.ApprovalAttachment)
|
(plan.attachments ?? []).filter(a => a.purpose === KhkkAttachmentPurpose.ApprovalAttachment)
|
||||||
@ -434,24 +433,15 @@ export function KhkkWorkflowPanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 4. Hành động + nút Xóa ─────────────────────────────────────────── */}
|
{/* ── 4. Hành động + nút Xóa ─────────────────────────────────────────── */}
|
||||||
{(canSubmit || isWaiting || canDeletePlan) && (
|
{/* 🧊 [S175 — anh: "Bỏ hết đi" + "chỗ menu đang duyệt → Duyệt/Trả lại vậy thôi"]
|
||||||
|
"Gửi duyệt" và "Xóa phiếu" ĐÃ RỜI khỏi đây → nay nằm ở THANH NÚT ĐÁY của
|
||||||
|
`KhkkDetailContent`. Panel này từ nay CHỈ còn **Duyệt / Trả lại** — đúng phạm vi
|
||||||
|
"màn đang duyệt". Máy `KhkkTransitionAction.Submit` vẫn nguyên ở BE và ở thanh đáy;
|
||||||
|
gỡ ở đây chỉ là gỡ ĐƯỜNG BẤM TRÙNG, không phải bỏ chức năng. */}
|
||||||
|
{isWaiting && (
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">Hành động:</Label>
|
<Label className="text-xs">Hành động:</Label>
|
||||||
<div className="mt-1 flex flex-wrap gap-1.5">
|
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||||
{canSubmit && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => { setComment(''); setPendingFiles([]); setAction(KhkkTransitionAction.Submit) }}
|
|
||||||
className="inline-flex items-center gap-1 rounded border border-brand-300 px-2 py-1 text-[11px] font-bold text-brand-700 transition hover:bg-brand-50"
|
|
||||||
>
|
|
||||||
<Send className="h-3 w-3" /> Gửi duyệt
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{isDraftLike && !plan.approvalWorkflowId && (
|
|
||||||
<span className="rounded border border-dashed border-slate-300 px-2 py-1 text-[11px] text-slate-500">
|
|
||||||
Chưa pin quy trình duyệt — không gửi duyệt được.
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{isWaiting && (
|
{isWaiting && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@ -494,16 +484,6 @@ export function KhkkWorkflowPanel({
|
|||||||
và mở lại sau này chỉ là thêm nút. Gỡ cả máy = mất dữ-liệu lịch sử. */}
|
và mở lại sau này chỉ là thêm nút. Gỡ cả máy = mất dữ-liệu lịch sử. */}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{canDeletePlan && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setConfirmDelete(true)}
|
|
||||||
title="Xóa phiếu này (phiếu chuyển sang mục “Đã xóa”)"
|
|
||||||
className="inline-flex items-center gap-1 rounded border border-red-300 bg-white px-2 py-1 text-[11px] font-bold text-red-700 transition hover:bg-red-50"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" /> Xóa phiếu
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -28,7 +28,7 @@
|
|||||||
// 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.
|
// 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 { 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 { useSearchParams } from 'react-router-dom'
|
||||||
import { FileCheck, Pencil, X } from 'lucide-react'
|
import { FileCheck, Pencil, X } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { EmptyState } from '@/components/EmptyState'
|
import { EmptyState } from '@/components/EmptyState'
|
||||||
@ -39,7 +39,6 @@ import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
|
|||||||
import { KhkkWorkListPanel } from '@/components/khkk/KhkkWorkListPanel'
|
import { KhkkWorkListPanel } from '@/components/khkk/KhkkWorkListPanel'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { getErrorMessage } from '@/lib/apiError'
|
import { getErrorMessage } from '@/lib/apiError'
|
||||||
import { cn } from '@/lib/cn'
|
|
||||||
import { useAuth } from '@/contexts/AuthContext'
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
import {
|
import {
|
||||||
KHKK_APPROVAL_GROUP_DEFAULT,
|
KHKK_APPROVAL_GROUP_DEFAULT,
|
||||||
@ -67,7 +66,6 @@ interface WorkflowDto {
|
|||||||
// chỉnh"). Giữ lại thành hàm chết thì lượt sau có người gọi lại.
|
// 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() {
|
export function KhkkCreatePage() {
|
||||||
const navigate = useNavigate()
|
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
|
|
||||||
const groupParam = searchParams.get('group')
|
const groupParam = searchParams.get('group')
|
||||||
@ -203,6 +201,7 @@ export function KhkkCreatePage() {
|
|||||||
selectedPeId={purchaseEvaluationId || null}
|
selectedPeId={purchaseEvaluationId || null}
|
||||||
selectedPlanId={viewPlanId}
|
selectedPlanId={viewPlanId}
|
||||||
onEditPlan={(id) => { setViewPlanId(id); setEditPlan(true) }}
|
onEditPlan={(id) => { setViewPlanId(id); setEditPlan(true) }}
|
||||||
|
group={approvalGroup}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="min-h-0 overflow-y-auto bg-slate-50 p-4 lg:p-6">
|
<main className="min-h-0 overflow-y-auto bg-slate-50 p-4 lg:p-6">
|
||||||
@ -220,44 +219,35 @@ export function KhkkCreatePage() {
|
|||||||
const readOnly = !editPlan || !canEdit
|
const readOnly = !editPlan || !canEdit
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className={cn(
|
{/* 🧊 [S175 — anh: "bỏ mấy dòng này đi"] Dải banner "Đang xem kế hoạch…" ĐÃ GỠ.
|
||||||
'flex flex-wrap items-center justify-between gap-2 rounded-lg border px-4 py-2',
|
Nút của nó đi đâu: **Sửa** → bút chì ở danh sách (đường duy nhất, đúng như
|
||||||
readOnly ? 'border-violet-200 bg-violet-50' : 'border-emerald-300 bg-emerald-50',
|
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
|
||||||
<span className={cn('text-[12px] font-medium', readOnly ? 'text-violet-800' : 'text-emerald-800')}>
|
thì người dùng mở 1 phiếu xong là kẹt, không có đường lùi. */}
|
||||||
{readOnly
|
<div className="flex items-center justify-end gap-2">
|
||||||
? (editPlan && !canEdit
|
|
||||||
? 'Kế hoạch này không sửa được (đã gửi duyệt / đã duyệt, hoặc bạn không phải người soạn) — chỉ xem.'
|
|
||||||
: 'Đang xem kế hoạch — chỉ xem. Bấm bút chì ở danh sách để sửa.')
|
|
||||||
: '✎ Đang SỬA kế hoạch — thay đổi lưu thẳng vào phiếu.'}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{readOnly && canEdit && (
|
{readOnly && canEdit && (
|
||||||
<Button variant="outline" size="sm" onClick={() => setEditPlan(true)}>
|
<button
|
||||||
<Pencil className="mr-1.5 h-3.5 w-3.5" />
|
type="button"
|
||||||
Sửa
|
onClick={() => setEditPlan(true)}
|
||||||
</Button>
|
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>
|
||||||
)}
|
)}
|
||||||
{!readOnly && (
|
<button
|
||||||
<Button variant="outline" size="sm" onClick={() => setEditPlan(false)}>
|
type="button"
|
||||||
Xong
|
onClick={() => { setViewPlanId(null); setEditPlan(false) }}
|
||||||
</Button>
|
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"
|
||||||
)}
|
>
|
||||||
<Button variant="outline" size="sm" onClick={() => navigate(`/khkk/${viewPlanId}`)}>
|
<X className="h-3 w-3" /> Về form tạo
|
||||||
Mở trang đầy đủ
|
</button>
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => { setViewPlanId(null); setEditPlan(false) }}>
|
|
||||||
<X className="mr-1.5 h-3.5 w-3.5" />
|
|
||||||
Về form tạo
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{viewPlan.isLoading && <div className="text-sm text-slate-500">Đang tải…</div>}
|
{viewPlan.isLoading && <div className="text-sm text-slate-500">Đang tải…</div>}
|
||||||
{!viewPlan.isLoading && !viewPlan.data && (
|
{!viewPlan.isLoading && !viewPlan.data && (
|
||||||
<div className="text-sm text-red-600">Không tìm thấy phiếu.</div>
|
<div className="text-sm text-red-600">Không tìm thấy phiếu.</div>
|
||||||
)}
|
)}
|
||||||
{viewPlan.data && (
|
{viewPlan.data && (
|
||||||
<KhkkDetailContent plan={viewPlan.data} readOnly={readOnly} onChanged={() => viewPlan.refetch()} />
|
<KhkkDetailContent plan={viewPlan.data} readOnly={readOnly} onChanged={() => viewPlan.refetch()} onDeleted={() => { setViewPlanId(null); setEditPlan(false) }} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -170,7 +170,7 @@ export function KhkkDetailPage() {
|
|||||||
<KhkkWorkflowPanel plan={k} onChanged={invalidate} onDeleted={() => navigate(backToList)} />
|
<KhkkWorkflowPanel plan={k} onChanged={invalidate} onDeleted={() => navigate(backToList)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<KhkkDetailContent plan={k} readOnly={readOnly} onChanged={invalidate} />
|
<KhkkDetailContent plan={k} readOnly={readOnly} onChanged={invalidate} onDeleted={() => navigate("/khkk/list")} />
|
||||||
|
|
||||||
{bridgeOpen && <CreateContractFromKhkkDialog plan={k} onClose={() => setBridgeOpen(false)} />}
|
{bridgeOpen && <CreateContractFromKhkkDialog plan={k} onClose={() => setBridgeOpen(false)} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -27,9 +27,8 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { FileCheck, Plus, X } from 'lucide-react'
|
import { FileCheck, X } from 'lucide-react'
|
||||||
import { EmptyState } from '@/components/EmptyState'
|
import { EmptyState } from '@/components/EmptyState'
|
||||||
import { Button } from '@/components/ui/Button'
|
|
||||||
import { KhkkListPanel } from '@/components/khkk/KhkkListPanel'
|
import { KhkkListPanel } from '@/components/khkk/KhkkListPanel'
|
||||||
import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
|
import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
|
||||||
import { KhkkWorkflowPanel } from '@/components/khkk/KhkkWorkflowPanel'
|
import { KhkkWorkflowPanel } from '@/components/khkk/KhkkWorkflowPanel'
|
||||||
@ -162,12 +161,10 @@ export function KhkkListPage() {
|
|||||||
Tên nhóm đã có trong `headerTitle` ngay bên trái (dạng ngắn "— N2"), và dải 4 giai
|
Tên nhóm đã có trong `headerTitle` ngay bên trái (dạng ngắn "— N2"), và dải 4 giai
|
||||||
đoạn ở panel giữa đã chỉ rõ đang đứng ở GĐ2 ⇒ chip này là lần kể thứ ba. */}
|
đoạn ở panel giữa đã chỉ rõ đang đứng ở GĐ2 ⇒ chip này là lần kể thứ ba. */}
|
||||||
</div>
|
</div>
|
||||||
{!deletedView && (
|
{/* [S175 - anh: "Bo het di"] Nut "+ Lap ke hoach" o header DA GO. Duong lap ke
|
||||||
<Button onClick={() => navigate(group !== null ? `/khkk/create?group=${group}` : '/khkk/create')}>
|
hoach di tu man Thao tac (bam phieu PE o cot trai = nap thang vao form tao) —
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
do moi la duong dung nghiep-vu; nut o day de nguoi dung mo form TRONG khong
|
||||||
Lập kế hoạch
|
gan PE nao. */}
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[400px_1fr_360px]">
|
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[400px_1fr_360px]">
|
||||||
@ -239,7 +236,7 @@ export function KhkkListPage() {
|
|||||||
<div className="text-sm text-red-600">Không tìm thấy phiếu.</div>
|
<div className="text-sm text-red-600">Không tìm thấy phiếu.</div>
|
||||||
)}
|
)}
|
||||||
{selectedId && plan && (
|
{selectedId && plan && (
|
||||||
<KhkkDetailContent plan={plan} readOnly={readOnly} onChanged={() => detail.refetch()} />
|
<KhkkDetailContent plan={plan} readOnly={readOnly} onChanged={() => detail.refetch()} onDeleted={() => setId(null)} />
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@ -174,6 +174,9 @@ export interface KhkkListItemDto {
|
|||||||
purchaseEvaluationId: string
|
purchaseEvaluationId: string
|
||||||
peMaPhieu: string | null
|
peMaPhieu: string | null
|
||||||
peTenGoiThau: string | null // [F-5 S161] BE trả `PeTenGoiThau` (List :39 + Detail :124) — KHÔNG phải tenGoiThau
|
peTenGoiThau: string | null // [F-5 S161] BE trả `PeTenGoiThau` (List :39 + Detail :124) — KHÔNG phải tenGoiThau
|
||||||
|
// [S175] Hang muc CONG VIEC cua du an, tu phieu Duyet NCC nguon.
|
||||||
|
// KHAC `KhkkLineDto.tenHangMuc` (danh-muc SP-002 phan loai hop dong) - 2 truc khac nhau.
|
||||||
|
peWorkItemName: string | null
|
||||||
projectId: string
|
projectId: string
|
||||||
projectName: string | null
|
projectName: string | null
|
||||||
departmentId: string | null
|
departmentId: string | null
|
||||||
|
|||||||
@ -173,6 +173,9 @@ public record ContractSigningPlanDetailDto(
|
|||||||
Guid PurchaseEvaluationId,
|
Guid PurchaseEvaluationId,
|
||||||
string? PeMaPhieu,
|
string? PeMaPhieu,
|
||||||
string? PeTenGoiThau,
|
string? PeTenGoiThau,
|
||||||
|
/// [S175] Hang muc CONG VIEC cua du an, lay tu phieu Duyet NCC nguon (PE.WorkItemId).
|
||||||
|
/// KHONG phai Line.TenHangMuc (do la danh-muc SP-002 phan loai hop dong).
|
||||||
|
string? PeWorkItemName,
|
||||||
Guid ProjectId,
|
Guid ProjectId,
|
||||||
string? ProjectName,
|
string? ProjectName,
|
||||||
string? ProjectCode,
|
string? ProjectCode,
|
||||||
@ -911,10 +914,21 @@ public class GetContractSigningPlanQueryHandler(IApplicationDbContext db, ICurre
|
|||||||
// [W2 S161 — F-8] rào xem mirror PE (list bịt thì detail phải bịt cùng vị ngữ).
|
// [W2 S161 — F-8] rào xem mirror PE (list bịt thì detail phải bịt cùng vị ngữ).
|
||||||
await ContractSigningPlanScope.EnsureCanViewAsync(db, currentUser, plan, ct);
|
await ContractSigningPlanScope.EnsureCanViewAsync(db, currentUser, plan, ct);
|
||||||
|
|
||||||
|
// [S175] +`WorkItemId` để lấy HẠNG MỤC CÔNG VIỆC CỦA DỰ ÁN — anh: "hạng mục của Dự án,
|
||||||
|
// Duyệt NCC ấy". 🔴 KHÁC hẳn `TenHangMuc` của Line: cái đó là hạng mục danh-mục SP-002
|
||||||
|
// (86 dòng, dùng để phân loại HỢP ĐỒNG); cái này là gói việc của dự án trên phiếu PE.
|
||||||
|
// Trùng tên tiếng Việt nhưng hai trục khác nhau — trộn là chỉ sai gói thầu.
|
||||||
var pe = await db.PurchaseEvaluations.AsNoTracking()
|
var pe = await db.PurchaseEvaluations.AsNoTracking()
|
||||||
.Where(p => p.Id == plan.PurchaseEvaluationId)
|
.Where(p => p.Id == plan.PurchaseEvaluationId)
|
||||||
.Select(p => new { p.MaPhieu, p.TenGoiThau })
|
.Select(p => new { p.MaPhieu, p.TenGoiThau, p.WorkItemId })
|
||||||
.FirstOrDefaultAsync(ct);
|
.FirstOrDefaultAsync(ct);
|
||||||
|
// `WorkItemId` là loose-Guid (Mig 49 — KHÔNG FK vật lý) ⇒ join tay, null-safe.
|
||||||
|
var peWorkItemName = pe != null && pe.WorkItemId != null
|
||||||
|
? await db.WorkItems.AsNoTracking()
|
||||||
|
.Where(w => w.Id == pe.WorkItemId)
|
||||||
|
.Select(w => w.Name)
|
||||||
|
.FirstOrDefaultAsync(ct)
|
||||||
|
: null;
|
||||||
var project = await db.Projects.AsNoTracking()
|
var project = await db.Projects.AsNoTracking()
|
||||||
.Where(p => p.Id == plan.ProjectId)
|
.Where(p => p.Id == plan.ProjectId)
|
||||||
.Select(p => new { p.Name, p.Code })
|
.Select(p => new { p.Name, p.Code })
|
||||||
@ -1115,6 +1129,7 @@ public class GetContractSigningPlanQueryHandler(IApplicationDbContext db, ICurre
|
|||||||
|
|
||||||
return new ContractSigningPlanDetailDto(
|
return new ContractSigningPlanDetailDto(
|
||||||
plan.Id, plan.MaKeHoach, plan.PurchaseEvaluationId, pe?.MaPhieu, pe?.TenGoiThau,
|
plan.Id, plan.MaKeHoach, plan.PurchaseEvaluationId, pe?.MaPhieu, pe?.TenGoiThau,
|
||||||
|
peWorkItemName,
|
||||||
plan.ProjectId, project?.Name, project?.Code,
|
plan.ProjectId, project?.Name, project?.Code,
|
||||||
plan.DepartmentId, deptName, plan.DrafterUserId, drafterName,
|
plan.DepartmentId, deptName, plan.DrafterUserId, drafterName,
|
||||||
plan.Phase, plan.ApprovalWorkflowId, wfCode, wfName,
|
plan.Phase, plan.ApprovalWorkflowId, wfCode, wfName,
|
||||||
|
|||||||
Reference in New Issue
Block a user