From faed59f4c417769c065402b4636d965aff34b105 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Thu, 11 Jun 2026 17:39:17 +0700 Subject: [PATCH] [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 - 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. --- fe-admin/src/components/pe/PeDetailTabs.tsx | 7 +- fe-admin/src/components/pe/PeHeaderForm.tsx | 56 ++++---- .../components/pe/PeWorkspaceCreateView.tsx | 60 +++++--- .../src/components/ui/SearchableSelect.tsx | 134 ++++++++++++++++++ fe-user/src/components/pe/PeDetailTabs.tsx | 55 +++---- fe-user/src/components/pe/PeHeaderForm.tsx | 56 ++++---- .../components/pe/PeWorkspaceCreateView.tsx | 60 +++++--- .../src/components/ui/SearchableSelect.tsx | 134 ++++++++++++++++++ 8 files changed, 441 insertions(+), 121 deletions(-) create mode 100644 fe-admin/src/components/ui/SearchableSelect.tsx create mode 100644 fe-user/src/components/ui/SearchableSelect.tsx 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
- 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. */} - + placeholder={editId && form.tenGoiThau && !form.workItemId + ? `Giữ nguyên: ${form.tenGoiThau}` + : '-- Chọn hạng mục công việc (gõ để lọc) --'} + />
- + 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. */} - + 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({

- + 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) —" + />
@@ -271,10 +282,13 @@ export function PeWorkspaceCreateView({ {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 { 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 && ( + + )} + {open && !disabled && ( + + )} +
+ ) +} 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
- 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. */} - + placeholder={editId && form.tenGoiThau && !form.workItemId + ? `Giữ nguyên: ${form.tenGoiThau}` + : '-- Chọn hạng mục công việc (gõ để lọc) --'} + />
- + 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. */} - + 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({

- + 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) —" + />
@@ -271,10 +282,13 @@ export function PeWorkspaceCreateView({ {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 { 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 && ( + + )} + {open && !disabled && ( +
    + {filtered.length === 0 && ( +
  • Không tìm thấy mục nào.
  • + )} + {filtered.map((o, i) => ( +
  • + +
  • + ))} +
+ )} +
+ ) +}