[CLAUDE] Contract: UAT S174 dot-A — 3 LOI THAT + O2 bo chu SAP + bo nut Tu choi (FE-only, 0-mig)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m41s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m41s
Nguon: /fable-clone frontend-designer ensemble 5 lane (wf_016b1bed-b85, 5/5 xong, 0 chet, 755K tok). Spec day du: runs/2026-08-05-S174-khkk-style-audit/spec-khkk-style-dong-bo-05-08-2026.md === DOT A — LOI THAT (khong phai style) === A1 🔴 Nut "Tai xuong" file dinh kem KHKK BI 401 — lane L1 bat, lead verify. Cu: <a href="/api/contract-signing-plans/.../download"> TRAN. JWT chi gan boi interceptor axios (lib/api.ts:18); the <a> KHONG di qua axios => request toi [Authorize(Policy="KeHoachKyKet.Read")] (ContractSigningPlansController.cs:222-223) di TAY KHONG => 401, nut khong tai duoc. Moi: ham downloadAttachment() dung khuon PE (PeWorkflowPanel.tsx:327-342) — api.get(responseType:'blob') -> createObjectURL -> click ngam -> revoke. A2 🔴 REGRESSION DO CHINH LEAD GAY RA TRONG PHIEN NAY (lane L3 bat). Man Thao tac dung <main className="hidden ... lg:block"> => duoi lg chi con panel-1, KHONG co form => MOBILE KHONG TAO DUOC PHIEU (bam phieu xong khong co cho bam "Tao"). Va 2 ve: (1) <main> bo hidden, hien moi kho; (2) mobile xep DOC voi panel-1 chot tran 14rem — khong chot thi danh sach dai day form roi khoi man, dung cai ngo cut vua va. A3 Tra no da khai voi owner: trang chi tiet KHKK tren mobile mat nut Gui duyet / Xoa phieu. Giu KhkkWorkflowPanel o DUNG lg:hidden — desktop van sach (panel-3 lo), mobile thao tac duoc. === OWNER CHOT VONG NAY === O2 "Dung bo chu sap di" — go SoonChip(). Lane L4 do: KHONG giai doan nao con dung nghia "sap" (ca 4 deu fetch API that); SoonChip KHONG export, 8 hit noi bo => 0 host ngoai phu thuoc. 🔴 Go != "thay het bang 0". BA ca, BA cach xu dung nghia: · 2 ca dem-duoc -> luon in SO (ke ca 0) · ca KHOA-QUYEN -> BO TRANG (in 0 la NOI DOI: co the day phieu, chi la khong duoc xem, va cung khong duoc phep goi API de biet). O khoa + title du. · ca FAILED (dau —) -> GIU NGUYEN ("khong tai duoc" khac "co 0 phieu", dung gop) Cau-4 "vay thi bo luon tu choi ~ tuong tu PE" — go nut TU CHOI khoi KhkkWorkflowPanel. PE co code tu-choi nhung THUC TE khong hien (BE go khoi nextPhases + FE loc, UAT S60). 🔴 Chi go DUONG BAM o UI. May BE (KhkkTransitionAction.Reject, phase TuChoi=99) GIU NGUYEN: phieu cu da tu-choi van hien dung, mo lai sau chi la them nut. B4 Grid man Thao tac 300/340 -> 260/320 dung so cua PE (PurchaseEvaluationWorkspacePage:80). === HAI GIA-DINH CUA LEAD BI LANE BAC (lead da tu verify, lane DUNG) === - O3 hang-muc N-dong KHONG can migration. ContractSigningPlanLineConfiguration.cs:37-38 unique index 3 COT (PlanId, SupplierId, CatalogEntryId) => 1 NCC DA duoc phep N dong x N hang muc tu Mig 71. May rong san, chi UI hep. Lead tung bao owner "nang nhat, can migration" — SAI, da dinh chinh. - O4 shopdrawing dinh kem = FE-only 0-BE (rail dossierItemId da land du 6 tang tu Mig 69). Verify: build 2 app PASS; mirror SHA256 4/4 cap KHOP; control-am: <SoonChip=0 · "> Tu choi"=0 · a-href=0; control-duong: responseType=2 + downloadAttachment=2 CA HAI app. 🔴 CHUA co screenshot — ca 5 lane deu khong chup duoc (dev-rig chan: chi :8080 LISTENING, API :5443 khong co, trang sau ProtectedRoute — gotcha #3). Phai soi that 375/1366/1440 tren prod sau deploy truoc khi tuyen PASS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@ -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 { Check, History, Paperclip, Send, Trash2, Undo2, Workflow, X } from 'lucide-react'
|
import { Check, History, Paperclip, Send, Trash2, Undo2, Workflow } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Dialog } from '@/components/ui/Dialog'
|
import { Dialog } from '@/components/ui/Dialog'
|
||||||
import { Label } from '@/components/ui/Label'
|
import { Label } from '@/components/ui/Label'
|
||||||
@ -243,6 +243,29 @@ export function KhkkWorkflowPanel({
|
|||||||
action === KhkkTransitionAction.Return || action === KhkkTransitionAction.Reject
|
action === KhkkTransitionAction.Return || action === KhkkTransitionAction.Reject
|
||||||
const confirmDisabled = transition.isPending || (commentRequired && !comment.trim())
|
const confirmDisabled = transition.isPending || (commentRequired && !comment.trim())
|
||||||
|
|
||||||
|
// [S174 · A1 — LỖI THẬT, không phải style] Trước đây tải file bằng `<a href="/api/…">` TRẦN.
|
||||||
|
// 🔴 JWT chỉ được gắn bởi interceptor axios (`lib/api.ts:18`); thẻ `<a>` KHÔNG đi qua axios
|
||||||
|
// ⇒ request tới `[Authorize(Policy="KeHoachKyKet.Read")]`
|
||||||
|
// (`ContractSigningPlansController.cs:222-223`) đi TAY KHÔNG ⇒ **401, nút không tải được**.
|
||||||
|
// Bê đúng khuôn PE (`PeWorkflowPanel.tsx:327-342`): axios lấy blob → objectURL → click ngầm.
|
||||||
|
// Phát hiện bởi lane L1 của ensemble; lead verify bằng đối chứng `responseType:'blob'` ở PE.
|
||||||
|
async function downloadAttachment(a: KhkkAttachmentDto) {
|
||||||
|
try {
|
||||||
|
const r = await api.get(
|
||||||
|
`/contract-signing-plans/${plan.id}/attachments/${a.id}/download`,
|
||||||
|
{ responseType: 'blob' },
|
||||||
|
)
|
||||||
|
const url = window.URL.createObjectURL(r.data as Blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = a.fileName
|
||||||
|
link.click()
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(getErrorMessage(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-emerald-500)' }}>
|
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-emerald-500)' }}>
|
||||||
{/* ── 1. Header + meta {code} v{NN} · {name} ───────────────────────────── */}
|
{/* ── 1. Header + meta {code} v{NN} · {name} ───────────────────────────── */}
|
||||||
@ -453,18 +476,12 @@ export function KhkkWorkflowPanel({
|
|||||||
>
|
>
|
||||||
<Undo2 className="h-3 w-3" /> Trả lại
|
<Undo2 className="h-3 w-3" /> Trả lại
|
||||||
</button>
|
</button>
|
||||||
<button
|
{/* 🧊 [S174 owner] Nút "TỪ CHỐI" ĐÃ GỠ — anh chốt "vậy thì bỏ luôn từ chối
|
||||||
type="button"
|
~ tương tự PE". Duyệt NCC có code từ-chối nhưng THỰC TẾ không hiện
|
||||||
disabled={blockedByLevel}
|
(BE gỡ khỏi `nextPhases` + FE lọc, UAT S60) ⇒ KHKK còn hiện là lệch.
|
||||||
onClick={() => { if (!blockedByLevel) { setComment(''); setPendingFiles([]); setAction(KhkkTransitionAction.Reject) } }}
|
🔴 Chỉ gỡ ĐƯỜNG BẤM ở UI. Máy BE (`KhkkTransitionAction.Reject`,
|
||||||
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Từ chối kế hoạch (khoá phiếu)'}
|
phase `TuChoi=99`) GIỮ NGUYÊN: phiếu cũ đã từ-chối vẫn hiển thị đúng,
|
||||||
className={cn(
|
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ử. */}
|
||||||
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition',
|
|
||||||
blockedByLevel ? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400' : 'border-red-300 text-red-700 hover:bg-red-50',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" /> Từ chối
|
|
||||||
</button>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{canDeletePlan && (
|
{canDeletePlan && (
|
||||||
@ -497,12 +514,13 @@ export function KhkkWorkflowPanel({
|
|||||||
<div className="truncate font-medium text-slate-700">{a.fileName}</div>
|
<div className="truncate font-medium text-slate-700">{a.fileName}</div>
|
||||||
<div className="text-slate-500">{formatSize(a.fileSize)} · {formatDateTime(a.createdAt)}</div>
|
<div className="text-slate-500">{formatSize(a.fileSize)} · {formatDateTime(a.createdAt)}</div>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<button
|
||||||
href={`/api/contract-signing-plans/${plan.id}/attachments/${a.id}/download`}
|
type="button"
|
||||||
|
onClick={() => downloadAttachment(a)}
|
||||||
className="shrink-0 text-[11px] font-medium text-brand-600 hover:underline"
|
className="shrink-0 text-[11px] font-medium text-brand-600 hover:underline"
|
||||||
>
|
>
|
||||||
Tải xuống
|
Tải xuống
|
||||||
</a>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@ -96,15 +96,11 @@ function stageCount(stage: PipelineStage): number {
|
|||||||
return stage.content !== undefined ? (stage.count ?? 0) : stage.leaves.length
|
return stage.content !== undefined ? (stage.count ?? 0) : stage.leaves.length
|
||||||
}
|
}
|
||||||
|
|
||||||
function SoonChip() {
|
// 🧊 [S174 owner O2] `SoonChip()` ĐÃ GỠ — owner: "Bỏ hết đi đổi thành 0". Lane L4 đo được
|
||||||
// Token lấy verbatim từ dải strip (PePipelineStrip.tsx:50-52) — uppercase CSS
|
// KHÔNG giai đoạn nào còn đúng nghĩa "sắp" (cả 4 đều fetch API thật), và `SoonChip` KHÔNG
|
||||||
// nên chữ "Sắp" hiển thị "SẮP" đúng như owner gõ.
|
// export (8 hit nội bộ) ⇒ 0 host ngoài phụ thuộc, gỡ an toàn.
|
||||||
return (
|
// 🔴 Gỡ KHÔNG đồng nghĩa "thay hết bằng 0": 2 ca đếm-được thì in số, ca KHOÁ-QUYỀN thì bỏ
|
||||||
<span className="shrink-0 rounded bg-slate-100 px-1 text-[9px] font-semibold uppercase tracking-wide text-slate-400">
|
// trắng (xem chú thích tại chỗ) — in 0 cho ca khoá là nói dối về dữ-liệu.
|
||||||
Sắp
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type StageStyle = (typeof STAGE_STYLE)[PipelineStageNo]
|
type StageStyle = (typeof STAGE_STYLE)[PipelineStageNo]
|
||||||
|
|
||||||
@ -189,11 +185,10 @@ function GroupFolder({ group, style }: { group: PipelineGroup; style: StageStyle
|
|||||||
{group.label}
|
{group.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{count > 0 ? (
|
{/* [S174 owner O2] "Bỏ hết đi đổi thành 0" — luôn in SỐ, kể cả 0. Chữ "SẮP" cũ nói
|
||||||
|
"giai đoạn chưa xây"; nay CẢ 4 giai đoạn đều gọi API thật (lane L4 đo) ⇒ 0 nghĩa
|
||||||
|
là "chưa có phiếu", không phải "chưa có tính năng". */}
|
||||||
<span className={cn('shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium', style.count)}>{count}</span>
|
<span className={cn('shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium', style.count)}>{count}</span>
|
||||||
) : (
|
|
||||||
<SoonChip />
|
|
||||||
)}
|
|
||||||
</summary>
|
</summary>
|
||||||
<div className="ml-3 border-l border-slate-200">
|
<div className="ml-3 border-l border-slate-200">
|
||||||
{empty ? (
|
{empty ? (
|
||||||
@ -229,8 +224,11 @@ function StageFolder({ stage, currentStage }: { stage: PipelineStage; currentSta
|
|||||||
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded bg-slate-100 text-slate-400">
|
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded bg-slate-100 text-slate-400">
|
||||||
<Icon aria-hidden className="h-3 w-3" />
|
<Icon aria-hidden className="h-3 w-3" />
|
||||||
</span>
|
</span>
|
||||||
|
{/* [S174 owner O2] KHÔNG in "0" ở đây. Đây là ca "chưa có QUYỀN xem", không phải
|
||||||
|
"có 0 phiếu" — in 0 là NÓI DỐI về dữ-liệu (thực tế có thể đầy phiếu, chỉ là mình
|
||||||
|
không được xem, và cũng không được phép gọi API để biết). Ổ khoá + `title` đã
|
||||||
|
mang trọn nghĩa; bỏ chip là đúng, không thay bằng số. */}
|
||||||
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-slate-500">{label}</span>
|
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-slate-500">{label}</span>
|
||||||
<SoonChip />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -267,10 +265,10 @@ function StageFolder({ stage, currentStage }: { stage: PipelineStage; currentSta
|
|||||||
>
|
>
|
||||||
—
|
—
|
||||||
</span>
|
</span>
|
||||||
) : count > 0 ? (
|
|
||||||
<span className={cn('shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium', style.count)}>{count}</span>
|
|
||||||
) : (
|
) : (
|
||||||
<SoonChip />
|
/* [S174 owner O2] luôn in SỐ (kể cả 0). Nhánh `—` ở trên GIỮ: đó là ca
|
||||||
|
"không tải được dữ-liệu", khác hẳn "có 0 phiếu" — đừng gộp hai thứ. */
|
||||||
|
<span className={cn('shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium', style.count)}>{count}</span>
|
||||||
)}
|
)}
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
|
|||||||
@ -176,7 +176,15 @@ export function KhkkCreatePage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[300px_1fr] xl:grid-cols-[340px_1fr]">
|
{/* [S174 · B4] Bề ngang cột về ĐÚNG số của Duyệt NCC (`PurchaseEvaluationWorkspacePage.tsx:80`
|
||||||
|
`260px` / `xl:320px`) — trước đó lead tự đặt 300/340, lệch chuẩn không lý do.
|
||||||
|
[S174 · A2 — VÁ REGRESSION DO CHÍNH LEAD GÂY RA TRONG PHIÊN NÀY] `<main>` từng mang
|
||||||
|
`hidden … lg:block` ⇒ dưới `lg` chỉ còn panel-1, KHÔNG có form ⇒ **mobile không tạo
|
||||||
|
được phiếu**: bấm phiếu ở cột trái xong thì không có chỗ nào để bấm "Tạo kế hoạch".
|
||||||
|
🔴 Vá bằng 2 vế, thiếu vế nào cũng hỏng: (1) `<main>` bỏ `hidden`, hiện mọi khổ;
|
||||||
|
(2) mobile xếp DỌC với panel-1 chốt trần `14rem` — không chốt thì panel-1 (danh sách
|
||||||
|
dài) đẩy form rơi khỏi màn, đúng cái ngõ cụt vừa vá. Từ `lg` trở lên về 1 hàng như cũ. */}
|
||||||
|
<div className="grid flex-1 grid-cols-1 grid-rows-[minmax(0,14rem)_minmax(0,1fr)] overflow-hidden lg:grid-cols-[260px_1fr] lg:grid-rows-1 xl:grid-cols-[320px_1fr]">
|
||||||
<KhkkWorkListPanel
|
<KhkkWorkListPanel
|
||||||
onPickPe={pickPe}
|
onPickPe={pickPe}
|
||||||
onPickPlan={setViewPlanId}
|
onPickPlan={setViewPlanId}
|
||||||
@ -184,7 +192,7 @@ export function KhkkCreatePage() {
|
|||||||
selectedPlanId={viewPlanId}
|
selectedPlanId={viewPlanId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
|
<main className="min-h-0 overflow-y-auto bg-slate-50 p-4 lg:p-6">
|
||||||
{/* (a) Đang XEM một kế hoạch — read-only, có đường quay về form tạo. */}
|
{/* (a) Đang XEM một kế hoạch — read-only, có đường quay về form tạo. */}
|
||||||
{viewPlanId && (
|
{viewPlanId && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { Input } from '@/components/ui/Input'
|
|||||||
import { Label } from '@/components/ui/Label'
|
import { Label } from '@/components/ui/Label'
|
||||||
import { Select } from '@/components/ui/Select'
|
import { Select } from '@/components/ui/Select'
|
||||||
import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
|
import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
|
||||||
|
import { KhkkWorkflowPanel } from '@/components/khkk/KhkkWorkflowPanel'
|
||||||
import { useAuth } from '@/contexts/AuthContext'
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
import { usePermission } from '@/hooks/usePermission'
|
import { usePermission } from '@/hooks/usePermission'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
@ -158,13 +159,17 @@ export function KhkkDetailPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* [S174 owner] Khối "Quy trình duyệt" (sơ-đồ Bước/Cấp + HÀNH ĐỘNG + 2 lịch sử) ĐÃ GỠ
|
{/* [S174 owner] Khối "Quy trình duyệt" GỠ khỏi bản DESKTOP của trang này — anh chốt
|
||||||
khỏi trang này — anh chốt "bỏ hết, nó sẽ hiển thị ở Menu duyệt". Nhà của nó nay là
|
"bỏ hết, nó sẽ hiển thị ở Menu duyệt". Nhà của nó ở desktop là Panel-3 màn danh sách
|
||||||
Panel-3 của màn danh sách (`KhkkListPage.tsx:196` — cùng `KhkkWorkflowPanel`), nên
|
(`KhkkListPage.tsx:196`), nên gỡ = bỏ BẢN SAO THỨ HAI, không phải bỏ tính năng.
|
||||||
gỡ ở đây là bỏ BẢN SAO THỨ HAI, không phải bỏ tính năng.
|
🔴 [A3] NHƯNG `<lg` KHÔNG có 3 panel: `selectPlan` điều hướng thẳng vào trang này
|
||||||
🔴 Hệ quả CÒN LẠI phải biết: `<lg` không có 3 panel nên `selectPlan` điều hướng vào
|
(`KhkkListPage.tsx:107`) ⇒ gỡ trắng thì mobile MẤT HẲN đường Gửi duyệt / Xóa phiếu.
|
||||||
đúng trang này (`KhkkListPage.tsx:107`) ⇒ trên mobile hiện KHÔNG còn đường Gửi
|
⇒ giữ lại ĐÚNG ở `lg:hidden`: desktop vẫn sạch (panel-3 lo), mobile vẫn thao tác được.
|
||||||
duyệt / Xóa phiếu. Đã khai với anh; mở lại thì bọc `<div className="lg:hidden">`. */}
|
Đây là nợ lead đã khai với owner ở vòng trước, nay trả. */}
|
||||||
|
<div className="lg:hidden">
|
||||||
|
<KhkkWorkflowPanel plan={k} onChanged={invalidate} onDeleted={() => navigate(backToList)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<KhkkDetailContent plan={k} readOnly={readOnly} onChanged={invalidate} />
|
<KhkkDetailContent plan={k} readOnly={readOnly} onChanged={invalidate} />
|
||||||
|
|
||||||
{bridgeOpen && <CreateContractFromKhkkDialog plan={k} onClose={() => setBridgeOpen(false)} />}
|
{bridgeOpen && <CreateContractFromKhkkDialog plan={k} onClose={() => setBridgeOpen(false)} />}
|
||||||
|
|||||||
@ -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 { Check, History, Paperclip, Send, Trash2, Undo2, Workflow, X } from 'lucide-react'
|
import { Check, History, Paperclip, Send, Trash2, Undo2, Workflow } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Dialog } from '@/components/ui/Dialog'
|
import { Dialog } from '@/components/ui/Dialog'
|
||||||
import { Label } from '@/components/ui/Label'
|
import { Label } from '@/components/ui/Label'
|
||||||
@ -243,6 +243,29 @@ export function KhkkWorkflowPanel({
|
|||||||
action === KhkkTransitionAction.Return || action === KhkkTransitionAction.Reject
|
action === KhkkTransitionAction.Return || action === KhkkTransitionAction.Reject
|
||||||
const confirmDisabled = transition.isPending || (commentRequired && !comment.trim())
|
const confirmDisabled = transition.isPending || (commentRequired && !comment.trim())
|
||||||
|
|
||||||
|
// [S174 · A1 — LỖI THẬT, không phải style] Trước đây tải file bằng `<a href="/api/…">` TRẦN.
|
||||||
|
// 🔴 JWT chỉ được gắn bởi interceptor axios (`lib/api.ts:18`); thẻ `<a>` KHÔNG đi qua axios
|
||||||
|
// ⇒ request tới `[Authorize(Policy="KeHoachKyKet.Read")]`
|
||||||
|
// (`ContractSigningPlansController.cs:222-223`) đi TAY KHÔNG ⇒ **401, nút không tải được**.
|
||||||
|
// Bê đúng khuôn PE (`PeWorkflowPanel.tsx:327-342`): axios lấy blob → objectURL → click ngầm.
|
||||||
|
// Phát hiện bởi lane L1 của ensemble; lead verify bằng đối chứng `responseType:'blob'` ở PE.
|
||||||
|
async function downloadAttachment(a: KhkkAttachmentDto) {
|
||||||
|
try {
|
||||||
|
const r = await api.get(
|
||||||
|
`/contract-signing-plans/${plan.id}/attachments/${a.id}/download`,
|
||||||
|
{ responseType: 'blob' },
|
||||||
|
)
|
||||||
|
const url = window.URL.createObjectURL(r.data as Blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = a.fileName
|
||||||
|
link.click()
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(getErrorMessage(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-emerald-500)' }}>
|
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-emerald-500)' }}>
|
||||||
{/* ── 1. Header + meta {code} v{NN} · {name} ───────────────────────────── */}
|
{/* ── 1. Header + meta {code} v{NN} · {name} ───────────────────────────── */}
|
||||||
@ -453,18 +476,12 @@ export function KhkkWorkflowPanel({
|
|||||||
>
|
>
|
||||||
<Undo2 className="h-3 w-3" /> Trả lại
|
<Undo2 className="h-3 w-3" /> Trả lại
|
||||||
</button>
|
</button>
|
||||||
<button
|
{/* 🧊 [S174 owner] Nút "TỪ CHỐI" ĐÃ GỠ — anh chốt "vậy thì bỏ luôn từ chối
|
||||||
type="button"
|
~ tương tự PE". Duyệt NCC có code từ-chối nhưng THỰC TẾ không hiện
|
||||||
disabled={blockedByLevel}
|
(BE gỡ khỏi `nextPhases` + FE lọc, UAT S60) ⇒ KHKK còn hiện là lệch.
|
||||||
onClick={() => { if (!blockedByLevel) { setComment(''); setPendingFiles([]); setAction(KhkkTransitionAction.Reject) } }}
|
🔴 Chỉ gỡ ĐƯỜNG BẤM ở UI. Máy BE (`KhkkTransitionAction.Reject`,
|
||||||
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Từ chối kế hoạch (khoá phiếu)'}
|
phase `TuChoi=99`) GIỮ NGUYÊN: phiếu cũ đã từ-chối vẫn hiển thị đúng,
|
||||||
className={cn(
|
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ử. */}
|
||||||
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition',
|
|
||||||
blockedByLevel ? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400' : 'border-red-300 text-red-700 hover:bg-red-50',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" /> Từ chối
|
|
||||||
</button>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{canDeletePlan && (
|
{canDeletePlan && (
|
||||||
@ -497,12 +514,13 @@ export function KhkkWorkflowPanel({
|
|||||||
<div className="truncate font-medium text-slate-700">{a.fileName}</div>
|
<div className="truncate font-medium text-slate-700">{a.fileName}</div>
|
||||||
<div className="text-slate-500">{formatSize(a.fileSize)} · {formatDateTime(a.createdAt)}</div>
|
<div className="text-slate-500">{formatSize(a.fileSize)} · {formatDateTime(a.createdAt)}</div>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<button
|
||||||
href={`/api/contract-signing-plans/${plan.id}/attachments/${a.id}/download`}
|
type="button"
|
||||||
|
onClick={() => downloadAttachment(a)}
|
||||||
className="shrink-0 text-[11px] font-medium text-brand-600 hover:underline"
|
className="shrink-0 text-[11px] font-medium text-brand-600 hover:underline"
|
||||||
>
|
>
|
||||||
Tải xuống
|
Tải xuống
|
||||||
</a>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@ -96,15 +96,11 @@ function stageCount(stage: PipelineStage): number {
|
|||||||
return stage.content !== undefined ? (stage.count ?? 0) : stage.leaves.length
|
return stage.content !== undefined ? (stage.count ?? 0) : stage.leaves.length
|
||||||
}
|
}
|
||||||
|
|
||||||
function SoonChip() {
|
// 🧊 [S174 owner O2] `SoonChip()` ĐÃ GỠ — owner: "Bỏ hết đi đổi thành 0". Lane L4 đo được
|
||||||
// Token lấy verbatim từ dải strip (PePipelineStrip.tsx:50-52) — uppercase CSS
|
// KHÔNG giai đoạn nào còn đúng nghĩa "sắp" (cả 4 đều fetch API thật), và `SoonChip` KHÔNG
|
||||||
// nên chữ "Sắp" hiển thị "SẮP" đúng như owner gõ.
|
// export (8 hit nội bộ) ⇒ 0 host ngoài phụ thuộc, gỡ an toàn.
|
||||||
return (
|
// 🔴 Gỡ KHÔNG đồng nghĩa "thay hết bằng 0": 2 ca đếm-được thì in số, ca KHOÁ-QUYỀN thì bỏ
|
||||||
<span className="shrink-0 rounded bg-slate-100 px-1 text-[9px] font-semibold uppercase tracking-wide text-slate-400">
|
// trắng (xem chú thích tại chỗ) — in 0 cho ca khoá là nói dối về dữ-liệu.
|
||||||
Sắp
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type StageStyle = (typeof STAGE_STYLE)[PipelineStageNo]
|
type StageStyle = (typeof STAGE_STYLE)[PipelineStageNo]
|
||||||
|
|
||||||
@ -189,11 +185,10 @@ function GroupFolder({ group, style }: { group: PipelineGroup; style: StageStyle
|
|||||||
{group.label}
|
{group.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{count > 0 ? (
|
{/* [S174 owner O2] "Bỏ hết đi đổi thành 0" — luôn in SỐ, kể cả 0. Chữ "SẮP" cũ nói
|
||||||
|
"giai đoạn chưa xây"; nay CẢ 4 giai đoạn đều gọi API thật (lane L4 đo) ⇒ 0 nghĩa
|
||||||
|
là "chưa có phiếu", không phải "chưa có tính năng". */}
|
||||||
<span className={cn('shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium', style.count)}>{count}</span>
|
<span className={cn('shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium', style.count)}>{count}</span>
|
||||||
) : (
|
|
||||||
<SoonChip />
|
|
||||||
)}
|
|
||||||
</summary>
|
</summary>
|
||||||
<div className="ml-3 border-l border-slate-200">
|
<div className="ml-3 border-l border-slate-200">
|
||||||
{empty ? (
|
{empty ? (
|
||||||
@ -229,8 +224,11 @@ function StageFolder({ stage, currentStage }: { stage: PipelineStage; currentSta
|
|||||||
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded bg-slate-100 text-slate-400">
|
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded bg-slate-100 text-slate-400">
|
||||||
<Icon aria-hidden className="h-3 w-3" />
|
<Icon aria-hidden className="h-3 w-3" />
|
||||||
</span>
|
</span>
|
||||||
|
{/* [S174 owner O2] KHÔNG in "0" ở đây. Đây là ca "chưa có QUYỀN xem", không phải
|
||||||
|
"có 0 phiếu" — in 0 là NÓI DỐI về dữ-liệu (thực tế có thể đầy phiếu, chỉ là mình
|
||||||
|
không được xem, và cũng không được phép gọi API để biết). Ổ khoá + `title` đã
|
||||||
|
mang trọn nghĩa; bỏ chip là đúng, không thay bằng số. */}
|
||||||
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-slate-500">{label}</span>
|
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-slate-500">{label}</span>
|
||||||
<SoonChip />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -267,10 +265,10 @@ function StageFolder({ stage, currentStage }: { stage: PipelineStage; currentSta
|
|||||||
>
|
>
|
||||||
—
|
—
|
||||||
</span>
|
</span>
|
||||||
) : count > 0 ? (
|
|
||||||
<span className={cn('shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium', style.count)}>{count}</span>
|
|
||||||
) : (
|
) : (
|
||||||
<SoonChip />
|
/* [S174 owner O2] luôn in SỐ (kể cả 0). Nhánh `—` ở trên GIỮ: đó là ca
|
||||||
|
"không tải được dữ-liệu", khác hẳn "có 0 phiếu" — đừng gộp hai thứ. */
|
||||||
|
<span className={cn('shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium', style.count)}>{count}</span>
|
||||||
)}
|
)}
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
|
|||||||
@ -176,7 +176,15 @@ export function KhkkCreatePage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[300px_1fr] xl:grid-cols-[340px_1fr]">
|
{/* [S174 · B4] Bề ngang cột về ĐÚNG số của Duyệt NCC (`PurchaseEvaluationWorkspacePage.tsx:80`
|
||||||
|
`260px` / `xl:320px`) — trước đó lead tự đặt 300/340, lệch chuẩn không lý do.
|
||||||
|
[S174 · A2 — VÁ REGRESSION DO CHÍNH LEAD GÂY RA TRONG PHIÊN NÀY] `<main>` từng mang
|
||||||
|
`hidden … lg:block` ⇒ dưới `lg` chỉ còn panel-1, KHÔNG có form ⇒ **mobile không tạo
|
||||||
|
được phiếu**: bấm phiếu ở cột trái xong thì không có chỗ nào để bấm "Tạo kế hoạch".
|
||||||
|
🔴 Vá bằng 2 vế, thiếu vế nào cũng hỏng: (1) `<main>` bỏ `hidden`, hiện mọi khổ;
|
||||||
|
(2) mobile xếp DỌC với panel-1 chốt trần `14rem` — không chốt thì panel-1 (danh sách
|
||||||
|
dài) đẩy form rơi khỏi màn, đúng cái ngõ cụt vừa vá. Từ `lg` trở lên về 1 hàng như cũ. */}
|
||||||
|
<div className="grid flex-1 grid-cols-1 grid-rows-[minmax(0,14rem)_minmax(0,1fr)] overflow-hidden lg:grid-cols-[260px_1fr] lg:grid-rows-1 xl:grid-cols-[320px_1fr]">
|
||||||
<KhkkWorkListPanel
|
<KhkkWorkListPanel
|
||||||
onPickPe={pickPe}
|
onPickPe={pickPe}
|
||||||
onPickPlan={setViewPlanId}
|
onPickPlan={setViewPlanId}
|
||||||
@ -184,7 +192,7 @@ export function KhkkCreatePage() {
|
|||||||
selectedPlanId={viewPlanId}
|
selectedPlanId={viewPlanId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
|
<main className="min-h-0 overflow-y-auto bg-slate-50 p-4 lg:p-6">
|
||||||
{/* (a) Đang XEM một kế hoạch — read-only, có đường quay về form tạo. */}
|
{/* (a) Đang XEM một kế hoạch — read-only, có đường quay về form tạo. */}
|
||||||
{viewPlanId && (
|
{viewPlanId && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { Input } from '@/components/ui/Input'
|
|||||||
import { Label } from '@/components/ui/Label'
|
import { Label } from '@/components/ui/Label'
|
||||||
import { Select } from '@/components/ui/Select'
|
import { Select } from '@/components/ui/Select'
|
||||||
import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
|
import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
|
||||||
|
import { KhkkWorkflowPanel } from '@/components/khkk/KhkkWorkflowPanel'
|
||||||
import { useAuth } from '@/contexts/AuthContext'
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
import { usePermission } from '@/hooks/usePermission'
|
import { usePermission } from '@/hooks/usePermission'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
@ -158,13 +159,17 @@ export function KhkkDetailPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* [S174 owner] Khối "Quy trình duyệt" (sơ-đồ Bước/Cấp + HÀNH ĐỘNG + 2 lịch sử) ĐÃ GỠ
|
{/* [S174 owner] Khối "Quy trình duyệt" GỠ khỏi bản DESKTOP của trang này — anh chốt
|
||||||
khỏi trang này — anh chốt "bỏ hết, nó sẽ hiển thị ở Menu duyệt". Nhà của nó nay là
|
"bỏ hết, nó sẽ hiển thị ở Menu duyệt". Nhà của nó ở desktop là Panel-3 màn danh sách
|
||||||
Panel-3 của màn danh sách (`KhkkListPage.tsx:196` — cùng `KhkkWorkflowPanel`), nên
|
(`KhkkListPage.tsx:196`), nên gỡ = bỏ BẢN SAO THỨ HAI, không phải bỏ tính năng.
|
||||||
gỡ ở đây là bỏ BẢN SAO THỨ HAI, không phải bỏ tính năng.
|
🔴 [A3] NHƯNG `<lg` KHÔNG có 3 panel: `selectPlan` điều hướng thẳng vào trang này
|
||||||
🔴 Hệ quả CÒN LẠI phải biết: `<lg` không có 3 panel nên `selectPlan` điều hướng vào
|
(`KhkkListPage.tsx:107`) ⇒ gỡ trắng thì mobile MẤT HẲN đường Gửi duyệt / Xóa phiếu.
|
||||||
đúng trang này (`KhkkListPage.tsx:107`) ⇒ trên mobile hiện KHÔNG còn đường Gửi
|
⇒ giữ lại ĐÚNG ở `lg:hidden`: desktop vẫn sạch (panel-3 lo), mobile vẫn thao tác được.
|
||||||
duyệt / Xóa phiếu. Đã khai với anh; mở lại thì bọc `<div className="lg:hidden">`. */}
|
Đây là nợ lead đã khai với owner ở vòng trước, nay trả. */}
|
||||||
|
<div className="lg:hidden">
|
||||||
|
<KhkkWorkflowPanel plan={k} onChanged={invalidate} onDeleted={() => navigate(backToList)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<KhkkDetailContent plan={k} readOnly={readOnly} onChanged={invalidate} />
|
<KhkkDetailContent plan={k} readOnly={readOnly} onChanged={invalidate} />
|
||||||
|
|
||||||
{bridgeOpen && <CreateContractFromKhkkDialog plan={k} onClose={() => setBridgeOpen(false)} />}
|
{bridgeOpen && <CreateContractFromKhkkDialog plan={k} onClose={() => setBridgeOpen(false)} />}
|
||||||
|
|||||||
Reference in New Issue
Block a user