diff --git a/fe-admin/src/components/pe/PeDetailTabs.tsx b/fe-admin/src/components/pe/PeDetailTabs.tsx
index 30e0809..892e349 100644
--- a/fe-admin/src/components/pe/PeDetailTabs.tsx
+++ b/fe-admin/src/components/pe/PeDetailTabs.tsx
@@ -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 }: {
-
+ {/* 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. */}
+
+ ({ 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) --"
+ />
+
+
{form.supplierId &&
✓ Đã tự điền từ Master — bạn có thể sửa lại nếu cần.
}
+ {showNew && (
+
+
Tạo NCC mới vào danh mục (dùng ngay cho phiếu này)
@@ -2324,10 +2399,13 @@ function SupplierAttachmentsCell({
}
}
- function onPick(e: React.ChangeEvent) {
- const f = e.target.files?.[0]
- if (f) upload.mutate(f)
+ async function onPick(e: React.ChangeEvent) {
+ // 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({
) {
- const f = e.target.files?.[0]
- if (f) upload.mutate(f)
+ async function onPick(e: React.ChangeEvent) {
+ // 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({
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 }: {
-
+ {/* 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. */}
+
+ ({ 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) --"
+ />
+
+
{form.supplierId &&
✓ Đã tự điền từ Master — bạn có thể sửa lại nếu cần.
}
+ {showNew && (
+
+
Tạo NCC mới vào danh mục (dùng ngay cho phiếu này)
@@ -2324,10 +2399,13 @@ function SupplierAttachmentsCell({
}
}
- function onPick(e: React.ChangeEvent) {
- const f = e.target.files?.[0]
- if (f) upload.mutate(f)
+ async function onPick(e: React.ChangeEvent) {
+ // 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({
) {
- const f = e.target.files?.[0]
- if (f) upload.mutate(f)
+ async function onPick(e: React.ChangeEvent) {
+ // 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({
(null)
const [comment, setComment] = useState('')
// Mig 28 (S21 t4) — F1 mode Trả lại. Default Drafter (backward compat S17).
- // S23 t2 fix: default sang first available F1 mode (mode đang gửi duyệt) khi
- // admin tick — Drafter (= trả về Người soạn) chỉ default khi không có F1 nào.
const [returnMode, setReturnMode] = useState(WorkflowReturnMode.Drafter)
const [returnTargetUserId, setReturnTargetUserId] = useState(null)
// Mig 31 (S23 t1) — F2 Approver duyệt thẳng Cấp cuối. Default false (admin opt-in
@@ -46,8 +44,7 @@ export function PeWorkflowPanel({
const { user: currentUser } = useAuth()
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
- // Mig 29 (S21 t5) — F1 options per-Level (Cấp Approver hiện tại). Null nếu
- // V1 legacy hoặc pointer chưa init → fallback chỉ "Trả về Drafter".
+ // Mig 29 (S21 t5) — F1 options per-Level (Cấp Approver hiện tại).
const levelOptions = evaluation.currentLevelOptions
// S23 t2 fix bro UAT: khi admin tick AllowReturnToAssignee/OneLevel/OneStep
@@ -69,16 +66,18 @@ export function PeWorkflowPanel({
// List approvers đã ký (cho mode Assignee dropdown pick)
const signedApprovers = (evaluation.levelOpinions ?? [])
.map(o => ({ userId: o.approverUserId, fullName: o.approverFullName ?? 'NV' }))
- // Dedupe by userId (1 NV có thể ký nhiều cấp nếu workflow đặt như vậy)
.filter((v, i, arr) => arr.findIndex(x => x.userId === v.userId) === i)
// Mig 24 — V2 schema chỉ cho phép approver trong CurrentApproval.approvers
- // thao tác cấp hiện tại. UAT S22+1: disable cả 3 button (Duyệt + Trả lại
- // + Từ chối) khi actor không match. BE mirror EnsureCanRejectV2Async.
+ // thao tác cấp hiện tại. UAT S22+1 feedback: "không quyền thao tác = ko quyền
+ // mọi hành động" — disable cả 3 button (Duyệt + Trả lại + Từ chối) khi actor
+ // không match currentLevel.ApproverUserId. BE mirror guard trong
+ // EnsureCanRejectV2Async (defense-in-depth — UI disable + BE reject).
// Admin bypass.
const v2Approvers = evaluation.currentApproval?.approvers ?? []
const actorInV2Level = isAdmin
|| (currentUser?.id && v2Approvers.some(a => a.userId === currentUser.id))
+ // V2 active = phiếu pin V2 + Phase=ChoDuyet + có currentApproval
const isV2Pending = !!evaluation.currentApproval
const blockedByV2Level = isV2Pending && !actorInV2Level
@@ -221,13 +220,15 @@ export function PeWorkflowPanel({
)}
+ {/* Phiếu V1 legacy không có flow → fallback hiển thị phase summary đơn giản */}
{!flow && (
Phiếu này dùng quy trình cũ — workflow chi tiết không khả dụng.
)}
- {/* Mig 24 — V2 banner Bước/Cấp + danh sách NV duyệt */}
+ {/* Mig 24 — V2 banner: hiển thị Bước/Cấp hiện tại + danh sách NV được duyệt.
+ Nếu actor không có trong list → banner amber + nút Duyệt sẽ disable. */}
{isV2Pending && evaluation.currentApproval && !readOnly && (
{
const isCancel = target === PurchaseEvaluationPhase.TuChoi
- // isSendBack sync với button label L205-207 + payload isReject L64-68
- // (gotcha #45). Include cả DangSoanThao (legacy Mig 16) lẫn TraLai
- // (Session 17 spec) — cả 2 là Trả lại Drafter sửa.
+ // isSendBack sync với button label + payload isReject (gotcha #45).
+ // Include cả DangSoanThao (legacy Mig 16) lẫn TraLai (Session 17 spec)
+ // — cả 2 là Trả lại Drafter sửa.
const isSendBack = (target === PurchaseEvaluationPhase.DangSoanThao
|| target === PurchaseEvaluationPhase.TraLai)
&& evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao
diff --git a/src/Backend/SolutionErp.Api/Controllers/SuppliersController.cs b/src/Backend/SolutionErp.Api/Controllers/SuppliersController.cs
index 72d7b7a..d9a8d06 100644
--- a/src/Backend/SolutionErp.Api/Controllers/SuppliersController.cs
+++ b/src/Backend/SolutionErp.Api/Controllers/SuppliersController.cs
@@ -31,7 +31,9 @@ public class SuppliersController(IMediator mediator) : ControllerBase
// [S57] Master write khóa Admin+CatalogManager (đọc mở cho mọi role review/test;
// chống nhân viên sửa/xóa NCC production qua API khi menu hiện cho toàn bộ phận).
- [Authorize(Roles = "Admin,CatalogManager")]
+ // [S59] anh chốt (UAT "Không tự thêm dc tên NTP mới"): TẠO MỚI mở cho mọi user
+ // đăng nhập — nghiệp vụ đi thầu phát sinh NCC/NTP liên tục, quick-add ngay form
+ // thêm NCC vào phiếu. SỬA/XÓA vẫn khóa Admin+CatalogManager (2 action dưới).
[HttpPost]
public async Task> Create([FromBody] CreateSupplierCommand cmd, CancellationToken ct)
{