Compare commits

...

3 Commits

Author SHA1 Message Date
cb0598d76d [CLAUDE] Docs: Session 11++ housekeeping — InfoTab inline edit + pencil hover
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m9s
Chunk 3/3 — STATUS row + HANDOFF TL;DR Session 11++. KHÔNG cắt narrative cũ
(rule §6.5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:57:52 +07:00
27b291ccea [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>
2026-05-07 14:57:24 +07:00
5a89dd2188 [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>
2026-05-07 14:55:49 +07:00
8 changed files with 333 additions and 39 deletions

View File

@ -1,6 +1,17 @@
# HANDOFF — Brief 5 phút cho session tiếp theo # HANDOFF — Brief 5 phút cho session tiếp theo
**Last updated:** 2026-05-07 (Session 11+ chốt — **PE BudgetFieldRow inline editor + 3 commit FE-only.**) **Last updated:** 2026-05-07 (Session 11++ chốt — **PE InfoTab inline edit Section 1 + Panel 1 pencil hover. 3 commit FE-only.**)
## TL;DR Session 11++ housekeeping (07/05 — InfoTab inline edit + pencil hover)
User feedback after BudgetFieldRow: muốn thêm nút edit kế bên row trong Panel 1 list, click sáng nội dung Section 1 lên cho sửa header inline.
-**InfoTab inline edit** — display mode + button "✎ Sửa" góc phải Section 1. Editing mode: card border brand-200 + 4 input (Tên / Dự án locked / Địa điểm / Mô tả / Payment) + Save (PUT /pe/:id) / Hủy.
-**PeListPanel pencil hover** — icon absolute right-2 top-2 mỗi row, group-hover opacity-100. Click → callback onEditClick(id).
-**URL flag `?editHeader=1`** — set bởi pencil click → autoEditHeader prop chain xuống PeDetailTabs → InfoTab tự open editing mode mount-time.
-**fe-admin** Chunk 1 (`5a89dd2`) · **fe-user mirror** Chunk 2 (this).
**Defer (chưa làm):** Refactor workspace "new" mode wrap PeHeaderForm trong sectioned layout giống detail view (5 sections visible). PeHeaderForm hiện tại single-card đủ dùng — chỉ làm thêm khi user feedback rõ.
## TL;DR Session 11+ housekeeping (07/05 — inline budget editor) ## TL;DR Session 11+ housekeeping (07/05 — inline budget editor)

View File

@ -61,6 +61,7 @@
| Ngày | Ai | Task | Commit | | Ngày | Ai | Task | Commit |
|---|---|---|---| |---|---|---|---|
| 2026-05-07 | Claude | **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 sáng nội dung Section 1 lên cho user sửa header inline (KHÔNG cần đi "Sửa header" page). 2 chunk per-commit (build pass mỗi chunk): C1 fe-admin (3 file) — InfoTab thêm prop `readOnly + autoEdit`, canEdit=`!readOnly && isDraft`: display mode hiển thị FormRow + button "✎ Sửa" góc trên phải, editing mode card border brand-200 + 4 input (Tên */Dự án locked/Địa điểm/Mô tả/Payment) + Save (PUT /pe/:id full payload + invalidate detail+list)/Hủy. PeListPanel thêm prop `onEditClick`, pencil icon absolute right-2 top-2 mỗi row, opacity-0 group-hover:opacity-100. PurchaseEvaluationWorkspacePage đọc URL `?editHeader=1` → pass `autoEditHeader` xuống PeDetailTabs → trigger edit auto. C2 fe-user mirror y hệt 3 file (rule §3.9). KHÔNG đụng BE. KHÔNG refactor workspace "new" mode (defer — PeHeaderForm hiện tại đủ dùng, làm thêm khi user feedback). | `5a89dd2` (C1) · (current C2) · (current C3 docs) |
| 2026-05-07 | Claude | **PE BudgetFieldRow inline editor — toggle + 2 fields trong Section 2 (cả 3 view)** — User feedback after Session 11: muốn toggle "Nhập tay" + 2 input fields hiển thị trực tiếp trong Section 2 "b. Ngân sách" của PeDetailTabs — KHÔNG chỉ ở "Sửa header" page, mà cả 3 view (Workspace / Danh sách / Duyệt). Empty values cứ hiển thị empty (không text "(chưa link)" verbose). 2 chunk mirror per-commit (build pass mỗi chunk): C1 fe-admin BudgetFieldRow component (~125 LOC) thay FormRow tĩnh ở `b. Ngân sách`. canEdit=`!readOnly && isDraft`: render toggle + Select Budget OR 2 input grid 2-col + nút "Lưu ngân sách" (chỉ hiện khi dirty) + "Hủy thay đổi". Save: full PUT `/pe/:id` với current entity values + new budget payload. Read-only mode (Duyệt + !isDraft): chỉ display, KHÔNG toggle/inputs, empty hiển thị "—". C2 fe-user mirror y hệt 1 file (rule §3.9). Mỗi chunk: build pass + 0 TS error. KHÔNG đụng BE — re-use existing PUT endpoint. | `19712d8` (C1) · `d5c6f12` (C2) · (current C3) | | 2026-05-07 | Claude | **PE BudgetFieldRow inline editor — toggle + 2 fields trong Section 2 (cả 3 view)** — User feedback after Session 11: muốn toggle "Nhập tay" + 2 input fields hiển thị trực tiếp trong Section 2 "b. Ngân sách" của PeDetailTabs — KHÔNG chỉ ở "Sửa header" page, mà cả 3 view (Workspace / Danh sách / Duyệt). Empty values cứ hiển thị empty (không text "(chưa link)" verbose). 2 chunk mirror per-commit (build pass mỗi chunk): C1 fe-admin BudgetFieldRow component (~125 LOC) thay FormRow tĩnh ở `b. Ngân sách`. canEdit=`!readOnly && isDraft`: render toggle + Select Budget OR 2 input grid 2-col + nút "Lưu ngân sách" (chỉ hiện khi dirty) + "Hủy thay đổi". Save: full PUT `/pe/:id` với current entity values + new budget payload. Read-only mode (Duyệt + !isDraft): chỉ display, KHÔNG toggle/inputs, empty hiển thị "—". C2 fe-user mirror y hệt 1 file (rule §3.9). Mỗi chunk: build pass + 0 TS error. KHÔNG đụng BE — re-use existing PUT endpoint. | `19712d8` (C1) · `d5c6f12` (C2) · (current C3) |
| 2026-05-07 | Claude | **Migration 17 — manual budget fields fallback cho PE + HĐ (toggle "Nhập tay")** — User feedback: khi project chưa có Budget approved (Phase=DaDuyet eligible), user phải break flow đi tạo Budget + duyệt + quay lại link. UX kém. Solution: thêm fallback "Nhập tay" — checkbox toggle cạnh Label Ngân sách, khi ON → hide Select Budget, show 2 input field grid 2-col (Tên tham chiếu text + Số tiền number formatted VND). Lưu trên entity row, KHÔNG cần Budget entity. Q1-3 chốt: 1 = stick-toggle reveal 2 input fields; 2 = cả BudgetId + manual fields cùng null OK (KHÔNG XOR validate); 3 = mirror logic sang HĐ luôn (cả 7 ContractType qua ContractCreatePage). 5 chunk per-commit (build + 83 test pass mỗi chunk): C1 Migration 17 `AddManualBudgetFieldsToPeAndContract` 4 ALTER (PE + HĐ × Name nvarchar(200) + Amount decimal(18,2)) + Domain 2 entity + 2 EF config (HasMaxLength + HasPrecision) — applied LocalDB OK, 3-file rule. C2 App CQRS — CreatePurchaseEvaluationCommand + Update + DTO + Validator (>=0 when has value), CreateContractCommand + Update + DTO + diff log audit, CreateContractFromEvaluation carry forward pe.BudgetManualName/Amount → contract khi gen HĐ từ phiếu. C3 FE-Admin — types +2 field, PeHeaderForm toggle + 2 input + payload conditional (manual mode clear budgetId, link mode clear manual), PeDetailTabs Section "b. Ngân sách" fallback display "Tên · Số tiền + badge nhập tay" khi !budget + có manual data, refactor PurchaseEvaluationCreatePage wrap PeHeaderForm DRY (222→30 LOC), ContractCreatePage NewContractForm + EditContractForm cùng pattern + read-only display branch khi !isDraft. C4 fe-user mirror y hệt 6 file. C5 docs (this row + HANDOFF + session log). KHÔNG đụng Budget entity / Phase=DaDuyet validation (giữ invariant). | `ecd5f7e` (C1) · `0f7901c` (C2) · `bab5031` (C3) · `14f8d9d` (C4) · (current C5) | | 2026-05-07 | Claude | **Migration 17 — manual budget fields fallback cho PE + HĐ (toggle "Nhập tay")** — User feedback: khi project chưa có Budget approved (Phase=DaDuyet eligible), user phải break flow đi tạo Budget + duyệt + quay lại link. UX kém. Solution: thêm fallback "Nhập tay" — checkbox toggle cạnh Label Ngân sách, khi ON → hide Select Budget, show 2 input field grid 2-col (Tên tham chiếu text + Số tiền number formatted VND). Lưu trên entity row, KHÔNG cần Budget entity. Q1-3 chốt: 1 = stick-toggle reveal 2 input fields; 2 = cả BudgetId + manual fields cùng null OK (KHÔNG XOR validate); 3 = mirror logic sang HĐ luôn (cả 7 ContractType qua ContractCreatePage). 5 chunk per-commit (build + 83 test pass mỗi chunk): C1 Migration 17 `AddManualBudgetFieldsToPeAndContract` 4 ALTER (PE + HĐ × Name nvarchar(200) + Amount decimal(18,2)) + Domain 2 entity + 2 EF config (HasMaxLength + HasPrecision) — applied LocalDB OK, 3-file rule. C2 App CQRS — CreatePurchaseEvaluationCommand + Update + DTO + Validator (>=0 when has value), CreateContractCommand + Update + DTO + diff log audit, CreateContractFromEvaluation carry forward pe.BudgetManualName/Amount → contract khi gen HĐ từ phiếu. C3 FE-Admin — types +2 field, PeHeaderForm toggle + 2 input + payload conditional (manual mode clear budgetId, link mode clear manual), PeDetailTabs Section "b. Ngân sách" fallback display "Tên · Số tiền + badge nhập tay" khi !budget + có manual data, refactor PurchaseEvaluationCreatePage wrap PeHeaderForm DRY (222→30 LOC), ContractCreatePage NewContractForm + EditContractForm cùng pattern + read-only display branch khi !isDraft. C4 fe-user mirror y hệt 6 file. C5 docs (this row + HANDOFF + session log). KHÔNG đụng Budget entity / Phase=DaDuyet validation (giữ invariant). | `ecd5f7e` (C1) · `0f7901c` (C2) · `bab5031` (C3) · `14f8d9d` (C4) · (current C5) |
| 2026-05-07 | Claude | **PE "Thao tác" 2-panel workspace + Panel 1 read-only picker + Section 5 disabled** — User chỉ thị restructure leaf "Thao tác" (Pe_DuyetNcc_Create + Pe_DuyetNccPhuongAn_Create) từ page tạo header riêng (`/purchase-evaluations/new?type=N` — chỉ form Tên/Project/Địa điểm/Payment/Budget) sang workspace 2-panel mirror pattern HĐ Thầu phụ ContractCreatePage. 5 câu chốt spec trước code: Q1 Panel 2 KHÔNG render Workflow Panel + Approvals + History (chỉ data entry); Panel 1 = pure picker, KHÔNG inline edit/delete; Q2 mirror HĐ Thầu phụ pattern (sticky "+ Thêm mới" + Panel 2 transition new→edit form); Q3 leaf "Danh sách" + "Duyệt" giữ 3-panel hiện tại; Q4 route mới `/purchase-evaluations/workspace?type={1\|2}`; Q5 Section 5 Ý kiến 4PB disable trong workspace (vì người ta nhập khi duyệt, không phải lúc nhập liệu). 2 chunk per-commit (build + 83 test pass mỗi chunk): C1 fe-admin (3 file mới `PeListPanel.tsx` ~180 LOC pure picker reuse + `PeHeaderForm.tsx` ~210 LOC extract + `PurchaseEvaluationWorkspacePage.tsx` ~120 LOC, 3 file sửa `PeDetailTabs.tsx` thêm prop `mode?: 'detail' \| 'workspace'` + Section 5 hint banner amber + Layout.tsx resolver `Pe_*_Create``/workspace?type=N` + App.tsx route mới). C2 fe-user mirror y hệt 6 file (rule §3.9). KHÔNG đụng BE / migration / schema / endpoint. Route `/new` cũ giữ tồn tại cho deep-link "Sửa header" button. **Total +1142 LOC FE / 0 BE / 32 FE pages.** | `ee0d360` (C1) · `ecf3c59` (C2) · (current C3) | | 2026-05-07 | Claude | **PE "Thao tác" 2-panel workspace + Panel 1 read-only picker + Section 5 disabled** — User chỉ thị restructure leaf "Thao tác" (Pe_DuyetNcc_Create + Pe_DuyetNccPhuongAn_Create) từ page tạo header riêng (`/purchase-evaluations/new?type=N` — chỉ form Tên/Project/Địa điểm/Payment/Budget) sang workspace 2-panel mirror pattern HĐ Thầu phụ ContractCreatePage. 5 câu chốt spec trước code: Q1 Panel 2 KHÔNG render Workflow Panel + Approvals + History (chỉ data entry); Panel 1 = pure picker, KHÔNG inline edit/delete; Q2 mirror HĐ Thầu phụ pattern (sticky "+ Thêm mới" + Panel 2 transition new→edit form); Q3 leaf "Danh sách" + "Duyệt" giữ 3-panel hiện tại; Q4 route mới `/purchase-evaluations/workspace?type={1\|2}`; Q5 Section 5 Ý kiến 4PB disable trong workspace (vì người ta nhập khi duyệt, không phải lúc nhập liệu). 2 chunk per-commit (build + 83 test pass mỗi chunk): C1 fe-admin (3 file mới `PeListPanel.tsx` ~180 LOC pure picker reuse + `PeHeaderForm.tsx` ~210 LOC extract + `PurchaseEvaluationWorkspacePage.tsx` ~120 LOC, 3 file sửa `PeDetailTabs.tsx` thêm prop `mode?: 'detail' \| 'workspace'` + Section 5 hint banner amber + Layout.tsx resolver `Pe_*_Create``/workspace?type=N` + App.tsx route mới). C2 fe-user mirror y hệt 6 file (rule §3.9). KHÔNG đụng BE / migration / schema / endpoint. Route `/new` cũ giữ tồn tại cho deep-link "Sửa header" button. **Total +1142 LOC FE / 0 BE / 32 FE pages.** | `ee0d360` (C1) · `ecf3c59` (C2) · (current C3) |

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>

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>