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)

+
+
+ + setNewSup({ ...newSup, code: e.target.value.toUpperCase() })} placeholder="VD: NTP-THANGLONG" /> +
+
+ + +
+
+ + setNewSup({ ...newSup, name: e.target.value })} placeholder="CÔNG TY ..." /> +
+
+ + setNewSup({ ...newSup, phone: e.target.value })} placeholder="0987654321" /> +
+
+ + setNewSup({ ...newSup, email: e.target.value })} /> +
+
+
+ +
+
+ )}
setForm({ ...form, displayName: e.target.value })} placeholder="vd TGN-30 ngà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)

+
+
+ + setNewSup({ ...newSup, code: e.target.value.toUpperCase() })} placeholder="VD: NTP-THANGLONG" /> +
+
+ + +
+
+ + setNewSup({ ...newSup, name: e.target.value })} placeholder="CÔNG TY ..." /> +
+
+ + setNewSup({ ...newSup, phone: e.target.value })} placeholder="0987654321" /> +
+
+ + setNewSup({ ...newSup, email: e.target.value })} /> +
+
+
+ +
+
+ )}
setForm({ ...form, displayName: e.target.value })} placeholder="vd TGN-30 ngà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) {