[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

@ -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>
)
}