[CLAUDE] FE-Admin: PE InfoTab inline edit Section 1 + PeListPanel pencil edit hover
User feedback 2026-05-07: muốn thêm nút edit kế bên row trong Panel 1, click →
Panel 2 sáng nội dung Section 1 lên cho user sửa header inline (KHÔNG cần đi
"Sửa header" page). Cũng muốn create new interface gần giống detail view
sectioned (defer cho chunk sau, hoặc keep PeHeaderForm nếu user OK).
Implementation:
~ PeDetailTabs.tsx
- InfoTab thêm prop `readOnly` + `autoEdit` (trigger edit mode tự động khi
mount nếu URL ?editHeader=1)
- canEdit = !readOnly && isDraft (DangSoanThao):
→ display mode: hiển thị FormRow + button "✎ Sửa" góc trên phải Section 1
→ editing mode (click Sửa hoặc autoEdit): card border brand-200 + 4 input
(Tên * / Dự án disabled / Địa điểm / Mô tả / Payment) + nút Lưu/Hủy
- Save: PUT /pe/:id full payload (current ev values + new editable fields).
onSuccess: invalidate ['pe-detail', 'pe-list'] + setEditing(false)
- PeDetailTabs prop `autoEditHeader` mới — pass-through xuống InfoTab
~ PeListPanel.tsx
- Thêm prop `onEditClick?: (id) => void`
- Pencil icon (lucide) absolute right-2 top-2 trong mỗi <li>, opacity-0
group-hover:opacity-100 — chỉ hiện khi user hover row + onEditClick set
- Click → trigger onEditClick(id) callback (different from row click)
~ PurchaseEvaluationWorkspacePage.tsx
- Đọc URL ?editHeader=1 → pass autoEditHeader xuống PeDetailTabs
- PeListPanel onEditClick → setParams({ id, mode: null, editHeader: '1' })
- onSelect (click row thường) → editHeader: null (clear flag)
- onBack → clear editHeader
Verify: npm run build fe-admin pass · 0 TS error.
Pending: workspace "new" mode wrap PeHeaderForm trong sectioned layout giống
detail view (defer — user có thể chấp nhận PeHeaderForm hiện tại nếu OK).
Next: Chunk 2 fe-user mirror.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -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,19 +295,141 @@ 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">Mô tả:</span> {ev.moTa}</div>}
|
{ev.moTa && <div><span className="text-slate-400">Mô 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]">Mô 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) =====
|
||||||
|
|||||||
@ -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 HĐ</div>
|
<div className="mt-1 text-[10px] text-brand-600">✓ Đã tạo HĐ</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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user