[CLAUDE] FE-Admin+FE-User: UX form tao phieu theo UAT feedback - combobox go-de-loc (Hang muc + Du an) + auto dia diem + dieu khoan da dong
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m31s

- NEW ui/SearchableSelect: combobox tu render, go loc theo label, match BO DAU
  tieng Viet (go "be tong" trung "Be tong"), keyboard arrows/Enter/Esc, clear (x),
  style mirror ui/Input density S55. Khong them lib ngoai.
- PeWorkspaceCreateView + PeHeaderForm: Hang muc + Du an doi Select -> SearchableSelect
  (UAT: "nen co loc de tu danh chu" / "nen co tu go chu" - 70+ muc kho do).
- Auto dia diem (UAT "dia chi nen tu auto"): chon Du an tu dien diaDiem tu
  Project.Location (S55), chi ghi de khi user chua go tay (track lastAutoLoc ref).
- Dieu khoan thanh toan nhap tay: Input 1 dong -> Textarea 3 dong (UAT "khong cho
  xuong dong?") o CreateView + PeDetailTabs inline-edit; render detail da pre-wrap san.
- SHA256 mirror x2 app (4 file IDENTICAL), build tsc+vite x2 PASS.
This commit is contained in:
pqhuy1987
2026-06-11 17:39:17 +07:00
parent c869d2617d
commit faed59f4c4
8 changed files with 441 additions and 121 deletions

View File

@ -12,6 +12,7 @@ import { Dialog } from '@/components/ui/Dialog'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label' import { Label } from '@/components/ui/Label'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError' import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
@ -721,10 +722,12 @@ function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boo
</div> </div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<Label className="text-[11px]">Điều khoản thanh toán</Label> <Label className="text-[11px]">Điều khoản thanh toán</Label>
<Input {/* S59 UAT "nhập tay chỉ được 1 dòng?" → Textarea đa dòng (render đã pre-wrap). */}
<Textarea
rows={3}
value={paymentTerms} value={paymentTerms}
onChange={e => setPaymentTerms(e.target.value)} onChange={e => setPaymentTerms(e.target.value)}
placeholder="JSON hoặc text" placeholder={'Nhập điều khoản — Enter để xuống dòng'}
/> />
</div> </div>
</div> </div>

View File

@ -2,12 +2,13 @@
// reuse trong Workspace mode "new". Sửa header sau khi tạo vẫn redirect về // 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 // page Create cũ (`/purchase-evaluations/new?id=`) — workspace KHÔNG re-edit
// header. // header.
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner' import { toast } from 'sonner'
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 { Label } from '@/components/ui/Label' import { Label } from '@/components/ui/Label'
import { SearchableSelect } from '@/components/ui/SearchableSelect'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea' import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api' import { api } from '@/lib/api'
@ -58,6 +59,9 @@ export function PeHeaderForm({
enabled: !!editId, 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). // 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). // S59 — sort numeric-aware client (mã PMH không pad số — mirror CreateView).
const workItems = useQuery({ const workItems = useQuery({
@ -194,39 +198,43 @@ export function PeHeaderForm({
workItemId + tenGoiThau (= tên hạng mục). Phiếu cũ (workItemId null, 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 nhập tay): option đầu "Giữ nguyên" — không ép đổi, PUT null-safe. */}
<Label>Tên gói thầu (Hạng mục công việc) *</Label> <Label>Tên gói thầu (Hạng mục công việc) *</Label>
<Select {/* S59 UAT "nên có lọc để tự đánh chữ" → SearchableSelect gõ-lọc bỏ dấu.
Phiếu cũ (workItemId null): placeholder "Giữ nguyên: …", clear (×) = về giữ-nguyên. */}
<SearchableSelect
options={(workItems.data ?? []).map(w => ({
value: w.id,
label: `${w.category ? `[${w.category}] ` : ''}${w.code}${w.name}`,
}))}
value={form.workItemId} value={form.workItemId}
onChange={e => { onChange={id => {
const id = e.target.value
const w = workItems.data?.find(x => x.id === id) const w = workItems.data?.find(x => x.id === id)
setForm({ ...form, workItemId: id, tenGoiThau: id ? (w?.name ?? '') : (editId ? form.tenGoiThau : '') }) setForm({ ...form, workItemId: id, tenGoiThau: id ? (w?.name ?? '') : (editId ? form.tenGoiThau : '') })
}} }}
> placeholder={editId && form.tenGoiThau && !form.workItemId
<option value="">
{editId && form.tenGoiThau && !form.workItemId
? `Giữ nguyên: ${form.tenGoiThau}` ? `Giữ nguyên: ${form.tenGoiThau}`
: '-- Chọn hạng mục công việc --'} : '-- Chọn hạng mục công việc (gõ để lọc) --'}
</option> />
{workItems.data?.map(w => (
<option key={w.id} value={w.id}>
{w.category ? `[${w.category}] ` : ''}{w.code} {w.name}
</option>
))}
</Select>
</div> </div>
<div> <div>
<Label>Dự án *</Label> <Label>Dự án *</Label>
<Select {/* S59 UAT: gõ-lọc + auto-fill Địa điểm từ Project.Location (mirror CreateView;
chỉ ăn khi tạo mới — edit disabled). Ghi đè khi user chưa gõ tay diaDiem. */}
<SearchableSelect
options={(projects.data ?? []).map(p => ({ value: p.id, label: `${p.code}${p.name}` }))}
value={form.projectId} value={form.projectId}
disabled={!!editId} disabled={!!editId}
onChange={e => setForm({ ...form, projectId: e.target.value })} onChange={id => {
> const p = projects.data?.find(x => x.id === id)
<option value="">-- Chọn --</option> const loc = p?.location ?? ''
{projects.data?.map(p => ( setForm(f => {
<option key={p.id} value={p.id}>{p.code} {p.name}</option> const untouched = !f.diaDiem || f.diaDiem === lastAutoLoc.current
))} return { ...f, projectId: id, diaDiem: untouched ? loc : f.diaDiem }
</Select> })
lastAutoLoc.current = loc
}}
placeholder="-- Chọn dự án (gõ để lọc) --"
/>
</div> </div>
<div> <div>

View File

@ -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 // 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." // 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Lock } from 'lucide-react' import { Lock } 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 { Label } from '@/components/ui/Label' import { Label } from '@/components/ui/Label'
import { SearchableSelect } from '@/components/ui/SearchableSelect'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError' import { getErrorMessage } from '@/lib/apiError'
import { PurchaseEvaluationTypeLabel } from '@/types/purchaseEvaluation' 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, 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). // 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…) // 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}. // → 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 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. */} hạng mục). Phiếu vẫn lưu cả 2 field BE — không đổi contract. */}
<Label className="text-[11px]">a. Tên gói thầu (Hạng mục công việc) *</Label> <Label className="text-[11px]">a. Tên gói thầu (Hạng mục công việc) *</Label>
<Select {/* S59 UAT "nên có lọc để tự đánh chữ" → SearchableSelect gõ-lọc bỏ dấu. */}
<SearchableSelect
options={(workItems.data ?? []).map(w => ({
value: w.id,
label: `${w.category ? `[${w.category}] ` : ''}${w.code}${w.name}`,
}))}
value={form.workItemId} value={form.workItemId}
onChange={e => { onChange={id => {
const id = e.target.value
const w = workItems.data?.find(x => x.id === id) const w = workItems.data?.find(x => x.id === id)
setForm({ ...form, workItemId: id, tenGoiThau: id ? (w?.name ?? '') : '' }) setForm({ ...form, workItemId: id, tenGoiThau: id ? (w?.name ?? '') : '' })
}} }}
required placeholder="— Chọn hạng mục công việc (gõ để lọc) —"
> />
<option value=""> Chọn hạng mục công việc </option>
{workItems.data?.map(w => (
<option key={w.id} value={w.id}>
{w.category ? `[${w.category}] ` : ''}{w.code} {w.name}
</option>
))}
</Select>
{workItems.data && workItems.data.length === 0 && ( {workItems.data && workItems.data.length === 0 && (
<p className="mt-1 text-[11px] text-amber-700"> <p className="mt-1 text-[11px] text-amber-700">
Chưa 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. Chưa 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({
</div> </div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<Label className="text-[11px]">b. Dự án *</Label> <Label className="text-[11px]">b. Dự án *</Label>
<Select {/* S59 UAT "nên có tự gõ chữ" + "địa chỉ nên tự auto": chọn dự án tự điền
Địa điểm từ Project.Location (S55) — chỉ ghi đè khi user CHƯA gõ tay
(rỗng hoặc vẫn là giá trị auto của dự án trước, track qua lastAutoLoc). */}
<SearchableSelect
options={(projects.data ?? []).map(p => ({ value: p.id, label: `${p.code}${p.name}` }))}
value={form.projectId} value={form.projectId}
onChange={e => setForm({ ...form, projectId: e.target.value, budgetId: '' })} onChange={id => {
> const p = projects.data?.find(x => x.id === id)
<option value=""> Chọn dự án </option> const loc = p?.location ?? ''
{projects.data?.map(p => ( setForm(f => {
<option key={p.id} value={p.id}>{p.code} {p.name}</option> const untouched = !f.diaDiem || f.diaDiem === lastAutoLoc.current
))} return { ...f, projectId: id, budgetId: '', diaDiem: untouched ? loc : f.diaDiem }
</Select> })
lastAutoLoc.current = loc
}}
placeholder="— Chọn dự án (gõ để lọc) —"
/>
</div> </div>
<div> <div>
<Label className="text-[11px]">Đa điểm</Label> <Label className="text-[11px]">Đa điểm</Label>
@ -271,10 +282,13 @@ export function PeWorkspaceCreateView({
<option value={PAYMENT_CUSTOM}>Khác (nhập tay)</option> <option value={PAYMENT_CUSTOM}>Khác (nhập tay)</option>
</Select> </Select>
{isPaymentCustom && ( {isPaymentCustom && (
<Input /* S59 UAT "nhập tay chỉ được 1 dòng?" → Textarea đa dòng (render
detail đã whitespace-pre-wrap sẵn nên xuống dòng hiển thị đúng). */
<Textarea
rows={3}
value={form.paymentTerms} value={form.paymentTerms}
onChange={e => setForm({ ...form, paymentTerms: e.target.value })} onChange={e => 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" className="mt-2"
/> />
)} )}

View File

@ -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 <select>). 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<HTMLDivElement>(null)
const listRef = useRef<HTMLUListElement>(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 (
<div ref={wrapRef} className={cn('relative', className)}>
<input
type="text"
role="combobox"
aria-expanded={open}
disabled={disabled}
value={open ? query : (selected?.label ?? '')}
placeholder={selected?.label ?? placeholder}
onFocus={() => { 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',
)}
/>
<span className="pointer-events-none absolute inset-y-0 right-2.5 flex items-center">
<ChevronDown className="h-3.5 w-3.5 text-slate-400" />
</span>
{selected && !disabled && !open && (
<button
type="button"
aria-label="Xóa lựa chọn"
onClick={() => pick('')}
className="absolute inset-y-0 right-8 flex items-center text-slate-400 hover:text-slate-600"
>
<X className="h-3.5 w-3.5" />
</button>
)}
{open && !disabled && (
<ul
ref={listRef}
role="listbox"
className="absolute z-50 mt-1 max-h-64 w-full overflow-y-auto rounded-lg border border-slate-200 bg-white py-1 shadow-lg"
>
{filtered.length === 0 && (
<li className="px-3 py-2 text-xs text-slate-400">Không tìm thấy mục nào.</li>
)}
{filtered.map((o, i) => (
<li key={o.value}>
<button
type="button"
onMouseDown={e => 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}
</button>
</li>
))}
</ul>
)}
</div>
)
}

View File

@ -7,16 +7,17 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Check, ChevronDown, ChevronRight, Download, Eye, Paperclip, Pencil, Plus, Trash2, Upload, Wallet } from 'lucide-react' 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 { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog' import { Dialog } from '@/components/ui/Dialog'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label' import { Label } from '@/components/ui/Label'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError' import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
import { useAuth } from '@/contexts/AuthContext' import { useAuth } from '@/contexts/AuthContext'
import { AttachmentPreviewDialog, isPreviewable } from './AttachmentPreviewDialog'
import { import {
PeAttachmentPurpose, PeAttachmentPurpose,
PeAttachmentPurposeLabel, PeAttachmentPurposeLabel,
@ -103,8 +104,6 @@ export function PeDetailTabs({
const opinionsReadOnly = readOnly || mode === 'workspace' const opinionsReadOnly = readOnly || mode === 'workspace'
// Mig 28 (S21 t4) — F3: Approver edit Section 2 (Hạng mục + NCC + Báo giá). // 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 { user: currentUser } = useAuth()
const isAdmin = currentUser?.roles?.includes('Admin') ?? false const isAdmin = currentUser?.roles?.includes('Admin') ?? false
const v2Approvers = evaluation.currentApproval?.approvers ?? [] const v2Approvers = evaluation.currentApproval?.approvers ?? []
@ -114,16 +113,14 @@ export function PeDetailTabs({
// Mig 29 (S21 t5) — read F3 từ currentLevelOptions (per-NV slot) // Mig 29 (S21 t5) — read F3 từ currentLevelOptions (per-NV slot)
&& (evaluation.currentLevelOptions?.allowApproverEditDetails ?? false) && (evaluation.currentLevelOptions?.allowApproverEditDetails ?? false)
&& actorMatchesLevel && actorMatchesLevel
// itemsReadOnly = readOnly trừ khi approver mode F3 mở
const itemsReadOnly = readOnly && !approverEditMode 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 // "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 // sang phase tiếp theo (= Đã gửi duyệt). nextPhases[0] thường là ChoPurchasing
// (skip TuChoi). Sau success → toast + invalidate + onBack đóng workspace. // (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({ const submitForApproval = useMutation({
mutationFn: async () => { mutationFn: async () => {
const next = evaluation.workflow.nextPhases.find(p => p !== PurchaseEvaluationPhase.TuChoi && p !== PurchaseEvaluationPhase.TraLai) const next = evaluation.workflow.nextPhases.find(p => p !== PurchaseEvaluationPhase.TuChoi && p !== PurchaseEvaluationPhase.TraLai)
@ -215,8 +212,7 @@ export function PeDetailTabs({
<InfoTab ev={evaluation} readOnly={readOnly} autoEdit={autoEditHeader} /> <InfoTab ev={evaluation} readOnly={readOnly} autoEdit={autoEditHeader} />
</Section> </Section>
<Section title={`2. Hạng mục + Báo giá NCC (${evaluation.details.length} hạng mục · ${evaluation.suppliers.length} NCC)`}> <Section title={`2. Hạng mục + Báo giá NCC (${evaluation.details.length} hạng mục · ${evaluation.suppliers.length} NCC)`}>
{/* Mig 28 (S21 t4) — F3: itemsReadOnly cho phép approver edit Section 2. {/* 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. */}
{/* Plan Q S23 t7 — Drop mx-5 banner, full-width Section padding to {/* 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 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). */} KHÔNG còn lệch khỏi banner inset gap). */}
@ -243,9 +239,9 @@ export function PeDetailTabs({
? <LevelOpinionsSectionV2 ev={evaluation} /> ? <LevelOpinionsSectionV2 ev={evaluation} />
: <DepartmentOpinionsSection ev={evaluation} readOnly={opinionsReadOnly} />} : <DepartmentOpinionsSection ev={evaluation} readOnly={opinionsReadOnly} />}
</Section> </Section>
{/* S22+4 — Feature 2: Section "Điều chỉnh ngân sách" (mirror fe-admin). {/* S22+4 — Feature 2: Section "Điều chỉnh ngân sách" cho phép Drafter
Drafter (Nháp/Trả lại) HOẶC Approver currentLevel (Đang duyệt) HOẶC (Nháp/Trả lại) HOẶC Approver currentLevel (Đang duyệt) HOẶC Admin
Admin sửa Budget link / Manual amount. BE PATCH /budget-adjust. */} sửa Budget link / Manual amount. BE PATCH /budget-adjust riêng. */}
<Section title="5. Điều chỉnh ngân sách"> <Section title="5. Điều chỉnh ngân sách">
<BudgetAdjustSection ev={evaluation} readOnly={readOnly} /> <BudgetAdjustSection ev={evaluation} readOnly={readOnly} />
</Section> </Section>
@ -726,10 +722,12 @@ function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boo
</div> </div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<Label className="text-[11px]">Điều khoản thanh toán</Label> <Label className="text-[11px]">Điều khoản thanh toán</Label>
<Input {/* S59 UAT "nhập tay chỉ được 1 dòng?" → Textarea đa dòng (render đã pre-wrap). */}
<Textarea
rows={3}
value={paymentTerms} value={paymentTerms}
onChange={e => setPaymentTerms(e.target.value)} onChange={e => setPaymentTerms(e.target.value)}
placeholder="JSON hoặc text" placeholder={'Nhập điều khoản — Enter để xuống dòng'}
/> />
</div> </div>
</div> </div>
@ -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) ===== // ===== 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 // Cho phép Drafter (DangSoanThao/TraLai) HOẶC Approver currentLevel (ChoDuyet)
// currentLevel (Đang duyệt) HOẶC Admin sửa Budget link / Manual amount via // HOẶC Admin sửa BudgetId + BudgetManualName + BudgetManualAmount qua endpoint
// PATCH /budget-adjust riêng. Audit changelog tự động. // PATCH /budget-adjust riêng. Audit changelog tự động.
function BudgetAdjustSection({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) { function BudgetAdjustSection({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
const { user: currentUser } = useAuth() 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 isDrafter = currentUser?.id != null && ev.drafterUserId === currentUser.id
const isDrafterPhase = ev.phase === PurchaseEvaluationPhase.DangSoanThao const isDrafterPhase = ev.phase === PurchaseEvaluationPhase.DangSoanThao
|| ev.phase === PurchaseEvaluationPhase.TraLai || 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 actorInCurrentLevel = ev.currentApproval?.approvers?.some(a => a.userId === currentUser?.id) ?? false
const approverEditBudgetAllowed = ev.currentLevelOptions?.allowApproverEditBudget ?? false const approverEditBudgetAllowed = ev.currentLevelOptions?.allowApproverEditBudget ?? false
const isApproverChoDuyet = ev.phase === PurchaseEvaluationPhase.ChoDuyet const isApproverChoDuyet = ev.phase === PurchaseEvaluationPhase.ChoDuyet
@ -983,8 +982,8 @@ function BudgetAdjustSection({ ev, readOnly }: { ev: PeDetailBundle; readOnly: b
&& approverEditBudgetAllowed && approverEditBudgetAllowed
// S23 t2 bug fix: F4 Approver scope BYPASS readOnly (mirror F3 itemsReadOnly // S23 t2 bug fix: F4 Approver scope BYPASS readOnly (mirror F3 itemsReadOnly
// pattern line 118). Khi admin tick AllowApproverEditBudget cho slot + actor // pattern). Khi admin tick AllowApproverEditBudget cho slot + actor match +
// match + Phase=ChoDuyet → button "Điều chỉnh" enable trong menu Duyệt (readOnly=true) // 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). // dù chế độ chỉ-đọc. Drafter + Admin vẫn cần !readOnly (chỉ active từ Workspace).
const canAdjust = isAdmin const canAdjust = isAdmin
|| (!readOnly && isDrafter && isDrafterPhase) || (!readOnly && isDrafter && isDrafterPhase)
@ -1020,6 +1019,10 @@ function BudgetAdjustSection({ ev, readOnly }: { ev: PeDetailBundle; readOnly: b
onError: e => toast.error(getErrorMessage(e)), 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 ? ( const displayLink = ev.budget ? (
<span> <span>
<span className="font-mono text-[11px] text-brand-700">{ev.budget.maNganSach ?? '—'}</span> <span className="font-mono text-[11px] text-brand-700">{ev.budget.maNganSach ?? '—'}</span>
@ -1039,6 +1042,7 @@ function BudgetAdjustSection({ ev, readOnly }: { ev: PeDetailBundle; readOnly: b
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{/* Read mode + Edit toggle */}
{!editing && ( {!editing && (
<div className="flex items-start justify-between gap-3 rounded border border-emerald-200 bg-emerald-50/40 px-3 py-2"> <div className="flex items-start justify-between gap-3 rounded border border-emerald-200 bg-emerald-50/40 px-3 py-2">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
@ -1063,6 +1067,7 @@ function BudgetAdjustSection({ ev, readOnly }: { ev: PeDetailBundle; readOnly: b
</div> </div>
)} )}
{/* Edit mode */}
{editing && canAdjust && ( {editing && canAdjust && (
<div className="space-y-3 rounded border border-emerald-300 bg-emerald-50/30 p-3"> <div className="space-y-3 rounded border border-emerald-300 bg-emerald-50/30 p-3">
{isApproverChoDuyet && ( {isApproverChoDuyet && (
@ -1136,6 +1141,8 @@ function BudgetAdjustSection({ ev, readOnly }: { ev: PeDetailBundle; readOnly: b
</div> </div>
</div> </div>
)} )}
{/* History defer S22+5 — UAT user xem Panel 3 "Lịch sử thay đổi" */}
</div> </div>
) )
} }
@ -1323,6 +1330,7 @@ function AddSupplierDialog({ evaluationId, detailId, onClose }: {
const mut = useMutation({ const mut = useMutation({
mutationFn: async () => { mutationFn: async () => {
// Step 1: tạo NCC tham gia (PE.Suppliers row)
const res = await api.post<{ id: string }>(`/purchase-evaluations/${evaluationId}/suppliers`, { const res = await api.post<{ id: string }>(`/purchase-evaluations/${evaluationId}/suppliers`, {
supplierId: form.supplierId, supplierId: form.supplierId,
displayName: form.displayName, displayName: form.displayName,
@ -1333,6 +1341,7 @@ function AddSupplierDialog({ evaluationId, detailId, onClose }: {
note: form.note, note: form.note,
}) })
const newSupplierRowId = res.data.id 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) { if (detailId && form.thanhTien > 0) {
await api.post(`/purchase-evaluations/${evaluationId}/quotes`, { await api.post(`/purchase-evaluations/${evaluationId}/quotes`, {
purchaseEvaluationDetailId: detailId, purchaseEvaluationDetailId: detailId,
@ -2178,11 +2187,7 @@ function HistoryTab({ ev }: { ev: PeDetailBundle }) {
queryFn: async () => (await api.get<PeChangelog[]>(`/purchase-evaluations/${ev.id}/changelogs`)).data, queryFn: async () => (await api.get<PeChangelog[]>(`/purchase-evaluations/${ev.id}/changelogs`)).data,
}) })
// Plan AF S25 — userMap fallback cho historical entries pre-Plan AE deploy // Plan AF S25 — userMap fallback cho historical entries pre-Plan AE
// (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.
const userMap = useMemo(() => { const userMap = useMemo(() => {
const m = new Map<string, string>() const m = new Map<string, string>()
if (ev.drafterUserId && ev.drafterName) m.set(ev.drafterUserId, ev.drafterName) if (ev.drafterUserId && ev.drafterName) m.set(ev.drafterUserId, ev.drafterName)

View File

@ -2,12 +2,13 @@
// reuse trong Workspace mode "new". Sửa header sau khi tạo vẫn redirect về // 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 // page Create cũ (`/purchase-evaluations/new?id=`) — workspace KHÔNG re-edit
// header. // header.
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner' import { toast } from 'sonner'
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 { Label } from '@/components/ui/Label' import { Label } from '@/components/ui/Label'
import { SearchableSelect } from '@/components/ui/SearchableSelect'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea' import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api' import { api } from '@/lib/api'
@ -58,6 +59,9 @@ export function PeHeaderForm({
enabled: !!editId, 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). // 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). // S59 — sort numeric-aware client (mã PMH không pad số — mirror CreateView).
const workItems = useQuery({ const workItems = useQuery({
@ -194,39 +198,43 @@ export function PeHeaderForm({
workItemId + tenGoiThau (= tên hạng mục). Phiếu cũ (workItemId null, 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 nhập tay): option đầu "Giữ nguyên" — không ép đổi, PUT null-safe. */}
<Label>Tên gói thầu (Hạng mục công việc) *</Label> <Label>Tên gói thầu (Hạng mục công việc) *</Label>
<Select {/* S59 UAT "nên có lọc để tự đánh chữ" → SearchableSelect gõ-lọc bỏ dấu.
Phiếu cũ (workItemId null): placeholder "Giữ nguyên: …", clear (×) = về giữ-nguyên. */}
<SearchableSelect
options={(workItems.data ?? []).map(w => ({
value: w.id,
label: `${w.category ? `[${w.category}] ` : ''}${w.code}${w.name}`,
}))}
value={form.workItemId} value={form.workItemId}
onChange={e => { onChange={id => {
const id = e.target.value
const w = workItems.data?.find(x => x.id === id) const w = workItems.data?.find(x => x.id === id)
setForm({ ...form, workItemId: id, tenGoiThau: id ? (w?.name ?? '') : (editId ? form.tenGoiThau : '') }) setForm({ ...form, workItemId: id, tenGoiThau: id ? (w?.name ?? '') : (editId ? form.tenGoiThau : '') })
}} }}
> placeholder={editId && form.tenGoiThau && !form.workItemId
<option value="">
{editId && form.tenGoiThau && !form.workItemId
? `Giữ nguyên: ${form.tenGoiThau}` ? `Giữ nguyên: ${form.tenGoiThau}`
: '-- Chọn hạng mục công việc --'} : '-- Chọn hạng mục công việc (gõ để lọc) --'}
</option> />
{workItems.data?.map(w => (
<option key={w.id} value={w.id}>
{w.category ? `[${w.category}] ` : ''}{w.code} {w.name}
</option>
))}
</Select>
</div> </div>
<div> <div>
<Label>Dự án *</Label> <Label>Dự án *</Label>
<Select {/* S59 UAT: gõ-lọc + auto-fill Địa điểm từ Project.Location (mirror CreateView;
chỉ ăn khi tạo mới — edit disabled). Ghi đè khi user chưa gõ tay diaDiem. */}
<SearchableSelect
options={(projects.data ?? []).map(p => ({ value: p.id, label: `${p.code}${p.name}` }))}
value={form.projectId} value={form.projectId}
disabled={!!editId} disabled={!!editId}
onChange={e => setForm({ ...form, projectId: e.target.value })} onChange={id => {
> const p = projects.data?.find(x => x.id === id)
<option value="">-- Chọn --</option> const loc = p?.location ?? ''
{projects.data?.map(p => ( setForm(f => {
<option key={p.id} value={p.id}>{p.code} {p.name}</option> const untouched = !f.diaDiem || f.diaDiem === lastAutoLoc.current
))} return { ...f, projectId: id, diaDiem: untouched ? loc : f.diaDiem }
</Select> })
lastAutoLoc.current = loc
}}
placeholder="-- Chọn dự án (gõ để lọc) --"
/>
</div> </div>
<div> <div>

View File

@ -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 // 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." // 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Lock } from 'lucide-react' import { Lock } 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 { Label } from '@/components/ui/Label' import { Label } from '@/components/ui/Label'
import { SearchableSelect } from '@/components/ui/SearchableSelect'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError' import { getErrorMessage } from '@/lib/apiError'
import { PurchaseEvaluationTypeLabel } from '@/types/purchaseEvaluation' 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, 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). // 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…) // 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}. // → 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 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. */} hạng mục). Phiếu vẫn lưu cả 2 field BE — không đổi contract. */}
<Label className="text-[11px]">a. Tên gói thầu (Hạng mục công việc) *</Label> <Label className="text-[11px]">a. Tên gói thầu (Hạng mục công việc) *</Label>
<Select {/* S59 UAT "nên có lọc để tự đánh chữ" → SearchableSelect gõ-lọc bỏ dấu. */}
<SearchableSelect
options={(workItems.data ?? []).map(w => ({
value: w.id,
label: `${w.category ? `[${w.category}] ` : ''}${w.code}${w.name}`,
}))}
value={form.workItemId} value={form.workItemId}
onChange={e => { onChange={id => {
const id = e.target.value
const w = workItems.data?.find(x => x.id === id) const w = workItems.data?.find(x => x.id === id)
setForm({ ...form, workItemId: id, tenGoiThau: id ? (w?.name ?? '') : '' }) setForm({ ...form, workItemId: id, tenGoiThau: id ? (w?.name ?? '') : '' })
}} }}
required placeholder="— Chọn hạng mục công việc (gõ để lọc) —"
> />
<option value=""> Chọn hạng mục công việc </option>
{workItems.data?.map(w => (
<option key={w.id} value={w.id}>
{w.category ? `[${w.category}] ` : ''}{w.code} {w.name}
</option>
))}
</Select>
{workItems.data && workItems.data.length === 0 && ( {workItems.data && workItems.data.length === 0 && (
<p className="mt-1 text-[11px] text-amber-700"> <p className="mt-1 text-[11px] text-amber-700">
Chưa 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. Chưa 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({
</div> </div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<Label className="text-[11px]">b. Dự án *</Label> <Label className="text-[11px]">b. Dự án *</Label>
<Select {/* S59 UAT "nên có tự gõ chữ" + "địa chỉ nên tự auto": chọn dự án tự điền
Địa điểm từ Project.Location (S55) — chỉ ghi đè khi user CHƯA gõ tay
(rỗng hoặc vẫn là giá trị auto của dự án trước, track qua lastAutoLoc). */}
<SearchableSelect
options={(projects.data ?? []).map(p => ({ value: p.id, label: `${p.code}${p.name}` }))}
value={form.projectId} value={form.projectId}
onChange={e => setForm({ ...form, projectId: e.target.value, budgetId: '' })} onChange={id => {
> const p = projects.data?.find(x => x.id === id)
<option value=""> Chọn dự án </option> const loc = p?.location ?? ''
{projects.data?.map(p => ( setForm(f => {
<option key={p.id} value={p.id}>{p.code} {p.name}</option> const untouched = !f.diaDiem || f.diaDiem === lastAutoLoc.current
))} return { ...f, projectId: id, budgetId: '', diaDiem: untouched ? loc : f.diaDiem }
</Select> })
lastAutoLoc.current = loc
}}
placeholder="— Chọn dự án (gõ để lọc) —"
/>
</div> </div>
<div> <div>
<Label className="text-[11px]">Đa điểm</Label> <Label className="text-[11px]">Đa điểm</Label>
@ -271,10 +282,13 @@ export function PeWorkspaceCreateView({
<option value={PAYMENT_CUSTOM}>Khác (nhập tay)</option> <option value={PAYMENT_CUSTOM}>Khác (nhập tay)</option>
</Select> </Select>
{isPaymentCustom && ( {isPaymentCustom && (
<Input /* S59 UAT "nhập tay chỉ được 1 dòng?" → Textarea đa dòng (render
detail đã whitespace-pre-wrap sẵn nên xuống dòng hiển thị đúng). */
<Textarea
rows={3}
value={form.paymentTerms} value={form.paymentTerms}
onChange={e => setForm({ ...form, paymentTerms: e.target.value })} onChange={e => 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" className="mt-2"
/> />
)} )}

View File

@ -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 <select>). 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<HTMLDivElement>(null)
const listRef = useRef<HTMLUListElement>(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 (
<div ref={wrapRef} className={cn('relative', className)}>
<input
type="text"
role="combobox"
aria-expanded={open}
disabled={disabled}
value={open ? query : (selected?.label ?? '')}
placeholder={selected?.label ?? placeholder}
onFocus={() => { 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',
)}
/>
<span className="pointer-events-none absolute inset-y-0 right-2.5 flex items-center">
<ChevronDown className="h-3.5 w-3.5 text-slate-400" />
</span>
{selected && !disabled && !open && (
<button
type="button"
aria-label="Xóa lựa chọn"
onClick={() => pick('')}
className="absolute inset-y-0 right-8 flex items-center text-slate-400 hover:text-slate-600"
>
<X className="h-3.5 w-3.5" />
</button>
)}
{open && !disabled && (
<ul
ref={listRef}
role="listbox"
className="absolute z-50 mt-1 max-h-64 w-full overflow-y-auto rounded-lg border border-slate-200 bg-white py-1 shadow-lg"
>
{filtered.length === 0 && (
<li className="px-3 py-2 text-xs text-slate-400">Không tìm thấy mục nào.</li>
)}
{filtered.map((o, i) => (
<li key={o.value}>
<button
type="button"
onMouseDown={e => 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}
</button>
</li>
))}
</ul>
)}
</div>
)
}