[CLAUDE] FE-User: YC-029 W3 — leaf Đang duyệt ở lại /my-contracts (pendingMe đổi-endpoint /contracts/inbox)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-12 19:39:40 +07:00
parent c84726e2bd
commit ed3b39ff21
2 changed files with 78 additions and 9 deletions

View File

@ -59,7 +59,11 @@ const INT_TO_PE_CODE: Record<number, string> = Object.fromEntries(
) )
// User-side menu key → route. Differs from admin: Danh sách points to // User-side menu key → route. Differs from admin: Danh sách points to
// /my-contracts (user's own drafts), Duyệt to /inbox (pending THEIR approval). // /my-contracts (user's own drafts).
// 🔴 [YC-029 W3 S190 — vá comment stale] Câu cũ "Duyệt to /inbox" HẾT ĐÚNG cho họ `Ct_*`:
// leaf "Đang duyệt" nay Ở LẠI `/my-contracts?type=N&pendingMe=1` (xem nhánh `Pending` :164).
// `/inbox` + `InboxPage` GIỮ NGUYÊN, đổi vai: hộp thư TỔNG mọi loại, vào bằng mục cố định
// "Hộp thư" (`USER_FIXED_TOP` / `staticResolvePath('__inbox')`), không còn là đích của leaf HĐ.
function resolvePath(key: string): string | null { function resolvePath(key: string): string | null {
const staticMap: Record<string, string> = { const staticMap: Record<string, string> = {
// [W2 KHKK S161] GĐ2 hết skeleton — 6 leaf re-point trang thật (gotcha #50: // [W2 KHKK S161] GĐ2 hết skeleton — 6 leaf re-point trang thật (gotcha #50:
@ -161,7 +165,13 @@ function resolvePath(key: string): string | null {
if (!typeInt) return null if (!typeInt) return null
if (action === 'List') return `/my-contracts?type=${typeInt}` if (action === 'List') return `/my-contracts?type=${typeInt}`
if (action === 'Create') return `/contracts/new?type=${typeInt}` if (action === 'Create') return `/contracts/new?type=${typeInt}`
if (action === 'Pending') return `/inbox?type=${typeInt}` // 🔴 [YC-029 W3 S190] "Đang duyệt" Ở LẠI trang danh sách thay vì nhảy sang `/inbox`
// (anh: bấm mục con của loại HĐ mà bị đá sang hộp thư tổng = mất ngữ cảnh loại).
// `pendingMe=1` là DANH-TÍNH leaf (KHÔNG vào `TRANSIENT_QUERY_KEYS` — xem chú ở đó),
// trang tự ĐỔI ENDPOINT sang `GET /contracts/inbox` khi thấy nó.
// GIỮ NGUYÊN route `/inbox` + `InboxPage`: đó là hộp thư TỔNG (mục cố định "Hộp thư"),
// không xoá — đường vào khác, dữ liệu cùng nguồn.
if (action === 'Pending') return `/my-contracts?type=${typeInt}&pendingMe=1`
if (action === 'WfView') return `/contracts/workflow-matrix?type=3&ct=${typeInt}` if (action === 'WfView') return `/contracts/workflow-matrix?type=3&ct=${typeInt}`
// [YC-029 W1 S190] `view=approved` = KHOÁ ĐỊNH DANH (khuôn PE :185, H-4): `phase` nằm // [YC-029 W1 S190] `view=approved` = KHOÁ ĐỊNH DANH (khuôn PE :185, H-4): `phase` nằm
// trong TRANSIENT (là bộ lọc) nên URL "Đã duyệt" trùng hệt URL user tự lọc phase=9 trên // trong TRANSIENT (là bộ lọc) nên URL "Đã duyệt" trùng hệt URL user tự lọc phase=9 trên

View File

@ -12,6 +12,11 @@
// ở Panel 2 chứ không bỏ. Delta này plan W2 KHÔNG nêu (plan chỉ nói "panel-2 giữ detail") — // ở Panel 2 chứ không bỏ. Delta này plan W2 KHÔNG nêu (plan chỉ nói "panel-2 giữ detail") —
// khai ở đây để người sau khỏi tưởng ai đó lỡ tay đánh rơi. // khai ở đây để người sau khỏi tưởng ai đó lỡ tay đánh rơi.
// //
// [S190 · W3 YC-029] Panel 3 có HAI NGUỒN, chọn bằng `?pendingMe=1` của URL:
// • mặc định → `GET /contracts` (phân trang, lọc `type`/`phase`/`search` phía server)
// • pendingMe → `GET /contracts/inbox` (0 tham số, trọn gói) — leaf "Đang duyệt" nay ở lại
// trang này thay vì nhảy sang `/inbox`. Xem khối `queryFn` để biết vì sao KHÔNG gửi param.
//
// Mobile (< lg): GIỮ NGUYÊN 100% hiện trạng — chỉ danh-sách hiện, click row → fullpage // Mobile (< lg): GIỮ NGUYÊN 100% hiện trạng — chỉ danh-sách hiện, click row → fullpage
// /contracts/:id. Cây là khối DESKTOP-ONLY y như TRƯỚC hoán-đổi (nó vốn nằm trong panel // /contracts/:id. Cây là khối DESKTOP-ONLY y như TRƯỚC hoán-đổi (nó vốn nằm trong panel
// `hidden lg:block`), nên đợt này không đổi một pixel nào ở < lg. // `hidden lg:block`), nên đợt này không đổi một pixel nào ở < lg.
@ -52,6 +57,10 @@ export function MyContractsPage() {
// 'Đã xóa' (?deleted=1 — endpoint /contracts/deleted mới, chỉ-xem). // 'Đã xóa' (?deleted=1 — endpoint /contracts/deleted mới, chỉ-xem).
const phaseFilter = searchParams.get('phase') ? Number(searchParams.get('phase')) : null const phaseFilter = searchParams.get('phase') ? Number(searchParams.get('phase')) : null
const showDeleted = searchParams.get('deleted') === '1' const showDeleted = searchParams.get('deleted') === '1'
// [S190 W3 YC-029] leaf "Đang duyệt" nay Ở LẠI trang này (`Layout.tsx` nhánh `Pending`)
// thay vì nhảy sang `/inbox`. `pendingMe` là DANH-TÍNH leaf ⇒ CẤM đưa vào
// `TRANSIENT_QUERY_KEYS` (strip = 2 leaf "Danh sách"/"Đang duyệt" cùng sáng).
const pendingMe = searchParams.get('pendingMe') === '1'
// [S190 W2 · Q2 nhánh-đã-chọn] Select loại HĐ ở đầu panel CÂY. Mặc định = `?type=N` của URL // [S190 W2 · Q2 nhánh-đã-chọn] Select loại HĐ ở đầu panel CÂY. Mặc định = `?type=N` của URL
// (menu loại HĐ đang đứng). 🔴 Đổi Select CHỈ đổi bộ lọc CÂY — KHÔNG navigate, KHÔNG đụng // (menu loại HĐ đang đứng). 🔴 Đổi Select CHỈ đổi bộ lọc CÂY — KHÔNG navigate, KHÔNG đụng
@ -73,9 +82,28 @@ export function MyContractsPage() {
// cache cũ (payload chưa lọc) tự hết hiệu lực vì mỗi `type` là 1 ô key riêng sẵn rồi. // cache cũ (payload chưa lọc) tự hết hiệu lực vì mỗi `type` là 1 ô key riêng sẵn rồi.
// [S190 W2] +trục `search`: ô tìm nay đi SERVER-SIDE (xem `params.search`) ⇒ mỗi từ khoá // [S190 W2] +trục `search`: ô tìm nay đi SERVER-SIDE (xem `params.search`) ⇒ mỗi từ khoá
// là một payload khác nhau, thiếu trục này là ăn cache của từ khoá trước. // là một payload khác nhau, thiếu trục này là ăn cache của từ khoá trước.
queryKey: ['my-contracts', typeFilter, phaseFilter, showDeleted, search], // [S190 W3] +trục `pendingMe`: 2 chế độ đọc 2 NGUỒN khác nhau (`/contracts` phân trang
queryFn: async () => // ⟂ `/contracts/inbox` trọn gói) ⇒ dùng chung ô cache là trả nhầm danh sách.
( queryKey: ['my-contracts', typeFilter, phaseFilter, showDeleted, search, pendingMe],
queryFn: async () => {
// [S190 W3 — khuôn PE `PurchaseEvaluationsListPage.tsx:77-99`] Nhánh "Đã xóa" đứng
// TRƯỚC `pendingMe`: 2 view LOẠI TRỪ nhau, và HĐ đã xóa chỉ thấy được qua endpoint
// riêng `/contracts/deleted`. Đảo thứ tự = URL `?deleted=1&pendingMe=1` rơi vào inbox
// (inbox không bao giờ trả HĐ đã xóa) ⇒ màn "Đã xóa" rỗng THẦM LẶNG.
if (pendingMe && !showDeleted) {
// 🔴 ĐỔI ENDPOINT, KHÔNG gửi thêm param: `GET /contracts` KHÔNG có param `pendingMe`
// (`ContractsController.cs:17-26` — 8 param, không có nó) và ASP.NET NUỐT param lạ IM
// LẶNG ⇒ gửi lên sẽ ra Y HỆT danh sách thường, hỏng câm chứ không đỏ.
// `GET /contracts/inbox` (`ContractsController.cs:41-43`) = 0 THAM SỐ, trả
// `List<ContractListItemDto>` (không phân trang) → lọc loại ở CLIENT + ép shape.
// [carry:w7b-inbox-param] LỆCH-W7 CHẤP NHẬN ĐỢT NÀY: W7 (S187) đã chuyển lọc `type`
// lên server cho 2 nhánh kia; nhánh này vẫn lọc client vì BE chưa có param —
// `GetMyInboxQuery` là record 0 tham số và còn `Take(100)` cứng. Nâng BE = việc đợt sau.
const res = await api.get<ContractListItem[]>('/contracts/inbox')
const items = typeFilter != null ? res.data.filter(c => c.type === typeFilter) : res.data
return { items, total: items.length, page: 1, pageSize: items.length }
}
return (
await api.get<Paged<ContractListItem>>(showDeleted ? '/contracts/deleted' : '/contracts', { await api.get<Paged<ContractListItem>>(showDeleted ? '/contracts/deleted' : '/contracts', {
params: { params: {
page: 1, page: 1,
@ -97,7 +125,8 @@ export function MyContractsPage() {
...(!showDeleted && phaseFilter != null ? { phase: phaseFilter } : {}), ...(!showDeleted && phaseFilter != null ? { phase: phaseFilter } : {}),
}, },
}) })
).data, ).data
},
}) })
const detail = useQuery({ const detail = useQuery({
@ -143,6 +172,19 @@ export function MyContractsPage() {
const typeLabel = typeFilter != null ? ContractTypeLabel[typeFilter] : null const typeLabel = typeFilter != null ? ContractTypeLabel[typeFilter] : null
// [S190 W3] "Rỗng vì LỌC" ⟂ "rỗng vì CHƯA CÓ GÌ" phải khác câu: dùng chung một câu thì
// người đang ở mục "Đang duyệt" đọc thành "loại HĐ này chưa có dữ liệu" — sai nghĩa hẳn.
const emptyTitle = pendingMe && !showDeleted ? 'Không có HĐ chờ bạn duyệt' : 'Không có HĐ'
const emptyDescription = showDeleted
? 'Chưa có HĐ nào bị xóa.'
: pendingMe
? typeLabel
? `Không HĐ "${typeLabel}" nào đang đến lượt bạn duyệt.`
: 'Không có HĐ nào đang đến lượt bạn duyệt.'
: typeFilter != null
? 'Loại HĐ này chưa có dữ liệu.'
: 'Tạo HĐ mới để bắt đầu.'
return ( return (
<div className="flex h-[calc(100vh-4rem)] flex-col"> <div className="flex h-[calc(100vh-4rem)] flex-col">
{/* Compact page header — chiếm ít chiều cao để 3 panel có max chỗ */} {/* Compact page header — chiếm ít chiều cao để 3 panel có max chỗ */}
@ -166,6 +208,13 @@ export function MyContractsPage() {
Đã phát hành Đã phát hành
</span> </span>
)} )}
{/* [S190 W3] Chế độ phải NHÌN THẤY ĐƯỢC: cùng URL `/my-contracts`, cùng tiêu đề,
nhưng danh-sách đọc từ hộp thư đến ⇒ không có nhãn thì user tưởng HĐ biến mất. */}
{!showDeleted && pendingMe && (
<span className="rounded-full bg-amber-50 px-2.5 py-1 text-[11px] font-semibold text-amber-700 ring-1 ring-inset ring-amber-200">
Đang duyệt đến lượt tôi
</span>
)}
{!showDeleted && ( {!showDeleted && (
<Button onClick={() => navigate(typeFilter ? `/contracts/new?type=${typeFilter}` : '/contracts/new')}> <Button onClick={() => navigate(typeFilter ? `/contracts/new?type=${typeFilter}` : '/contracts/new')}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
@ -246,12 +295,22 @@ export function MyContractsPage() {
<div className="relative"> <div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" /> <Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
<Input <Input
value={search} value={pendingMe ? '' : search}
onChange={e => updateSearch(e.target.value)} onChange={e => updateSearch(e.target.value)}
disabled={pendingMe}
placeholder="Tìm theo mã / tên / NCC…" placeholder="Tìm theo mã / tên / NCC…"
className="pl-8" className="pl-8"
/> />
</div> </div>
{/* [S190 W3] KHOÁ ô tìm ở chế độ "Đang duyệt" — và nói VÌ SAO bằng CHỮ HIỆN,
KHÔNG bằng `title` (tooltip trên control `disabled` bị Chromium nuốt — bài S182).
Trang cũng KHÔNG có UI phân trang (pageSize 100 cố định) nên không có gì để ẩn;
inbox trả trọn gói, không phân trang — `[carry:w7b-inbox-param]`. */}
{pendingMe && (
<p className="mt-1.5 text-[11px] leading-snug text-slate-500">
Chế đ Đang duyệt đc hộp thư đến chưa hỗ trợ tìm kiếm phía máy chủ.
</p>
)}
</div> </div>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{list.isLoading && ( {list.isLoading && (
@ -265,8 +324,8 @@ export function MyContractsPage() {
<div className="p-6"> <div className="p-6">
<EmptyState <EmptyState
icon={FileText} icon={FileText}
title="Không có HĐ" title={emptyTitle}
description={showDeleted ? 'Chưa có HĐ nào bị xóa.' : typeFilter != null ? 'Loại HĐ này chưa có dữ liệu.' : 'Tạo HĐ mới để bắt đầu.'} description={emptyDescription}
/> />
</div> </div>
)} )}