diff --git a/fe-admin/src/components/pe/PeDetailTabs.tsx b/fe-admin/src/components/pe/PeDetailTabs.tsx index 056733c..e7ecbb9 100644 --- a/fe-admin/src/components/pe/PeDetailTabs.tsx +++ b/fe-admin/src/components/pe/PeDetailTabs.tsx @@ -104,6 +104,13 @@ export function PeDetailTabs({
+
+ a.purchaseEvaluationSupplierId === null)} + readOnly={readOnly} + /> +
) @@ -869,3 +876,140 @@ function SupplierAttachmentsCell({ ) } + +// ===== Section Bảng so sánh — general attachments (không gắn NCC cụ thể) ===== +// Purpose mặc định = ComparisonTable (4). Upload file Excel/PDF tổng hợp so +// sánh giá N NCC × M hạng mục. Storage path giống SupplierAttachmentsCell +// nhưng supplierRowId KHÔNG truyền → BE lưu NULL. +function GeneralAttachmentsSection({ + evaluationId, + attachments, + readOnly = false, +}: { + evaluationId: string + attachments: PeAttachment[] + readOnly?: boolean +}) { + const qc = useQueryClient() + const fileInputRef = useRef(null) + + const upload = useMutation({ + mutationFn: async (file: File) => { + const fd = new FormData() + fd.append('file', file) + // KHÔNG append supplierRowId → BE set NULL → general attachment + fd.append('purpose', String(PeAttachmentPurpose.ComparisonTable)) + return api.post(`/purchase-evaluations/${evaluationId}/attachments`, fd, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + }, + onSuccess: () => { + toast.success('Đã tải lên bảng so sánh.') + qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }) + }, + onError: e => toast.error(getErrorMessage(e)), + }) + + const del = useMutation({ + mutationFn: async (attId: string) => + api.delete(`/purchase-evaluations/${evaluationId}/attachments/${attId}`), + onSuccess: () => { + toast.success('Đã xóa.') + qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }) + }, + onError: e => toast.error(getErrorMessage(e)), + }) + + async function download(att: PeAttachment) { + try { + const res = await api.get( + `/purchase-evaluations/${evaluationId}/attachments/${att.id}/download`, + { responseType: 'blob' }, + ) + const url = window.URL.createObjectURL(res.data as Blob) + const a = document.createElement('a') + a.href = url + a.download = att.fileName + a.click() + window.URL.revokeObjectURL(url) + } catch (e) { + toast.error(getErrorMessage(e)) + } + } + + function onPick(e: React.ChangeEvent) { + const f = e.target.files?.[0] + if (f) upload.mutate(f) + e.target.value = '' + } + + const fmtSize = (b: number) => + b > 1024 * 1024 ? `${(b / 1024 / 1024).toFixed(1)}MB` : `${Math.round(b / 1024)}KB` + + return ( +
+ {!readOnly && ( +

+ File Excel/PDF tổng hợp so sánh giá của tất cả NCC (không gắn với 1 NCC cụ thể). +

+ )} + {attachments.length === 0 && readOnly && ( +

Chưa có bảng so sánh.

+ )} + {attachments.length > 0 && ( +
+ {attachments.map(a => ( +
+ + + {fmtSize(a.fileSize)} + + {PeAttachmentPurposeLabel[a.purpose] ?? 'Khác'} + + + {new Date(a.createdAt).toLocaleDateString('vi-VN')} + + {!readOnly && ( + + )} +
+ ))} +
+ )} + {!readOnly && ( +
+ + +
+ )} +
+ ) +} diff --git a/fe-admin/src/types/purchaseEvaluation.ts b/fe-admin/src/types/purchaseEvaluation.ts index 0db568b..76a5980 100644 --- a/fe-admin/src/types/purchaseEvaluation.ts +++ b/fe-admin/src/types/purchaseEvaluation.ts @@ -121,6 +121,7 @@ export const PeAttachmentPurpose = { QuoteDocument: 1, RequirementSpec: 2, DecisionExport: 3, + ComparisonTable: 4, Other: 99, } as const @@ -128,6 +129,7 @@ export const PeAttachmentPurposeLabel: Record = { 1: 'Báo giá', 2: 'Yêu cầu KT', 3: 'Phiếu duyệt', + 4: 'Bảng so sánh', 99: 'Khác', } diff --git a/fe-user/src/components/pe/PeDetailTabs.tsx b/fe-user/src/components/pe/PeDetailTabs.tsx index 056733c..e7ecbb9 100644 --- a/fe-user/src/components/pe/PeDetailTabs.tsx +++ b/fe-user/src/components/pe/PeDetailTabs.tsx @@ -104,6 +104,13 @@ export function PeDetailTabs({
+
+ a.purchaseEvaluationSupplierId === null)} + readOnly={readOnly} + /> +
) @@ -869,3 +876,140 @@ function SupplierAttachmentsCell({ ) } + +// ===== Section Bảng so sánh — general attachments (không gắn NCC cụ thể) ===== +// Purpose mặc định = ComparisonTable (4). Upload file Excel/PDF tổng hợp so +// sánh giá N NCC × M hạng mục. Storage path giống SupplierAttachmentsCell +// nhưng supplierRowId KHÔNG truyền → BE lưu NULL. +function GeneralAttachmentsSection({ + evaluationId, + attachments, + readOnly = false, +}: { + evaluationId: string + attachments: PeAttachment[] + readOnly?: boolean +}) { + const qc = useQueryClient() + const fileInputRef = useRef(null) + + const upload = useMutation({ + mutationFn: async (file: File) => { + const fd = new FormData() + fd.append('file', file) + // KHÔNG append supplierRowId → BE set NULL → general attachment + fd.append('purpose', String(PeAttachmentPurpose.ComparisonTable)) + return api.post(`/purchase-evaluations/${evaluationId}/attachments`, fd, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + }, + onSuccess: () => { + toast.success('Đã tải lên bảng so sánh.') + qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }) + }, + onError: e => toast.error(getErrorMessage(e)), + }) + + const del = useMutation({ + mutationFn: async (attId: string) => + api.delete(`/purchase-evaluations/${evaluationId}/attachments/${attId}`), + onSuccess: () => { + toast.success('Đã xóa.') + qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }) + }, + onError: e => toast.error(getErrorMessage(e)), + }) + + async function download(att: PeAttachment) { + try { + const res = await api.get( + `/purchase-evaluations/${evaluationId}/attachments/${att.id}/download`, + { responseType: 'blob' }, + ) + const url = window.URL.createObjectURL(res.data as Blob) + const a = document.createElement('a') + a.href = url + a.download = att.fileName + a.click() + window.URL.revokeObjectURL(url) + } catch (e) { + toast.error(getErrorMessage(e)) + } + } + + function onPick(e: React.ChangeEvent) { + const f = e.target.files?.[0] + if (f) upload.mutate(f) + e.target.value = '' + } + + const fmtSize = (b: number) => + b > 1024 * 1024 ? `${(b / 1024 / 1024).toFixed(1)}MB` : `${Math.round(b / 1024)}KB` + + return ( +
+ {!readOnly && ( +

+ File Excel/PDF tổng hợp so sánh giá của tất cả NCC (không gắn với 1 NCC cụ thể). +

+ )} + {attachments.length === 0 && readOnly && ( +

Chưa có bảng so sánh.

+ )} + {attachments.length > 0 && ( +
+ {attachments.map(a => ( +
+ + + {fmtSize(a.fileSize)} + + {PeAttachmentPurposeLabel[a.purpose] ?? 'Khác'} + + + {new Date(a.createdAt).toLocaleDateString('vi-VN')} + + {!readOnly && ( + + )} +
+ ))} +
+ )} + {!readOnly && ( +
+ + +
+ )} +
+ ) +} diff --git a/fe-user/src/types/purchaseEvaluation.ts b/fe-user/src/types/purchaseEvaluation.ts index 0db568b..76a5980 100644 --- a/fe-user/src/types/purchaseEvaluation.ts +++ b/fe-user/src/types/purchaseEvaluation.ts @@ -121,6 +121,7 @@ export const PeAttachmentPurpose = { QuoteDocument: 1, RequirementSpec: 2, DecisionExport: 3, + ComparisonTable: 4, Other: 99, } as const @@ -128,6 +129,7 @@ export const PeAttachmentPurposeLabel: Record = { 1: 'Báo giá', 2: 'Yêu cầu KT', 3: 'Phiếu duyệt', + 4: 'Bảng so sánh', 99: 'Khác', } diff --git a/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationAttachment.cs b/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationAttachment.cs index cd61fc7..10baf6c 100644 --- a/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationAttachment.cs +++ b/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationAttachment.cs @@ -4,9 +4,10 @@ namespace SolutionErp.Domain.PurchaseEvaluations; public enum PurchaseEvaluationAttachmentPurpose { - QuoteDocument = 1, // File báo giá NCC gửi (PDF/xlsx) + QuoteDocument = 1, // File báo giá NCC gửi (PDF/xlsx) — gắn với NCC cụ thể RequirementSpec = 2, // Bản vẽ/yêu cầu kỹ thuật kèm theo DecisionExport = 3, // Bản phiếu duyệt đã export + ComparisonTable = 4, // Bảng so sánh tổng — không gắn với 1 NCC, file tổng hợp Other = 99, } diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs index 0195a42..db37474 100644 --- a/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs @@ -14,9 +14,39 @@ namespace SolutionErp.Infrastructure.Persistence; public static class DbInitializer { - public const string AdminEmail = "admin@solutionerp.local"; + public const string AdminEmail = "admin@solutions.com.vn"; public const string AdminPassword = "Admin@123456"; + // Migration helper (Phase 6 rebrand): rename user email + // @solutionerp.local → @solutions.com.vn. Idempotent — skip user đã rename. + // Password + role + refresh token giữ nguyên. Chạy trước SeedAdmin. + private static async Task BackfillUserEmailDomainAsync( + UserManager userManager, ILogger logger) + { + const string OldDomain = "@solutionerp.local"; + const string NewDomain = "@solutions.com.vn"; + var oldUsers = userManager.Users + .Where(u => u.Email != null && u.Email.EndsWith(OldDomain)) + .ToList(); + if (oldUsers.Count == 0) return; + + var renamed = 0; + foreach (var u in oldUsers) + { + var newEmail = u.Email!.Replace(OldDomain, NewDomain); + var conflict = await userManager.FindByEmailAsync(newEmail); + if (conflict != null && conflict.Id != u.Id) continue; + u.Email = newEmail; + u.NormalizedEmail = newEmail.ToUpperInvariant(); + u.UserName = newEmail; + u.NormalizedUserName = newEmail.ToUpperInvariant(); + var res = await userManager.UpdateAsync(u); + if (res.Succeeded) renamed++; + } + if (renamed > 0) + logger.LogInformation("Backfilled {Count} user emails -> @solutions.com.vn", renamed); + } + public static async Task InitializeAsync(IServiceProvider services) { using var scope = services.CreateScope(); @@ -30,6 +60,9 @@ public static class DbInitializer await db.Database.MigrateAsync(); await SeedRolesAsync(roleManager, logger); + // Phase 6 rebrand: rename user email @solutionerp.local → @solutions.com.vn + // TRƯỚC SeedAdmin để SeedAdmin tìm theo new email thấy user đã rename → skip create. + await BackfillUserEmailDomainAsync(userManager, logger); await SeedAdminAsync(userManager, logger); await SeedDepartmentsAsync(db, logger); await SeedDemoUsersAsync(db, userManager, logger); @@ -452,10 +485,10 @@ public static class DbInitializer var fallbackSupplier = suppliersByCode.Values.First(); var fallbackProject = projectsByCode.Values.First(); - var qsHoang = await userManager.FindByEmailAsync("qs.hoang@solutionerp.local"); - var ccmTran = await userManager.FindByEmailAsync("ccm.tran@solutionerp.local"); - var bodHuynh = await userManager.FindByEmailAsync("bod.huynh@solutionerp.local"); - var hraDang = await userManager.FindByEmailAsync("hra.dang@solutionerp.local"); + var qsHoang = await userManager.FindByEmailAsync("qs.hoang@solutions.com.vn"); + var ccmTran = await userManager.FindByEmailAsync("ccm.tran@solutions.com.vn"); + var bodHuynh = await userManager.FindByEmailAsync("bod.huynh@solutions.com.vn"); + var hraDang = await userManager.FindByEmailAsync("hra.dang@solutions.com.vn"); var qsDeptId = (await db.Departments.FirstOrDefaultAsync(d => d.Code == "QS"))?.Id; var nowUtc = DateTime.UtcNow; @@ -704,11 +737,11 @@ public static class DbInitializer } // Lookup actor users (per role) - var qsHoang = await userManager.FindByEmailAsync("qs.hoang@solutionerp.local"); - var proPham = await userManager.FindByEmailAsync("pro.pham@solutionerp.local"); - var ccmTran = await userManager.FindByEmailAsync("ccm.tran@solutionerp.local"); - var pmNguyen = await userManager.FindByEmailAsync("pm.nguyen@solutionerp.local"); - var bodHuynh = await userManager.FindByEmailAsync("bod.huynh@solutionerp.local"); + var qsHoang = await userManager.FindByEmailAsync("qs.hoang@solutions.com.vn"); + var proPham = await userManager.FindByEmailAsync("pro.pham@solutions.com.vn"); + var ccmTran = await userManager.FindByEmailAsync("ccm.tran@solutions.com.vn"); + var pmNguyen = await userManager.FindByEmailAsync("pm.nguyen@solutions.com.vn"); + var bodHuynh = await userManager.FindByEmailAsync("bod.huynh@solutions.com.vn"); var qsDeptId = (await db.Departments.FirstOrDefaultAsync(d => d.Code == "QS"))?.Id; var nowUtc = DateTime.UtcNow; @@ -1101,28 +1134,28 @@ public static class DbInitializer { // (Email, FullName, DeptCode, Position, RoleNames[]) // BOD (3) — director + signer + admin assistant - ("bod.huynh@solutionerp.local", "Huỳnh Văn Hùng", "BOD", "Tổng Giám đốc", new[] { AppRoles.Director }), - ("bod.le@solutionerp.local", "Lê Thị Mai", "BOD", "Phó Giám đốc (NĐUQ)", new[] { AppRoles.AuthorizedSigner }), - ("bod.tran@solutionerp.local", "Trần Quốc Bảo", "BOD", "Phó Giám đốc Kỹ thuật (NĐUQ)", new[] { AppRoles.AuthorizedSigner }), + ("bod.huynh@solutions.com.vn", "Huỳnh Văn Hùng", "BOD", "Tổng Giám đốc", new[] { AppRoles.Director }), + ("bod.le@solutions.com.vn", "Lê Thị Mai", "BOD", "Phó Giám đốc (NĐUQ)", new[] { AppRoles.AuthorizedSigner }), + ("bod.tran@solutions.com.vn", "Trần Quốc Bảo", "BOD", "Phó Giám đốc Kỹ thuật (NĐUQ)", new[] { AppRoles.AuthorizedSigner }), // PM (2) — giám đốc dự án - ("pm.nguyen@solutionerp.local", "Nguyễn Quốc Cường", "PM", "Giám đốc Dự án FLOCK 01", new[] { AppRoles.ProjectManager }), - ("pm.le@solutionerp.local", "Lê Thanh Tùng", "PM", "Giám đốc Dự án Vinhomes Ocean Park", new[] { AppRoles.ProjectManager }), + ("pm.nguyen@solutions.com.vn", "Nguyễn Quốc Cường", "PM", "Giám đốc Dự án FLOCK 01", new[] { AppRoles.ProjectManager }), + ("pm.le@solutions.com.vn", "Lê Thanh Tùng", "PM", "Giám đốc Dự án Vinhomes Ocean Park", new[] { AppRoles.ProjectManager }), // CCM/PRO/FIN/ACT/EQU/HRA — Trưởng phòng các phòng ban (1 mỗi) - ("ccm.tran@solutionerp.local", "Trần Văn Bình", "CCM", "Trưởng phòng Kiểm soát chi phí", new[] { AppRoles.CostControl, AppRoles.DeptManager }), - ("pro.pham@solutionerp.local", "Phạm Thị Hồng", "PRO", "Trưởng phòng Cung ứng", new[] { AppRoles.Procurement, AppRoles.DeptManager }), - ("fin.do@solutionerp.local", "Đỗ Minh Tuấn", "FIN", "Trưởng phòng Tài chính", new[] { AppRoles.Finance, AppRoles.DeptManager }), - ("act.vu@solutionerp.local", "Vũ Thị Lan", "ACT", "Kế toán trưởng", new[] { AppRoles.Accounting, AppRoles.DeptManager }), - ("equ.bui@solutionerp.local", "Bùi Văn Khánh", "EQU", "Trưởng phòng Thiết bị", new[] { AppRoles.Equipment, AppRoles.DeptManager }), - ("hra.dang@solutionerp.local", "Đặng Thị Thanh", "HRA", "Trưởng phòng Nhân sự - Hành chính", new[] { AppRoles.HrAdmin, AppRoles.DeptManager }), + ("ccm.tran@solutions.com.vn", "Trần Văn Bình", "CCM", "Trưởng phòng Kiểm soát chi phí", new[] { AppRoles.CostControl, AppRoles.DeptManager }), + ("pro.pham@solutions.com.vn", "Phạm Thị Hồng", "PRO", "Trưởng phòng Cung ứng", new[] { AppRoles.Procurement, AppRoles.DeptManager }), + ("fin.do@solutions.com.vn", "Đỗ Minh Tuấn", "FIN", "Trưởng phòng Tài chính", new[] { AppRoles.Finance, AppRoles.DeptManager }), + ("act.vu@solutions.com.vn", "Vũ Thị Lan", "ACT", "Kế toán trưởng", new[] { AppRoles.Accounting, AppRoles.DeptManager }), + ("equ.bui@solutions.com.vn", "Bùi Văn Khánh", "EQU", "Trưởng phòng Thiết bị", new[] { AppRoles.Equipment, AppRoles.DeptManager }), + ("hra.dang@solutions.com.vn", "Đặng Thị Thanh", "HRA", "Trưởng phòng Nhân sự - Hành chính", new[] { AppRoles.HrAdmin, AppRoles.DeptManager }), // Drafter (5) — soạn thảo HĐ ở các phòng khác nhau - ("qs.hoang@solutionerp.local", "Hoàng Văn Đức", "QS", "QS công trường — soạn thảo HĐ", new[] { AppRoles.Drafter }), - ("qs.ngo@solutionerp.local", "Ngô Thị Hà", "QS", "QS dự án FLOCK 01", new[] { AppRoles.Drafter }), - ("nv.cao@solutionerp.local", "Cao Văn Long", "PRO", "Nhân viên Cung ứng", new[] { AppRoles.Drafter }), - ("nv.dinh@solutionerp.local", "Đinh Thị Yến", "FIN", "Nhân viên Tài chính", new[] { AppRoles.Drafter }), - ("nv.truong@solutionerp.local", "Trương Minh Quân", "CCM", "Nhân viên Kiểm soát chi phí", new[] { AppRoles.Drafter }), + ("qs.hoang@solutions.com.vn", "Hoàng Văn Đức", "QS", "QS công trường — soạn thảo HĐ", new[] { AppRoles.Drafter }), + ("qs.ngo@solutions.com.vn", "Ngô Thị Hà", "QS", "QS dự án FLOCK 01", new[] { AppRoles.Drafter }), + ("nv.cao@solutions.com.vn", "Cao Văn Long", "PRO", "Nhân viên Cung ứng", new[] { AppRoles.Drafter }), + ("nv.dinh@solutions.com.vn", "Đinh Thị Yến", "FIN", "Nhân viên Tài chính", new[] { AppRoles.Drafter }), + ("nv.truong@solutions.com.vn", "Trương Minh Quân", "CCM", "Nhân viên Kiểm soát chi phí", new[] { AppRoles.Drafter }), }; int created = 0, fixedExisting = 0, failed = 0;