[CLAUDE] FE-User: PE InfoTab inline edit + PeListPanel pencil edit hover mirror

Chunk 2/3 — mirror y hệt Chunk 1 sang fe-user (rule §3.9). 3 file:
  ~ components/pe/PeDetailTabs.tsx — InfoTab inline edit + autoEditHeader prop
  ~ components/pe/PeListPanel.tsx — pencil icon group-hover absolute right
  ~ pages/pe/PurchaseEvaluationWorkspacePage.tsx — URL editHeader=1 wiring

Verify: npm run build fe-user pass · 0 TS error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-07 14:57:24 +07:00
parent 5a89dd2188
commit 27b291ccea
3 changed files with 160 additions and 19 deletions

View File

@ -53,6 +53,7 @@ export function PeDetailTabs({
onDelete, onDelete,
readOnly = false, readOnly = false,
mode = 'detail', mode = 'detail',
autoEditHeader = false,
}: { }: {
evaluation: PeDetailBundle evaluation: PeDetailBundle
onBack: () => void onBack: () => void
@ -61,6 +62,8 @@ export function PeDetailTabs({
readOnly?: boolean readOnly?: boolean
/** 'workspace' = Section 5 LUÔN disabled (ý kiến nhập ở leaf Duyệt). */ /** 'workspace' = Section 5 LUÔN disabled (ý kiến nhập ở leaf Duyệt). */
mode?: 'detail' | 'workspace' mode?: 'detail' | 'workspace'
/** Auto open Section 1 InfoTab in edit mode khi mount — triggered từ pencil icon Panel 1 */
autoEditHeader?: boolean
}) { }) {
const navigate = useNavigate() const navigate = useNavigate()
const isDraft = evaluation.phase === PurchaseEvaluationPhase.DangSoanThao const isDraft = evaluation.phase === PurchaseEvaluationPhase.DangSoanThao
@ -113,7 +116,7 @@ export function PeDetailTabs({
<div className="divide-y divide-slate-200"> <div className="divide-y divide-slate-200">
{/* Section 1 — đúng spec form FO-PHIẾU TRÌNH KÝ CHỌN TP/NCC */} {/* Section 1 — đúng spec form FO-PHIẾU TRÌNH KÝ CHỌN TP/NCC */}
<Section title="1. Thông tin gói thầu"> <Section title="1. Thông tin gói thầu">
<InfoTab ev={evaluation} /> <InfoTab ev={evaluation} readOnly={readOnly} autoEdit={autoEditHeader} />
</Section> </Section>
<Section title="2. Chọn NCC / TP"> <Section title="2. Chọn NCC / TP">
<ChonNccSection ev={evaluation} readOnly={readOnly} /> <ChonNccSection ev={evaluation} readOnly={readOnly} />
@ -292,21 +295,143 @@ export function PeHistorySection({ ev }: { ev: PeDetailBundle }) {
} }
// ===== Section 1 — Thông tin gói thầu (spec: a. Tên gói thầu / b. Dự án) ===== // ===== Section 1 — Thông tin gói thầu (spec: a. Tên gói thầu / b. Dự án) =====
function InfoTab({ ev }: { ev: PeDetailBundle }) { // Inline editable khi canEdit (=!readOnly && isDraft). Edit pencil button "Sửa"
// flip display ↔ form mode. Save dùng existing PUT /pe/:id endpoint với current
// entity values + new header fields. Dự án + Type LOCKED sau create — chỉ Tên/
// Địa điểm/Mô tả/Payment editable inline. autoEdit prop cho phép trigger edit
// mode từ pencil icon trong PeListPanel (URL flag ?editHeader=1).
function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boolean; autoEdit: boolean }) {
const isDraft = ev.phase === PurchaseEvaluationPhase.DangSoanThao
const canEdit = !readOnly && isDraft
const qc = useQueryClient()
const [editing, setEditing] = useState(autoEdit && canEdit)
const [tenGoiThau, setTenGoiThau] = useState(ev.tenGoiThau)
const [diaDiem, setDiaDiem] = useState(ev.diaDiem ?? '')
const [moTa, setMoTa] = useState(ev.moTa ?? '')
const [paymentTerms, setPaymentTerms] = useState(ev.paymentTerms ?? '')
const dirty = tenGoiThau !== ev.tenGoiThau
|| diaDiem !== (ev.diaDiem ?? '')
|| moTa !== (ev.moTa ?? '')
|| paymentTerms !== (ev.paymentTerms ?? '')
const save = useMutation({
mutationFn: async () => {
await api.put(`/purchase-evaluations/${ev.id}`, {
id: ev.id,
tenGoiThau,
diaDiem: diaDiem || null,
moTa: moTa || null,
paymentTerms: paymentTerms || null,
budgetId: ev.budgetId,
budgetManualName: ev.budgetManualName,
budgetManualAmount: ev.budgetManualAmount,
})
},
onSuccess: () => {
toast.success('Đã cập nhật thông tin')
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
setEditing(false)
},
onError: e => toast.error(getErrorMessage(e)),
})
function reset() {
setTenGoiThau(ev.tenGoiThau)
setDiaDiem(ev.diaDiem ?? '')
setMoTa(ev.moTa ?? '')
setPaymentTerms(ev.paymentTerms ?? '')
}
if (!editing) {
return ( return (
<dl className="space-y-2 text-sm"> <dl className="space-y-2 text-sm">
<div className="flex items-start justify-between">
<FormRow label="a. Tên gói thầu" value={ev.tenGoiThau} /> <FormRow label="a. Tên gói thầu" value={ev.tenGoiThau} />
{canEdit && (
<button
onClick={() => setEditing(true)}
className="inline-flex items-center gap-1 rounded px-2 py-1 text-[11px] text-slate-500 hover:bg-slate-100 hover:text-brand-600"
title="Sửa thông tin gói thầu"
>
<Pencil className="h-3 w-3" /> Sửa
</button>
)}
</div>
<FormRow label="b. Dự án" value={ev.projectName} /> <FormRow label="b. Dự án" value={ev.projectName} />
{(ev.diaDiem || ev.moTa) && ( {(ev.diaDiem || ev.moTa || ev.paymentTerms) && (
<div className="mt-3 rounded bg-slate-50 px-3 py-2 text-[12px] text-slate-600"> <div className="mt-3 rounded bg-slate-50 px-3 py-2 text-[12px] text-slate-600">
{ev.diaDiem && <div><span className="text-slate-400">Đa điểm:</span> {ev.diaDiem}</div>} {ev.diaDiem && <div><span className="text-slate-400">Đa điểm:</span> {ev.diaDiem}</div>}
{ev.moTa && <div><span className="text-slate-400"> tả:</span> {ev.moTa}</div>} {ev.moTa && <div><span className="text-slate-400"> tả:</span> {ev.moTa}</div>}
{ev.paymentTerms && <div><span className="text-slate-400">Điều khoản TT:</span> <span className="whitespace-pre-wrap">{ev.paymentTerms}</span></div>}
</div> </div>
)} )}
</dl> </dl>
) )
} }
// Editing mode
return (
<div className="space-y-3 rounded border border-brand-200 bg-brand-50/30 p-3">
<div className="grid gap-3 md:grid-cols-2">
<div className="md:col-span-2">
<Label className="text-[11px]">a. Tên gói thầu *</Label>
<Input
value={tenGoiThau}
onChange={e => setTenGoiThau(e.target.value)}
placeholder="vd Cung cấp bê tông"
/>
</div>
<div className="md:col-span-2">
<Label className="text-[11px]">b. Dự án (khóa)</Label>
<Input value={ev.projectName} disabled className="bg-slate-100" />
</div>
<div>
<Label className="text-[11px]">Đa điểm</Label>
<Input
value={diaDiem}
onChange={e => setDiaDiem(e.target.value)}
placeholder="Lô K, KCN Lộc An..."
/>
</div>
<div>
<Label className="text-[11px]"> tả ngắn</Label>
<Input
value={moTa}
onChange={e => setMoTa(e.target.value)}
placeholder="Phương án A: ..."
/>
</div>
<div className="md:col-span-2">
<Label className="text-[11px]">Điều khoản thanh toán</Label>
<Input
value={paymentTerms}
onChange={e => setPaymentTerms(e.target.value)}
placeholder="JSON hoặc text"
/>
</div>
</div>
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
onClick={() => { reset(); setEditing(false) }}
className="h-7 px-3 text-xs"
>
Hủy
</Button>
<Button
onClick={() => save.mutate()}
disabled={!dirty || !tenGoiThau || save.isPending}
className="h-7 px-3 text-xs"
>
{save.isPending ? 'Đang lưu…' : 'Lưu'}
</Button>
</div>
</div>
)
}
// ===== b. Ngân sách inline editor (Mig 17) ===== // ===== b. Ngân sách inline editor (Mig 17) =====
// Hiển thị + edit budget link / manual fields ngay trong Section 2 — KHÔNG cần // Hiển thị + edit budget link / manual fields ngay trong Section 2 — KHÔNG cần
// đi tới "Sửa header" page. Visible trong cả 3 view (Workspace / Danh sách / // đi tới "Sửa header" page. Visible trong cả 3 view (Workspace / Danh sách /

View File

@ -6,7 +6,7 @@
// chỉ render + invoke callbacks. Pendingme vẫn truyền được nếu cần dùng cho // chỉ render + invoke callbacks. Pendingme vẫn truyền được nếu cần dùng cho
// inbox view khác (hiện chỉ workspace dùng pendingMe=false). // inbox view khác (hiện chỉ workspace dùng pendingMe=false).
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { ClipboardCheck, Plus, Search } from 'lucide-react' import { ClipboardCheck, Pencil, Plus, Search } from 'lucide-react'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
@ -34,6 +34,7 @@ export function PeListPanel({
onPhaseChange, onPhaseChange,
showCreateButton = false, showCreateButton = false,
onCreate, onCreate,
onEditClick,
}: { }: {
typeFilter: number | null typeFilter: number | null
pendingMe?: boolean pendingMe?: boolean
@ -45,6 +46,8 @@ export function PeListPanel({
onPhaseChange: (p: string) => void onPhaseChange: (p: string) => void
showCreateButton?: boolean showCreateButton?: boolean
onCreate?: () => void onCreate?: () => void
/** Pencil edit icon hover next-to-row — click → select + auto-open Section 1 edit mode (URL ?editHeader=1). */
onEditClick?: (id: string) => void
}) { }) {
const list = useQuery({ const list = useQuery({
queryKey: ['pe-list', { typeFilter, pendingMe, search, phase }], queryKey: ['pe-list', { typeFilter, pendingMe, search, phase }],
@ -122,11 +125,11 @@ export function PeListPanel({
)} )}
<ul className="divide-y divide-slate-100"> <ul className="divide-y divide-slate-100">
{rows.map(p => ( {rows.map(p => (
<li key={p.id}> <li key={p.id} className="group relative">
<button <button
onClick={() => onSelect(p.id)} onClick={() => onSelect(p.id)}
className={cn( className={cn(
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50', 'block w-full px-3 py-2.5 pr-9 text-left transition hover:bg-slate-50',
selectedId === p.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200', selectedId === p.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200',
)} )}
> >
@ -163,6 +166,16 @@ export function PeListPanel({
<div className="mt-1 text-[10px] text-brand-600"> Đã tạo </div> <div className="mt-1 text-[10px] text-brand-600"> Đã tạo </div>
)} )}
</button> </button>
{/* Edit pencil — visible on hover (chỉ khi onEditClick được truyền) */}
{onEditClick && (
<button
onClick={() => onEditClick(p.id)}
className="absolute right-2 top-2 rounded p-1.5 text-slate-400 opacity-0 transition group-hover:opacity-100 hover:bg-white hover:text-brand-600 hover:shadow-sm"
title="Sửa thông tin gói thầu"
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
</li> </li>
))} ))}
</ul> </ul>

View File

@ -34,6 +34,7 @@ export function PurchaseEvaluationWorkspacePage() {
const phase = sp.get('phase') ?? '' const phase = sp.get('phase') ?? ''
const selectedId = sp.get('id') const selectedId = sp.get('id')
const mode = sp.get('mode') // 'new' | null const mode = sp.get('mode') // 'new' | null
const autoEditHeader = sp.get('editHeader') === '1'
const detail = useQuery({ const detail = useQuery({
queryKey: ['pe-detail', selectedId], queryKey: ['pe-detail', selectedId],
@ -77,17 +78,18 @@ export function PurchaseEvaluationWorkspacePage() {
</header> </header>
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[320px_1fr]"> <div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[320px_1fr]">
{/* Panel 1: List pure picker + sticky create */} {/* Panel 1: List pure picker + sticky create + pencil edit hover */}
<PeListPanel <PeListPanel
typeFilter={typeFilter} typeFilter={typeFilter}
selectedId={selectedId} selectedId={selectedId}
search={search} search={search}
phase={phase} phase={phase}
onSelect={id => setParams({ id, mode: null })} onSelect={id => setParams({ id, mode: null, editHeader: null })}
onSearchChange={q => setParams({ q })} onSearchChange={q => setParams({ q })}
onPhaseChange={p => setParams({ phase: p })} onPhaseChange={p => setParams({ phase: p })}
showCreateButton showCreateButton
onCreate={() => setParams({ mode: 'new', id: null })} onCreate={() => setParams({ mode: 'new', id: null, editHeader: null })}
onEditClick={id => setParams({ id, mode: null, editHeader: '1' })}
/> />
{/* Panel 2: Empty | Header form | Detail tabs (workspace mode) */} {/* Panel 2: Empty | Header form | Detail tabs (workspace mode) */}
@ -117,9 +119,10 @@ export function PurchaseEvaluationWorkspacePage() {
{selectedId && detail.data && ( {selectedId && detail.data && (
<PeDetailTabs <PeDetailTabs
evaluation={detail.data} evaluation={detail.data}
onBack={() => setParams({ id: null })} onBack={() => setParams({ id: null, editHeader: null })}
onDelete={() => del.mutate(detail.data!.id)} onDelete={() => del.mutate(detail.data!.id)}
mode="workspace" mode="workspace"
autoEditHeader={autoEditHeader}
/> />
)} )}
</main> </main>