[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
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:
@ -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
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<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}
|
||||
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>
|
||||
|
||||
@ -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. */}
|
||||
<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}
|
||||
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 : '') })
|
||||
}}
|
||||
>
|
||||
<option value="">
|
||||
{editId && form.tenGoiThau && !form.workItemId
|
||||
? `Giữ nguyên: ${form.tenGoiThau}`
|
||||
: '-- 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>
|
||||
placeholder={editId && form.tenGoiThau && !form.workItemId
|
||||
? `Giữ nguyên: ${form.tenGoiThau}`
|
||||
: '-- Chọn hạng mục công việc (gõ để lọc) --'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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}
|
||||
disabled={!!editId}
|
||||
onChange={e => setForm({ ...form, projectId: e.target.value })}
|
||||
>
|
||||
<option value="">-- Chọn --</option>
|
||||
{projects.data?.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.code} — {p.name}</option>
|
||||
))}
|
||||
</Select>
|
||||
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) --"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@ -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. */}
|
||||
<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}
|
||||
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
|
||||
>
|
||||
<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>
|
||||
placeholder="— Chọn hạng mục công việc (gõ để lọc) —"
|
||||
/>
|
||||
{workItems.data && workItems.data.length === 0 && (
|
||||
<p className="mt-1 text-[11px] text-amber-700">
|
||||
⚠ 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({
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<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}
|
||||
onChange={e => setForm({ ...form, projectId: e.target.value, budgetId: '' })}
|
||||
>
|
||||
<option value="">— Chọn dự án —</option>
|
||||
{projects.data?.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.code} — {p.name}</option>
|
||||
))}
|
||||
</Select>
|
||||
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) —"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
</Select>
|
||||
{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}
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
|
||||
134
fe-admin/src/components/ui/SearchableSelect.tsx
Normal file
134
fe-admin/src/components/ui/SearchableSelect.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user