[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 { 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>

View File

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

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
// 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 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"
/>
)}