[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

@ -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({
<InfoTab ev={evaluation} readOnly={readOnly} autoEdit={autoEditHeader} />
</Section>
<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.
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({
? <LevelOpinionsSectionV2 ev={evaluation} />
: <DepartmentOpinionsSection ev={evaluation} readOnly={opinionsReadOnly} />}
</Section>
{/* 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. */}
<Section title="5. Điều chỉnh ngân sách">
<BudgetAdjustSection ev={evaluation} readOnly={readOnly} />
</Section>
@ -726,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>
@ -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 ? (
<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 (
<div className="space-y-3">
{/* Read mode + Edit toggle */}
{!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 gap-2">
@ -1063,6 +1067,7 @@ function BudgetAdjustSection({ ev, readOnly }: { ev: PeDetailBundle; readOnly: b
</div>
)}
{/* Edit mode */}
{editing && canAdjust && (
<div className="space-y-3 rounded border border-emerald-300 bg-emerald-50/30 p-3">
{isApproverChoDuyet && (
@ -1136,6 +1141,8 @@ function BudgetAdjustSection({ ev, readOnly }: { ev: PeDetailBundle; readOnly: b
</div>
</div>
)}
{/* History defer S22+5 — UAT user xem Panel 3 "Lịch sử thay đổi" */}
</div>
)
}
@ -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<PeChangelog[]>(`/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<string, string>()
if (ev.drafterUserId && ev.drafterName) m.set(ev.drafterUserId, ev.drafterName)