Files
solution-erp/fe-admin/src/pages/pe/PurchaseEvaluationWorkspacePage.tsx
pqhuy1987 d15398fafe
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Failing after 2m0s
[CLAUDE] Domain+FE: PE thêm phase TraLai + pencil always visible + edit gating
User feedback 2026-05-07:
1. Pencil edit icon LUÔN hiện (không chỉ hover) trong workspace Panel 1
2. Pencil sáng (active brand-color) khi phase editable, xám/disabled khi không
3. Click pencil khi sáng → row sáng + auto-open edit toàn bộ (header + detail)
4. Thêm phase mới "Trả lại" — approver gửi về Drafter sửa (vs Từ chối terminal)
5. Edit chỉ cho 2 trạng thái: Đang soạn thảo + Trả lại
   (Từ chối + Đã gửi duyệt + Đã duyệt → không edit/thao tác gì)

Implementation:
  ~ Domain/PurchaseEvaluations/PurchaseEvaluationPhase.cs
    + TraLai = 98 (giữa DaDuyet=7 và TuChoi=99)
    Comment ghi rõ "approver trả về Drafter sửa, vẫn cho edit, khác TuChoi"
  ~ types/purchaseEvaluation.ts (× 2 app)
    + PurchaseEvaluationPhase enum: TraLai = 98
    + PurchaseEvaluationPhaseLabel/Color cho TraLai (yellow)
    + isEditablePhase(phase) helper: true cho DangSoanThao + TraLai
    + PeDisplayStatus thêm "TraLai" (separate, không gộp DaGuiDuyet)
    + getPeDisplayStatus map TraLai → "Trả lại" badge yellow
  ~ components/pe/PeListPanel.tsx (× 2 app)
    - Pencil icon: bỏ opacity-0 hover-only → LUÔN visible
    - editable=isEditablePhase(p.phase): bright text-brand-600 + cursor-pointer
    - !editable: text-slate-300 + cursor-not-allowed + onClick guard ignored
    - title tooltip rõ ràng "đã gửi duyệt / đã duyệt / từ chối — không sửa được"
    - Bỏ forcedPhase prop → editableOnly prop (filter client-side cả 2 phase
      DangSoanThao + TraLai vì BE chưa support multi-phase param)
    - Khi editableOnly: hiển thị "Lọc cố định: Bản nháp + Trả lại" indicator
  ~ components/pe/PeDetailTabs.tsx (× 2 app)
    - Header bar: isDraft → canEditPhase = isEditablePhase(phase)
    - InfoTab: canEdit = !readOnly && isEditablePhase
    - BudgetFieldRow: canEdit = !readOnly && isEditablePhase
    → Đồng nghĩa Drafter sửa được phiếu Trả lại sau approver send back
  ~ pages/pe/PurchaseEvaluationWorkspacePage.tsx (× 2 app)
    - PeListPanel forcedPhase=DangSoanThao → editableOnly
    - Bỏ import PurchaseEvaluationPhase

Workflow service BE chưa wire transition → TraLai (defer — user sẽ thêm button
"Trả lại" trong PeWorkflowPanel duyệt sau, hoặc dùng API PATCH manual). Phase
TraLai chỉ là enum value sẵn sàng FE hiển thị + BE ánh xạ HasConversion<int>.

UAT mode: skip verify, push ngay.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:38:46 +07:00

147 lines
6.2 KiB
TypeScript

// Workspace 2-panel cho leaf "Thao tác" Pe_*_Create (Type A=DuyetNcc / B=
// DuyetNccPhuongAn). Pattern mirror HĐ Thầu phụ ContractCreatePage:
// Panel 1 (320px): list pure picker (read-only, không edit/delete) + sticky
// "+ Thêm mới" bottom button (Q1 user 2026-05-07).
// Panel 2 (1fr): empty state · mode=new <PeHeaderForm> · else
// <PeDetailTabs mode="workspace"> (5 section + Section 5
// Ý kiến 4PB DISABLED — Q5: nhập ở leaf "Duyệt").
//
// URL: /purchase-evaluations/workspace?type={1|2}[&id=...][&mode=new][&q=][&phase=]
// Workflow Panel + Approvals + History KHÔNG render ở workspace (Q1 — chỉ
// hiện ở leaf Danh sách + Duyệt vẫn 3-panel).
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'sonner'
import { ClipboardCheck } from 'lucide-react'
import { EmptyState } from '@/components/EmptyState'
import { PeDetailTabs } from '@/components/pe/PeDetailTabs'
import { PeListPanel } from '@/components/pe/PeListPanel'
import { PeWorkspaceCreateView } from '@/components/pe/PeWorkspaceCreateView'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import {
PurchaseEvaluationType,
PurchaseEvaluationTypeLabel,
type PeDetailBundle,
} from '@/types/purchaseEvaluation'
export function PurchaseEvaluationWorkspacePage() {
const navigate = useNavigate()
const qc = useQueryClient()
const [sp, setSp] = useSearchParams()
const typeFilter = sp.get('type') ? Number(sp.get('type')) : PurchaseEvaluationType.DuyetNcc
const search = sp.get('q') ?? ''
const phase = sp.get('phase') ?? ''
const selectedId = sp.get('id')
const mode = sp.get('mode') // 'new' | null
const autoEditHeader = sp.get('editHeader') === '1'
const detail = useQuery({
queryKey: ['pe-detail', selectedId],
queryFn: async () => (await api.get<PeDetailBundle>(`/purchase-evaluations/${selectedId}`)).data,
enabled: !!selectedId,
})
const del = useMutation({
mutationFn: async (id: string) => api.delete(`/purchase-evaluations/${id}`),
onSuccess: () => {
toast.success('Đã xóa phiếu.')
setParams({ id: null })
qc.invalidateQueries({ queryKey: ['pe-list'] })
},
onError: e => toast.error(getErrorMessage(e)),
})
function setParams(updates: Record<string, string | null>) {
const next = new URLSearchParams(sp)
for (const [k, v] of Object.entries(updates)) {
if (v == null || v === '') next.delete(k)
else next.set(k, v)
}
// Search input gõ liên tục → replace (không spam history); pick/mode → push
const replace = Object.keys(updates).length === 1 && updates.q !== undefined
setSp(next, { replace })
}
const headerTitle = `${PurchaseEvaluationTypeLabel[typeFilter]} — Thao tác`
return (
<div className="flex h-[calc(100vh-4rem)] flex-col">
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-slate-200 bg-white px-6 py-3">
<div className="flex items-center gap-2">
<ClipboardCheck className="h-5 w-5 text-slate-500" />
<h1 className="text-base font-semibold tracking-tight text-slate-900">{headerTitle}</h1>
</div>
<div className="text-[12px] text-slate-500">
Workspace 2-panel Workflow + Duyệt menu &ldquo;Duyệt&rdquo;.
</div>
</header>
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[320px_1fr]">
{/* Panel 1: List pure picker + sticky create + pencil edit hover.
Workspace chỉ list phiếu Bản nháp (DangSoanThao) — đã gửi duyệt rồi
không hiện ở đây (vào Danh sách / Duyệt). User 2026-05-07. */}
<PeListPanel
typeFilter={typeFilter}
selectedId={selectedId}
search={search}
phase={phase}
onSelect={id => setParams({ id, mode: null, editHeader: null })}
onSearchChange={q => setParams({ q })}
onPhaseChange={p => setParams({ phase: p })}
showCreateButton
onCreate={() => setParams({ mode: 'new', id: null, editHeader: null })}
onEditClick={id => setParams({ id, mode: null, editHeader: '1' })}
editableOnly
/>
{/* Panel 2: Empty | Header form | Detail tabs (workspace mode) */}
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
{/* Empty: chưa pick + chưa create */}
{!selectedId && mode !== 'new' && (
<EmptyState
icon={ClipboardCheck}
title="Chọn phiếu hoặc tạo mới"
description='Chọn 1 phiếu ở danh sách trái để nhập liệu, hoặc bấm "+ Thêm mới" ở dưới.'
/>
)}
{/* Mode "new": sectioned create view (5 sections, 3-5 locked tới khi save) */}
{mode === 'new' && (
<PeWorkspaceCreateView
defaultType={typeFilter}
onSaved={(newId, t) => setParams({ id: newId, mode: null, type: String(t) })}
onCancel={() => setParams({ mode: null })}
/>
)}
{/* Mode "edit": detail tabs (workspace = no workflow + Section 5 disabled) */}
{selectedId && detail.isLoading && (
<div className="text-sm text-slate-500">Đang tải</div>
)}
{selectedId && detail.data && (
<PeDetailTabs
evaluation={detail.data}
onBack={() => setParams({ id: null, editHeader: null })}
onDelete={() => del.mutate(detail.data!.id)}
mode="workspace"
autoEditHeader={autoEditHeader}
/>
)}
</main>
</div>
{/* Mobile fallback: nếu không lg, redirect về detail page */}
{selectedId && (
<div className="lg:hidden">
{/* Quick UX: tap row khi mobile sẽ navigate fullpage detail */}
<button
onClick={() => navigate(`/purchase-evaluations/${selectedId}`)}
className="hidden"
/>
</div>
)}
</div>
)
}