diff --git a/fe-admin/src/components/pe/PeDetailTabs.tsx b/fe-admin/src/components/pe/PeDetailTabs.tsx
index 7069e4c..30e0809 100644
--- a/fe-admin/src/components/pe/PeDetailTabs.tsx
+++ b/fe-admin/src/components/pe/PeDetailTabs.tsx
@@ -12,6 +12,7 @@ import { Dialog } from '@/components/ui/Dialog'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { Select } from '@/components/ui/Select'
+import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
@@ -721,10 +722,12 @@ function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boo
Điều khoản thanh toán
- setPaymentTerms(e.target.value)}
- placeholder="JSON hoặc text"
+ placeholder={'Nhập điều khoản — Enter để xuống dòng'}
/>
diff --git a/fe-admin/src/components/pe/PeHeaderForm.tsx b/fe-admin/src/components/pe/PeHeaderForm.tsx
index ad354b4..03bc7a8 100644
--- a/fe-admin/src/components/pe/PeHeaderForm.tsx
+++ b/fe-admin/src/components/pe/PeHeaderForm.tsx
@@ -2,12 +2,13 @@
// reuse trong Workspace mode "new". Sửa header sau khi tạo vẫn redirect về
// page Create cũ (`/purchase-evaluations/new?id=`) — workspace KHÔNG re-edit
// header.
-import { useEffect, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
+import { SearchableSelect } from '@/components/ui/SearchableSelect'
import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
@@ -58,6 +59,9 @@ export function PeHeaderForm({
enabled: !!editId,
})
+ // S59 — track Địa điểm auto-fill gần nhất (mirror PeWorkspaceCreateView).
+ const lastAutoLoc = useRef('')
+
// S57bis — list Hạng mục công việc (active only, mirror PeWorkspaceCreateView).
// S59 — sort numeric-aware client (mã PMH không pad số — mirror CreateView).
const workItems = useQuery({
@@ -194,39 +198,43 @@ export function PeHeaderForm({
workItemId + tenGoiThau (= tên hạng mục). Phiếu cũ (workItemId null,
tên nhập tay): option đầu "Giữ nguyên" — không ép đổi, PUT null-safe. */}
Tên gói thầu (Hạng mục công việc) *
- ({
+ value: w.id,
+ label: `${w.category ? `[${w.category}] ` : ''}${w.code} — ${w.name}`,
+ }))}
value={form.workItemId}
- onChange={e => {
- const id = e.target.value
+ onChange={id => {
const w = workItems.data?.find(x => x.id === id)
setForm({ ...form, workItemId: id, tenGoiThau: id ? (w?.name ?? '') : (editId ? form.tenGoiThau : '') })
}}
- >
-
- {editId && form.tenGoiThau && !form.workItemId
- ? `Giữ nguyên: ${form.tenGoiThau}`
- : '-- Chọn hạng mục công việc --'}
-
- {workItems.data?.map(w => (
-
- {w.category ? `[${w.category}] ` : ''}{w.code} — {w.name}
-
- ))}
-
+ placeholder={editId && form.tenGoiThau && !form.workItemId
+ ? `Giữ nguyên: ${form.tenGoiThau}`
+ : '-- Chọn hạng mục công việc (gõ để lọc) --'}
+ />
Dự án *
- ({ value: p.id, label: `${p.code} — ${p.name}` }))}
value={form.projectId}
disabled={!!editId}
- onChange={e => setForm({ ...form, projectId: e.target.value })}
- >
- -- Chọn --
- {projects.data?.map(p => (
- {p.code} — {p.name}
- ))}
-
+ onChange={id => {
+ const p = projects.data?.find(x => x.id === id)
+ const loc = p?.location ?? ''
+ setForm(f => {
+ const untouched = !f.diaDiem || f.diaDiem === lastAutoLoc.current
+ return { ...f, projectId: id, diaDiem: untouched ? loc : f.diaDiem }
+ })
+ lastAutoLoc.current = loc
+ }}
+ placeholder="-- Chọn dự án (gõ để lọc) --"
+ />
diff --git a/fe-admin/src/components/pe/PeWorkspaceCreateView.tsx b/fe-admin/src/components/pe/PeWorkspaceCreateView.tsx
index 351ad93..c993fdf 100644
--- a/fe-admin/src/components/pe/PeWorkspaceCreateView.tsx
+++ b/fe-admin/src/components/pe/PeWorkspaceCreateView.tsx
@@ -6,14 +6,16 @@
//
// Pattern user 2026-05-07: "Thêm mới list ra hết trường dữ liệu giống chỉnh
// sửa nhưng trống, mở rộng từng phần. Save header xong mới cho nhập chi tiết."
-import { useState } from 'react'
+import { useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Lock } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
+import { SearchableSelect } from '@/components/ui/SearchableSelect'
import { Select } from '@/components/ui/Select'
+import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { PurchaseEvaluationTypeLabel } from '@/types/purchaseEvaluation'
@@ -86,6 +88,10 @@ export function PeWorkspaceCreateView({
queryFn: async () => (await api.get<{ items: Project[] }>('/projects', { params: { pageSize: 1000 } })).data.items,
})
+ // S59 — track giá trị Địa điểm auto-fill gần nhất (từ Project.Location) để biết
+ // user đã gõ tay chưa: diaDiem === lastAutoLoc ⟹ chưa đụng → đổi dự án ghi đè được.
+ const lastAutoLoc = useRef('')
+
// S57bis — list Hạng mục công việc (active only — filter client nếu BE trả isActive).
// S59 — sort numeric-aware client: mã PMH không pad số (MAT-1..16, MEP-SUB-1…)
// → BE OrderBy(Code) string xếp "MAT-10" trước "MAT-2"; re-sort {numeric:true}.
@@ -200,22 +206,19 @@ export function PeWorkspaceCreateView({
thay nhập tay; chọn 1 phát set cả workItemId + tenGoiThau (= tên
hạng mục). Phiếu vẫn lưu cả 2 field BE — không đổi contract. */}
a. Tên gói thầu (Hạng mục công việc) *
-
({
+ value: w.id,
+ label: `${w.category ? `[${w.category}] ` : ''}${w.code} — ${w.name}`,
+ }))}
value={form.workItemId}
- onChange={e => {
- const id = e.target.value
+ onChange={id => {
const w = workItems.data?.find(x => x.id === id)
setForm({ ...form, workItemId: id, tenGoiThau: id ? (w?.name ?? '') : '' })
}}
- required
- >
- — Chọn hạng mục công việc —
- {workItems.data?.map(w => (
-
- {w.category ? `[${w.category}] ` : ''}{w.code} — {w.name}
-
- ))}
-
+ placeholder="— Chọn hạng mục công việc (gõ để lọc) —"
+ />
{workItems.data && workItems.data.length === 0 && (
⚠ Chưa có hạng mục công việc nào. Vào Danh mục → Hạng mục công việc để tạo trước.
@@ -224,15 +227,23 @@ export function PeWorkspaceCreateView({
b. Dự án *
- ({ value: p.id, label: `${p.code} — ${p.name}` }))}
value={form.projectId}
- onChange={e => setForm({ ...form, projectId: e.target.value, budgetId: '' })}
- >
- — Chọn dự án —
- {projects.data?.map(p => (
- {p.code} — {p.name}
- ))}
-
+ onChange={id => {
+ const p = projects.data?.find(x => x.id === id)
+ const loc = p?.location ?? ''
+ setForm(f => {
+ const untouched = !f.diaDiem || f.diaDiem === lastAutoLoc.current
+ return { ...f, projectId: id, budgetId: '', diaDiem: untouched ? loc : f.diaDiem }
+ })
+ lastAutoLoc.current = loc
+ }}
+ placeholder="— Chọn dự án (gõ để lọc) —"
+ />
Địa điểm
@@ -271,10 +282,13 @@ export function PeWorkspaceCreateView({
Khác (nhập tay)
{isPaymentCustom && (
-
setForm({ ...form, paymentTerms: e.target.value })}
- placeholder="Nhập điều khoản tùy chỉnh"
+ placeholder={'Nhập điều khoản tùy chỉnh — Enter để xuống dòng, vd:\n1. Tạm ứng: 10%\n2. Thanh toán hàng tháng: 80% - 45 ngày'}
className="mt-2"
/>
)}
diff --git a/fe-admin/src/components/ui/SearchableSelect.tsx b/fe-admin/src/components/ui/SearchableSelect.tsx
new file mode 100644
index 0000000..de02775
--- /dev/null
+++ b/fe-admin/src/components/ui/SearchableSelect.tsx
@@ -0,0 +1,134 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { ChevronDown, X } from 'lucide-react'
+import { cn } from '@/lib/cn'
+
+// S59 — Searchable combobox (UAT 06-11: "nên có lọc để tự đánh chữ" — dropdown
+// 70+ mục Hạng mục/Dự án khó dò bằng native
). Input gõ-để-lọc theo
+// label, match BỎ DẤU tiếng Việt (gõ "be tong" trúng "Bê tông"). Tự render
+// listbox absolute — không thêm lib ngoài. Style mirror ui/Input density S55.
+// Required-gate: form dùng canSubmit/disabled button (không dựa native required).
+export type SearchableOption = { value: string; label: string }
+
+function fold(s: string): string {
+ return s.normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/đ/g, 'd').replace(/Đ/g, 'D').toLowerCase()
+}
+
+export function SearchableSelect({
+ options,
+ value,
+ onChange,
+ placeholder = '— Chọn —',
+ disabled,
+ className,
+}: {
+ options: SearchableOption[]
+ value: string
+ onChange: (value: string) => void
+ placeholder?: string
+ disabled?: boolean
+ className?: string
+}) {
+ const [open, setOpen] = useState(false)
+ const [query, setQuery] = useState('')
+ const [hi, setHi] = useState(0)
+ const wrapRef = useRef(null)
+ const listRef = useRef(null)
+
+ const selected = options.find(o => o.value === value) ?? null
+ const filtered = useMemo(() => {
+ const q = fold(query.trim())
+ return q ? options.filter(o => fold(o.label).includes(q)) : options
+ }, [options, query])
+
+ // Click ngoài → đóng + reset query (input quay về hiển thị label đã chọn).
+ useEffect(() => {
+ function onDocMouseDown(e: MouseEvent) {
+ if (!wrapRef.current?.contains(e.target as Node)) {
+ setOpen(false)
+ setQuery('')
+ }
+ }
+ document.addEventListener('mousedown', onDocMouseDown)
+ return () => document.removeEventListener('mousedown', onDocMouseDown)
+ }, [])
+
+ // Giữ item highlight trong khung nhìn khi điều hướng bàn phím.
+ useEffect(() => {
+ listRef.current?.children[hi]?.scrollIntoView({ block: 'nearest' })
+ }, [hi, open])
+
+ function pick(v: string) {
+ onChange(v)
+ setOpen(false)
+ setQuery('')
+ }
+
+ return (
+
+
{ if (!disabled) { setOpen(true); setQuery(''); setHi(0) } }}
+ onChange={e => { setQuery(e.target.value); setOpen(true); setHi(0) }}
+ onKeyDown={e => {
+ if (!open && (e.key === 'ArrowDown' || e.key === 'Enter')) { setOpen(true); return }
+ if (e.key === 'ArrowDown') { e.preventDefault(); setHi(h => Math.min(h + 1, filtered.length - 1)) }
+ else if (e.key === 'ArrowUp') { e.preventDefault(); setHi(h => Math.max(h - 1, 0)) }
+ else if (e.key === 'Enter') { e.preventDefault(); if (filtered[hi]) pick(filtered[hi].value) }
+ else if (e.key === 'Escape') { setOpen(false); setQuery('') }
+ }}
+ className={cn(
+ 'h-8 w-full rounded-lg border border-slate-300 bg-white px-3 py-1.5 pr-14 text-sm text-slate-900',
+ 'placeholder:text-slate-400',
+ 'transition-[border-color,box-shadow] focus-visible:border-brand-400 focus-visible:ring-2 focus-visible:ring-brand-500/15',
+ 'disabled:cursor-not-allowed disabled:bg-slate-50 disabled:opacity-70',
+ )}
+ />
+
+
+
+ {selected && !disabled && !open && (
+
pick('')}
+ className="absolute inset-y-0 right-8 flex items-center text-slate-400 hover:text-slate-600"
+ >
+
+
+ )}
+ {open && !disabled && (
+
+ {filtered.length === 0 && (
+ Không tìm thấy mục nào.
+ )}
+ {filtered.map((o, i) => (
+
+ e.preventDefault()}
+ onClick={() => pick(o.value)}
+ onMouseEnter={() => setHi(i)}
+ className={cn(
+ 'block w-full px-3 py-1.5 text-left text-xs',
+ i === hi ? 'bg-brand-50 text-brand-700' : 'text-slate-700',
+ o.value === value && 'font-semibold',
+ )}
+ >
+ {o.label}
+
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/fe-user/src/components/pe/PeDetailTabs.tsx b/fe-user/src/components/pe/PeDetailTabs.tsx
index eda2007..30e0809 100644
--- a/fe-user/src/components/pe/PeDetailTabs.tsx
+++ b/fe-user/src/components/pe/PeDetailTabs.tsx
@@ -7,16 +7,17 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { Check, ChevronDown, ChevronRight, Download, Eye, Paperclip, Pencil, Plus, Trash2, Upload, Wallet } from 'lucide-react'
-import { AttachmentPreviewDialog, isPreviewable } from './AttachmentPreviewDialog'
import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { Select } from '@/components/ui/Select'
+import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import { useAuth } from '@/contexts/AuthContext'
+import { AttachmentPreviewDialog, isPreviewable } from './AttachmentPreviewDialog'
import {
PeAttachmentPurpose,
PeAttachmentPurposeLabel,
@@ -103,8 +104,6 @@ export function PeDetailTabs({
const opinionsReadOnly = readOnly || mode === 'workspace'
// Mig 28 (S21 t4) — F3: Approver edit Section 2 (Hạng mục + NCC + Báo giá).
- // Khi phase=ChoDuyet + workflow.AllowApproverEditDetails + actor match
- // CurrentLevel.ApproverUserId → cho phép edit Section 2 dù readOnly=true.
const { user: currentUser } = useAuth()
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
const v2Approvers = evaluation.currentApproval?.approvers ?? []
@@ -114,16 +113,14 @@ export function PeDetailTabs({
// Mig 29 (S21 t5) — read F3 từ currentLevelOptions (per-NV slot)
&& (evaluation.currentLevelOptions?.allowApproverEditDetails ?? false)
&& actorMatchesLevel
- // itemsReadOnly = readOnly trừ khi approver mode F3 mở
const itemsReadOnly = readOnly && !approverEditMode
- // Mig 31 (S23 t1) — F2 Drafter-from-Nháp semantic deprecated. skipToFinal
- // moved sang Approver scope ChoDuyet (per-Level slot — xem PeWorkflowPanel).
- // Drafter SUBMIT chạy normal init pointer Step 0 Cấp 1.
-
// "Lưu & Gửi Duyệt" workspace mode (user 2026-05-07): trigger transition
// sang phase tiếp theo (= Đã gửi duyệt). nextPhases[0] thường là ChoPurchasing
// (skip TuChoi). Sau success → toast + invalidate + onBack đóng workspace.
+ // Mig 31 (S23 t1) — F2 Drafter-from-Nháp semantic deprecated. skipToFinal moved
+ // sang Approver scope ChoDuyet (per-Level slot — xem PeWorkflowPanel).
+
const submitForApproval = useMutation({
mutationFn: async () => {
const next = evaluation.workflow.nextPhases.find(p => p !== PurchaseEvaluationPhase.TuChoi && p !== PurchaseEvaluationPhase.TraLai)
@@ -215,8 +212,7 @@ export function PeDetailTabs({
- {/* Mig 28 (S21 t4) — F3: itemsReadOnly cho phép approver edit Section 2.
- Banner cảnh báo "Bạn đang chỉnh sửa khi đang duyệt" khi approverEditMode. */}
+ {/* Mig 28 (S21 t4) — F3: itemsReadOnly cho phép approver edit Section 2 */}
{/* Plan Q S23 t7 — Drop mx-5 banner, full-width Section padding to
align với ItemsTab header (button "+ Thêm hạng mục" right-aligned
KHÔNG còn lệch khỏi banner inset gap). */}
@@ -243,9 +239,9 @@ export function PeDetailTabs({
?
: }
- {/* S22+4 — Feature 2: Section "Điều chỉnh ngân sách" (mirror fe-admin).
- Drafter (Nháp/Trả lại) HOẶC Approver currentLevel (Đang duyệt) HOẶC
- Admin sửa Budget link / Manual amount. BE PATCH /budget-adjust. */}
+ {/* S22+4 — Feature 2: Section "Điều chỉnh ngân sách" cho phép Drafter
+ (Nháp/Trả lại) HOẶC Approver currentLevel (Đang duyệt) HOẶC Admin
+ sửa Budget link / Manual amount. BE PATCH /budget-adjust riêng. */}
@@ -726,10 +722,12 @@ function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boo
Điều khoản thanh toán
- setPaymentTerms(e.target.value)}
- placeholder="JSON hoặc text"
+ placeholder={'Nhập điều khoản — Enter để xuống dòng'}
/>
@@ -963,8 +961,8 @@ function BudgetFieldRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolea
}
// ===== Section "Điều chỉnh ngân sách" (S22+4 — Feature 2) =====
-// Mirror fe-admin BudgetAdjustSection — Drafter (Nháp/Trả lại) HOẶC Approver
-// currentLevel (Đang duyệt) HOẶC Admin sửa Budget link / Manual amount via
+// Cho phép Drafter (DangSoanThao/TraLai) HOẶC Approver currentLevel (ChoDuyet)
+// HOẶC Admin sửa BudgetId + BudgetManualName + BudgetManualAmount qua endpoint
// PATCH /budget-adjust riêng. Audit changelog tự động.
function BudgetAdjustSection({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
const { user: currentUser } = useAuth()
@@ -975,7 +973,8 @@ function BudgetAdjustSection({ ev, readOnly }: { ev: PeDetailBundle; readOnly: b
const isDrafter = currentUser?.id != null && ev.drafterUserId === currentUser.id
const isDrafterPhase = ev.phase === PurchaseEvaluationPhase.DangSoanThao
|| ev.phase === PurchaseEvaluationPhase.TraLai
- // F4 Approver scope (Mig 30): ChoDuyet + actor in approvers + flag tick.
+ // F4 Approver scope (Mig 30): phase ChoDuyet + actor in currentApproval.approvers
+ // + currentLevel có flag AllowApproverEditBudget=true (admin Designer tick per slot).
const actorInCurrentLevel = ev.currentApproval?.approvers?.some(a => a.userId === currentUser?.id) ?? false
const approverEditBudgetAllowed = ev.currentLevelOptions?.allowApproverEditBudget ?? false
const isApproverChoDuyet = ev.phase === PurchaseEvaluationPhase.ChoDuyet
@@ -983,8 +982,8 @@ function BudgetAdjustSection({ ev, readOnly }: { ev: PeDetailBundle; readOnly: b
&& approverEditBudgetAllowed
// S23 t2 bug fix: F4 Approver scope BYPASS readOnly (mirror F3 itemsReadOnly
- // pattern line 118). Khi admin tick AllowApproverEditBudget cho slot + actor
- // match + Phase=ChoDuyet → button "Điều chỉnh" enable trong menu Duyệt (readOnly=true)
+ // pattern). Khi admin tick AllowApproverEditBudget cho slot + actor match +
+ // Phase=ChoDuyet → button "Điều chỉnh" enable trong menu Duyệt (readOnly=true)
// dù chế độ chỉ-đọc. Drafter + Admin vẫn cần !readOnly (chỉ active từ Workspace).
const canAdjust = isAdmin
|| (!readOnly && isDrafter && isDrafterPhase)
@@ -1020,6 +1019,10 @@ function BudgetAdjustSection({ ev, readOnly }: { ev: PeDetailBundle; readOnly: b
onError: e => toast.error(getErrorMessage(e)),
})
+ // History defer S22+5 — changelog fetch separate endpoint, KHÔNG có trong
+ // PeDetailBundle. UAT user xem ở Panel "Lịch sử thay đổi" thông qua tab History.
+
+ // Display read mode
const displayLink = ev.budget ? (
{ev.budget.maNganSach ?? '—'}
@@ -1039,6 +1042,7 @@ function BudgetAdjustSection({ ev, readOnly }: { ev: PeDetailBundle; readOnly: b
return (
+ {/* Read mode + Edit toggle */}
{!editing && (
@@ -1063,6 +1067,7 @@ function BudgetAdjustSection({ ev, readOnly }: { ev: PeDetailBundle; readOnly: b
)}
+ {/* Edit mode */}
{editing && canAdjust && (
{isApproverChoDuyet && (
@@ -1136,6 +1141,8 @@ function BudgetAdjustSection({ ev, readOnly }: { ev: PeDetailBundle; readOnly: b
)}
+
+ {/* History defer S22+5 — UAT user xem Panel 3 "Lịch sử thay đổi" */}
)
}
@@ -1323,6 +1330,7 @@ function AddSupplierDialog({ evaluationId, detailId, onClose }: {
const mut = useMutation({
mutationFn: async () => {
+ // Step 1: tạo NCC tham gia (PE.Suppliers row)
const res = await api.post<{ id: string }>(`/purchase-evaluations/${evaluationId}/suppliers`, {
supplierId: form.supplierId,
displayName: form.displayName,
@@ -1333,6 +1341,7 @@ function AddSupplierDialog({ evaluationId, detailId, onClose }: {
note: form.note,
})
const newSupplierRowId = res.data.id
+ // Step 2: tạo quote cho hạng mục (chỉ khi có detailId + thanhTien > 0)
if (detailId && form.thanhTien > 0) {
await api.post(`/purchase-evaluations/${evaluationId}/quotes`, {
purchaseEvaluationDetailId: detailId,
@@ -2178,11 +2187,7 @@ function HistoryTab({ ev }: { ev: PeDetailBundle }) {
queryFn: async () => (await api.get(`/purchase-evaluations/${ev.id}/changelogs`)).data,
})
- // Plan AF S25 — userMap fallback cho historical entries pre-Plan AE deploy
- // (userName="" empty hoặc null). Build map từ data có sẵn PeDetailBundle:
- // drafter + approvals + approvalFlow + levelOpinions + departmentOpinions.
- // KHÔNG cần extra fetch /api/users (admin permission). Cover gần hết users
- // tham gia phiếu.
+ // Plan AF S25 — userMap fallback cho historical entries pre-Plan AE
const userMap = useMemo(() => {
const m = new Map()
if (ev.drafterUserId && ev.drafterName) m.set(ev.drafterUserId, ev.drafterName)
diff --git a/fe-user/src/components/pe/PeHeaderForm.tsx b/fe-user/src/components/pe/PeHeaderForm.tsx
index ad354b4..03bc7a8 100644
--- a/fe-user/src/components/pe/PeHeaderForm.tsx
+++ b/fe-user/src/components/pe/PeHeaderForm.tsx
@@ -2,12 +2,13 @@
// reuse trong Workspace mode "new". Sửa header sau khi tạo vẫn redirect về
// page Create cũ (`/purchase-evaluations/new?id=`) — workspace KHÔNG re-edit
// header.
-import { useEffect, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
+import { SearchableSelect } from '@/components/ui/SearchableSelect'
import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
@@ -58,6 +59,9 @@ export function PeHeaderForm({
enabled: !!editId,
})
+ // S59 — track Địa điểm auto-fill gần nhất (mirror PeWorkspaceCreateView).
+ const lastAutoLoc = useRef('')
+
// S57bis — list Hạng mục công việc (active only, mirror PeWorkspaceCreateView).
// S59 — sort numeric-aware client (mã PMH không pad số — mirror CreateView).
const workItems = useQuery({
@@ -194,39 +198,43 @@ export function PeHeaderForm({
workItemId + tenGoiThau (= tên hạng mục). Phiếu cũ (workItemId null,
tên nhập tay): option đầu "Giữ nguyên" — không ép đổi, PUT null-safe. */}
Tên gói thầu (Hạng mục công việc) *
- ({
+ value: w.id,
+ label: `${w.category ? `[${w.category}] ` : ''}${w.code} — ${w.name}`,
+ }))}
value={form.workItemId}
- onChange={e => {
- const id = e.target.value
+ onChange={id => {
const w = workItems.data?.find(x => x.id === id)
setForm({ ...form, workItemId: id, tenGoiThau: id ? (w?.name ?? '') : (editId ? form.tenGoiThau : '') })
}}
- >
-
- {editId && form.tenGoiThau && !form.workItemId
- ? `Giữ nguyên: ${form.tenGoiThau}`
- : '-- Chọn hạng mục công việc --'}
-
- {workItems.data?.map(w => (
-
- {w.category ? `[${w.category}] ` : ''}{w.code} — {w.name}
-
- ))}
-
+ placeholder={editId && form.tenGoiThau && !form.workItemId
+ ? `Giữ nguyên: ${form.tenGoiThau}`
+ : '-- Chọn hạng mục công việc (gõ để lọc) --'}
+ />
Dự án *
- ({ value: p.id, label: `${p.code} — ${p.name}` }))}
value={form.projectId}
disabled={!!editId}
- onChange={e => setForm({ ...form, projectId: e.target.value })}
- >
- -- Chọn --
- {projects.data?.map(p => (
- {p.code} — {p.name}
- ))}
-
+ onChange={id => {
+ const p = projects.data?.find(x => x.id === id)
+ const loc = p?.location ?? ''
+ setForm(f => {
+ const untouched = !f.diaDiem || f.diaDiem === lastAutoLoc.current
+ return { ...f, projectId: id, diaDiem: untouched ? loc : f.diaDiem }
+ })
+ lastAutoLoc.current = loc
+ }}
+ placeholder="-- Chọn dự án (gõ để lọc) --"
+ />
diff --git a/fe-user/src/components/pe/PeWorkspaceCreateView.tsx b/fe-user/src/components/pe/PeWorkspaceCreateView.tsx
index 351ad93..c993fdf 100644
--- a/fe-user/src/components/pe/PeWorkspaceCreateView.tsx
+++ b/fe-user/src/components/pe/PeWorkspaceCreateView.tsx
@@ -6,14 +6,16 @@
//
// Pattern user 2026-05-07: "Thêm mới list ra hết trường dữ liệu giống chỉnh
// sửa nhưng trống, mở rộng từng phần. Save header xong mới cho nhập chi tiết."
-import { useState } from 'react'
+import { useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Lock } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
+import { SearchableSelect } from '@/components/ui/SearchableSelect'
import { Select } from '@/components/ui/Select'
+import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { PurchaseEvaluationTypeLabel } from '@/types/purchaseEvaluation'
@@ -86,6 +88,10 @@ export function PeWorkspaceCreateView({
queryFn: async () => (await api.get<{ items: Project[] }>('/projects', { params: { pageSize: 1000 } })).data.items,
})
+ // S59 — track giá trị Địa điểm auto-fill gần nhất (từ Project.Location) để biết
+ // user đã gõ tay chưa: diaDiem === lastAutoLoc ⟹ chưa đụng → đổi dự án ghi đè được.
+ const lastAutoLoc = useRef('')
+
// S57bis — list Hạng mục công việc (active only — filter client nếu BE trả isActive).
// S59 — sort numeric-aware client: mã PMH không pad số (MAT-1..16, MEP-SUB-1…)
// → BE OrderBy(Code) string xếp "MAT-10" trước "MAT-2"; re-sort {numeric:true}.
@@ -200,22 +206,19 @@ export function PeWorkspaceCreateView({
thay nhập tay; chọn 1 phát set cả workItemId + tenGoiThau (= tên
hạng mục). Phiếu vẫn lưu cả 2 field BE — không đổi contract. */}
a. Tên gói thầu (Hạng mục công việc) *
-
({
+ value: w.id,
+ label: `${w.category ? `[${w.category}] ` : ''}${w.code} — ${w.name}`,
+ }))}
value={form.workItemId}
- onChange={e => {
- const id = e.target.value
+ onChange={id => {
const w = workItems.data?.find(x => x.id === id)
setForm({ ...form, workItemId: id, tenGoiThau: id ? (w?.name ?? '') : '' })
}}
- required
- >
- — Chọn hạng mục công việc —
- {workItems.data?.map(w => (
-
- {w.category ? `[${w.category}] ` : ''}{w.code} — {w.name}
-
- ))}
-
+ placeholder="— Chọn hạng mục công việc (gõ để lọc) —"
+ />
{workItems.data && workItems.data.length === 0 && (
⚠ Chưa có hạng mục công việc nào. Vào Danh mục → Hạng mục công việc để tạo trước.
@@ -224,15 +227,23 @@ export function PeWorkspaceCreateView({
b. Dự án *
- ({ value: p.id, label: `${p.code} — ${p.name}` }))}
value={form.projectId}
- onChange={e => setForm({ ...form, projectId: e.target.value, budgetId: '' })}
- >
- — Chọn dự án —
- {projects.data?.map(p => (
- {p.code} — {p.name}
- ))}
-
+ onChange={id => {
+ const p = projects.data?.find(x => x.id === id)
+ const loc = p?.location ?? ''
+ setForm(f => {
+ const untouched = !f.diaDiem || f.diaDiem === lastAutoLoc.current
+ return { ...f, projectId: id, budgetId: '', diaDiem: untouched ? loc : f.diaDiem }
+ })
+ lastAutoLoc.current = loc
+ }}
+ placeholder="— Chọn dự án (gõ để lọc) —"
+ />
Địa điểm
@@ -271,10 +282,13 @@ export function PeWorkspaceCreateView({
Khác (nhập tay)
{isPaymentCustom && (
-
setForm({ ...form, paymentTerms: e.target.value })}
- placeholder="Nhập điều khoản tùy chỉnh"
+ placeholder={'Nhập điều khoản tùy chỉnh — Enter để xuống dòng, vd:\n1. Tạm ứng: 10%\n2. Thanh toán hàng tháng: 80% - 45 ngày'}
className="mt-2"
/>
)}
diff --git a/fe-user/src/components/ui/SearchableSelect.tsx b/fe-user/src/components/ui/SearchableSelect.tsx
new file mode 100644
index 0000000..de02775
--- /dev/null
+++ b/fe-user/src/components/ui/SearchableSelect.tsx
@@ -0,0 +1,134 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { ChevronDown, X } from 'lucide-react'
+import { cn } from '@/lib/cn'
+
+// S59 — Searchable combobox (UAT 06-11: "nên có lọc để tự đánh chữ" — dropdown
+// 70+ mục Hạng mục/Dự án khó dò bằng native
). Input gõ-để-lọc theo
+// label, match BỎ DẤU tiếng Việt (gõ "be tong" trúng "Bê tông"). Tự render
+// listbox absolute — không thêm lib ngoài. Style mirror ui/Input density S55.
+// Required-gate: form dùng canSubmit/disabled button (không dựa native required).
+export type SearchableOption = { value: string; label: string }
+
+function fold(s: string): string {
+ return s.normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/đ/g, 'd').replace(/Đ/g, 'D').toLowerCase()
+}
+
+export function SearchableSelect({
+ options,
+ value,
+ onChange,
+ placeholder = '— Chọn —',
+ disabled,
+ className,
+}: {
+ options: SearchableOption[]
+ value: string
+ onChange: (value: string) => void
+ placeholder?: string
+ disabled?: boolean
+ className?: string
+}) {
+ const [open, setOpen] = useState(false)
+ const [query, setQuery] = useState('')
+ const [hi, setHi] = useState(0)
+ const wrapRef = useRef(null)
+ const listRef = useRef(null)
+
+ const selected = options.find(o => o.value === value) ?? null
+ const filtered = useMemo(() => {
+ const q = fold(query.trim())
+ return q ? options.filter(o => fold(o.label).includes(q)) : options
+ }, [options, query])
+
+ // Click ngoài → đóng + reset query (input quay về hiển thị label đã chọn).
+ useEffect(() => {
+ function onDocMouseDown(e: MouseEvent) {
+ if (!wrapRef.current?.contains(e.target as Node)) {
+ setOpen(false)
+ setQuery('')
+ }
+ }
+ document.addEventListener('mousedown', onDocMouseDown)
+ return () => document.removeEventListener('mousedown', onDocMouseDown)
+ }, [])
+
+ // Giữ item highlight trong khung nhìn khi điều hướng bàn phím.
+ useEffect(() => {
+ listRef.current?.children[hi]?.scrollIntoView({ block: 'nearest' })
+ }, [hi, open])
+
+ function pick(v: string) {
+ onChange(v)
+ setOpen(false)
+ setQuery('')
+ }
+
+ return (
+
+
{ if (!disabled) { setOpen(true); setQuery(''); setHi(0) } }}
+ onChange={e => { setQuery(e.target.value); setOpen(true); setHi(0) }}
+ onKeyDown={e => {
+ if (!open && (e.key === 'ArrowDown' || e.key === 'Enter')) { setOpen(true); return }
+ if (e.key === 'ArrowDown') { e.preventDefault(); setHi(h => Math.min(h + 1, filtered.length - 1)) }
+ else if (e.key === 'ArrowUp') { e.preventDefault(); setHi(h => Math.max(h - 1, 0)) }
+ else if (e.key === 'Enter') { e.preventDefault(); if (filtered[hi]) pick(filtered[hi].value) }
+ else if (e.key === 'Escape') { setOpen(false); setQuery('') }
+ }}
+ className={cn(
+ 'h-8 w-full rounded-lg border border-slate-300 bg-white px-3 py-1.5 pr-14 text-sm text-slate-900',
+ 'placeholder:text-slate-400',
+ 'transition-[border-color,box-shadow] focus-visible:border-brand-400 focus-visible:ring-2 focus-visible:ring-brand-500/15',
+ 'disabled:cursor-not-allowed disabled:bg-slate-50 disabled:opacity-70',
+ )}
+ />
+
+
+
+ {selected && !disabled && !open && (
+
pick('')}
+ className="absolute inset-y-0 right-8 flex items-center text-slate-400 hover:text-slate-600"
+ >
+
+
+ )}
+ {open && !disabled && (
+
+ {filtered.length === 0 && (
+ Không tìm thấy mục nào.
+ )}
+ {filtered.map((o, i) => (
+
+ e.preventDefault()}
+ onClick={() => pick(o.value)}
+ onMouseEnter={() => setHi(i)}
+ className={cn(
+ 'block w-full px-3 py-1.5 text-left text-xs',
+ i === hi ? 'bg-brand-50 text-brand-700' : 'text-slate-700',
+ o.value === value && 'font-semibold',
+ )}
+ >
+ {o.label}
+
+
+ ))}
+
+ )}
+
+ )
+}