[CLAUDE] PurchaseEvaluation: UAT dot 2 - an Tra lai/Tu choi khi tu duyet phieu minh soan + quick-add NCC ngay form + NCC go-tim sort A-Z + upload nhieu file 1 lan
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m25s

- PeWorkflowPanel x2: nguoi duyet == nguoi soan (drafterUserId == currentUser.id)
  -> an ca "Tra lai" + "Tu choi" (anh chot: tra cho chinh minh vo nghia, huy phieu
  = nho cap khac tu choi / xoa phieu Nhap).
- SuppliersController: POST tao NCC mo cho moi user dang nhap (anh chot - nghiep vu
  di thau phat sinh NTP moi lien tuc); PUT/DELETE van khoa Admin+CatalogManager (S57).
- PeDetailTabs AddSupplierDialog x2: Select -> SearchableSelect (go-tim bo dau,
  sort A-Z theo ma) + nut "+ NCC moi" quick-create (Ma/Ten/Loai/SDT/Email) ->
  POST /suppliers -> auto-select vao phieu.
- Upload file bao gia + bang so sanh x2: input multiple + upload tuan tu tung file
  (UAT "moi lan chi chon duoc 1 file").
- SHA256 mirror x2 app, build tsc+vite x2 PASS, BE 0 err, test 240/240 PASS local.
This commit is contained in:
pqhuy1987
2026-06-11 17:51:28 +07:00
parent faed59f4c4
commit 9c330d26c4
5 changed files with 245 additions and 66 deletions

View File

@ -11,6 +11,7 @@ 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 { SearchableSelect } from '@/components/ui/SearchableSelect'
import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
@ -41,6 +42,7 @@ import {
type PeSupplier,
} from '@/types/purchaseEvaluation'
import { BudgetPhase, type BudgetListItem } from '@/types/budget'
import { SupplierType, SupplierTypeLabel } from '@/types/master'
import type { Paged, Supplier } from '@/types/master'
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
@ -1328,6 +1330,28 @@ function AddSupplierDialog({ evaluationId, detailId, onClose }: {
const hasError = !!(phoneError || emailError)
const showQuote = !!detailId
// S59 UAT "Không tự thêm dc tên NTP mới" — anh chốt mở POST /suppliers cho mọi
// user đăng nhập (Sửa/Xóa vẫn Admin/CatalogManager). Tạo xong auto-select vào phiếu.
const [showNew, setShowNew] = useState(false)
const [newSup, setNewSup] = useState({ code: '', name: '', type: SupplierType.NhaThauPhu as SupplierType, phone: '', email: '' })
const createSup = useMutation({
mutationFn: async () => (await api.post<{ id: string }>('/suppliers', {
code: newSup.code.trim(),
name: newSup.name.trim(),
type: newSup.type,
phone: newSup.phone.trim() || null,
email: newSup.email.trim() || null,
})).data,
onSuccess: async created => {
toast.success('Đã tạo NCC mới vào danh mục.')
await qc.invalidateQueries({ queryKey: ['all-suppliers'] })
setForm(prev => ({ ...prev, supplierId: created.id, contactPhone: newSup.phone.trim(), contactEmail: newSup.email.trim() }))
setShowNew(false)
setNewSup({ code: '', name: '', type: SupplierType.NhaThauPhu as SupplierType, phone: '', email: '' })
},
onError: e => toast.error(getErrorMessage(e)),
})
const mut = useMutation({
mutationFn: async () => {
// Step 1: tạo NCC tham gia (PE.Suppliers row)
@ -1375,28 +1399,79 @@ function AddSupplierDialog({ evaluationId, detailId, onClose }: {
<div className="space-y-3">
<div>
<Label>NCC (master)</Label>
<Select
value={form.supplierId}
onChange={e => {
// Session 20 turn 10: auto-fill các field NCC từ master data sẵn có
// (contactPerson/phone/email/note). User vẫn override được sau đó.
const picked = suppliers.data?.find(s => s.id === e.target.value)
setForm(prev => ({
...prev,
supplierId: e.target.value,
contactName: picked?.contactPerson ?? '',
contactPhone: picked?.phone ?? '',
contactEmail: picked?.email ?? '',
note: picked?.note ?? '',
}))
}}
>
<option value="">-- Chọn --</option>
{suppliers.data?.map(s => (
<option key={s.id} value={s.id}>{s.code} {s.name}</option>
))}
</Select>
{/* S59 UAT (3 ý): gõ-tìm (SearchableSelect) + sort A-Z theo mã + "+ NCC mới"
tạo nhanh vào danh mục dùng ngay. Auto-fill liên hệ từ master giữ nguyên. */}
<div className="flex items-start gap-2">
<SearchableSelect
className="min-w-0 flex-1"
options={(suppliers.data ?? [])
.map(s => ({ value: s.id, label: `${s.code}${s.name}` }))
.sort((a, b) => a.label.localeCompare(b.label, 'vi', { numeric: true }))}
value={form.supplierId}
onChange={id => {
// Session 20 turn 10: auto-fill các field NCC từ master data sẵn có
// (contactPerson/phone/email/note). User vẫn override được sau đó.
const picked = suppliers.data?.find(s => s.id === id)
setForm(prev => ({
...prev,
supplierId: id,
contactName: picked?.contactPerson ?? '',
contactPhone: picked?.phone ?? '',
contactEmail: picked?.email ?? '',
note: picked?.note ?? '',
}))
}}
placeholder="-- Chọn NCC (gõ để lọc) --"
/>
<button
type="button"
onClick={() => setShowNew(v => !v)}
className="inline-flex h-8 shrink-0 items-center gap-1 whitespace-nowrap rounded-lg border border-dashed border-brand-300 px-2.5 text-[11px] font-medium text-brand-700 hover:bg-brand-50"
>
<Plus className="h-3 w-3" /> NCC mới
</button>
</div>
{form.supplierId && <p className="mt-1 text-[10px] text-emerald-600"> Đã tự điền từ Master bạn thể sửa lại nếu cần.</p>}
{showNew && (
<div className="mt-2 space-y-2 rounded-lg border border-dashed border-brand-200 bg-brand-50/40 p-2.5">
<p className="text-[11px] font-medium text-brand-700">Tạo NCC mới vào danh mục (dùng ngay cho phiếu này)</p>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[11px]"> *</Label>
<Input value={newSup.code} onChange={e => setNewSup({ ...newSup, code: e.target.value.toUpperCase() })} placeholder="VD: NTP-THANGLONG" />
</div>
<div>
<Label className="text-[11px]">Loại *</Label>
<Select value={newSup.type} onChange={e => setNewSup({ ...newSup, type: Number(e.target.value) as SupplierType })}>
{Object.values(SupplierType).map(t => (
<option key={t} value={t}>{SupplierTypeLabel[t]}</option>
))}
</Select>
</div>
<div className="col-span-2">
<Label className="text-[11px]">Tên *</Label>
<Input value={newSup.name} onChange={e => setNewSup({ ...newSup, name: e.target.value })} placeholder="CÔNG TY ..." />
</div>
<div>
<Label className="text-[11px]">SĐT</Label>
<Input value={newSup.phone} onChange={e => setNewSup({ ...newSup, phone: e.target.value })} placeholder="0987654321" />
</div>
<div>
<Label className="text-[11px]">Email</Label>
<Input value={newSup.email} onChange={e => setNewSup({ ...newSup, email: e.target.value })} />
</div>
</div>
<div className="flex justify-end">
<Button
onClick={() => createSup.mutate()}
disabled={!newSup.code.trim() || !newSup.name.trim() || createSup.isPending}
className="h-7 px-3 text-xs"
>
{createSup.isPending ? 'Đang tạo…' : 'Tạo & chọn'}
</Button>
</div>
</div>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div><Label>Hiển thị</Label><Input value={form.displayName} onChange={e => setForm({ ...form, displayName: e.target.value })} placeholder="vd TGN-30 ngày" /></div>
@ -2324,10 +2399,13 @@ function SupplierAttachmentsCell({
}
}
function onPick(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0]
if (f) upload.mutate(f)
async function onPick(e: React.ChangeEvent<HTMLInputElement>) {
// S59 UAT "mỗi lần chỉ chọn được 1 file" → input multiple, upload tuần tự từng file.
const files = Array.from(e.target.files ?? [])
e.target.value = ''
for (const f of files) {
try { await upload.mutateAsync(f) } catch { /* toast lỗi đã hiện ở onError */ }
}
}
const fmtSize = (b: number) =>
@ -2389,6 +2467,7 @@ function SupplierAttachmentsCell({
<input
ref={fileInputRef}
type="file"
multiple
accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.webp"
onChange={onPick}
className="hidden"
@ -2468,10 +2547,13 @@ function GeneralAttachmentsSection({
}
}
function onPick(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0]
if (f) upload.mutate(f)
async function onPick(e: React.ChangeEvent<HTMLInputElement>) {
// S59 UAT "mỗi lần chỉ chọn được 1 file" → input multiple, upload tuần tự từng file.
const files = Array.from(e.target.files ?? [])
e.target.value = ''
for (const f of files) {
try { await upload.mutateAsync(f) } catch { /* toast lỗi đã hiện ở onError */ }
}
}
const fmtSize = (b: number) =>
@ -2548,6 +2630,7 @@ function GeneralAttachmentsSection({
<input
ref={fileInputRef}
type="file"
multiple
accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.webp"
onChange={onPick}
className="hidden"

View File

@ -264,6 +264,11 @@ export function PeWorkflowPanel({
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai
const isCancel = p === PurchaseEvaluationPhase.TuChoi
const isForwardApprove = !isSendBack && !isCancel
// S59 anh chốt (UAT: "nhân viên tạo phiếu thì trả lại và từ chối cho ai?"):
// người duyệt CHÍNH LÀ người soạn phiếu → ẩn cả Trả lại + Từ chối
// (trả cho chính mình vô nghĩa — đang sửa inline được; hủy phiếu sai
// = nhờ cấp khác Từ chối, phiếu Nháp có nút Xóa riêng).
if ((isSendBack || isCancel) && evaluation.drafterUserId === currentUser?.id) return null
// Mig 24 + UAT S22+1 — disable cả 3 button khi actor không match
// currentLevel.ApproverUserId. "Không quyền = ko quyền mọi hành động."
const isDisabled = blockedByV2Level