diff --git a/fe-admin/src/pages/DashboardPage.tsx b/fe-admin/src/pages/DashboardPage.tsx index c1bafbe..08c3967 100644 --- a/fe-admin/src/pages/DashboardPage.tsx +++ b/fe-admin/src/pages/DashboardPage.tsx @@ -1,11 +1,21 @@ import { useQuery } from '@tanstack/react-query' -import { FileText, CheckCircle2, AlertTriangle, TrendingUp, Coins } from 'lucide-react' +import { FileText, CheckCircle2, AlertTriangle, TrendingUp, Coins, Pencil, Clock, Inbox } from 'lucide-react' +import { useNavigate } from 'react-router-dom' import { PageHeader } from '@/components/PageHeader' import { BarChart } from '@/components/BarChart' import { PhaseBadge } from '@/components/PhaseBadge' +import { useAuth } from '@/contexts/AuthContext' import { api } from '@/lib/api' import type { DashboardStats } from '@/types/reports' +type MyDashboard = { + draftsInProgress: number + pendingMyApproval: number + dueSoon: number + overdue: number + draftsTotalValue: number +} + const fmtMoney = (v: number) => { if (v >= 1_000_000_000) return (v / 1_000_000_000).toFixed(1) + ' tỷ' if (v >= 1_000_000) return (v / 1_000_000).toFixed(1) + ' tr' @@ -32,6 +42,67 @@ function StatCard({ icon: Icon, label, value, hint, tone = 'default' }: { ) } +function MyDashboardRow() { + const navigate = useNavigate() + const { user } = useAuth() + const q = useQuery({ + queryKey: ['my-dashboard'], + queryFn: async () => (await api.get('/reports/my-dashboard')).data, + staleTime: 30_000, + }) + const d = q.data + if (!d) return null + + // Admin thấy everything nhưng card "Tôi đang soạn thảo" + "Chờ tôi duyệt" + // thường = 0 cho admin. Chỉ show row nếu có ít nhất 1 card có giá trị. + const anyValue = d.draftsInProgress + d.pendingMyApproval + d.dueSoon + d.overdue > 0 + if (!anyValue && user?.roles.includes('Admin')) return null + + return ( +
+

Của tôi

+
+ + +
+
+
Sắp quá hạn (24h)
+ +
+
{d.dueSoon}
+
+
+
+
Đã quá hạn
+ +
+
{d.overdue}
+
+
+
+ ) +} + export function DashboardPage() { const stats = useQuery({ queryKey: ['dashboard-stats'], @@ -59,6 +130,9 @@ export function DashboardPage() {
+ + +

Toàn hệ thống

{/* KPI Cards */}
diff --git a/src/Backend/SolutionErp.Api/Controllers/ReportsController.cs b/src/Backend/SolutionErp.Api/Controllers/ReportsController.cs index ec86161..e832999 100644 --- a/src/Backend/SolutionErp.Api/Controllers/ReportsController.cs +++ b/src/Backend/SolutionErp.Api/Controllers/ReportsController.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc; using SolutionErp.Application.Reports.Commands.ExportContractsToExcel; using SolutionErp.Application.Reports.Dtos; using SolutionErp.Application.Reports.Queries.GetDashboardStats; +using SolutionErp.Application.Reports.Queries.GetMyDashboard; using SolutionErp.Domain.Contracts; namespace SolutionErp.Api.Controllers; @@ -17,6 +18,10 @@ public class ReportsController(IMediator mediator) : ControllerBase public async Task> Dashboard(CancellationToken ct) => Ok(await mediator.Send(new GetDashboardStatsQuery(), ct)); + [HttpGet("my-dashboard")] + public async Task> MyDashboard(CancellationToken ct) + => Ok(await mediator.Send(new GetMyDashboardQuery(), ct)); + [HttpGet("contracts/export")] public async Task ExportContracts( [FromQuery] ContractPhase? phase, diff --git a/src/Backend/SolutionErp.Application/Reports/Queries/GetMyDashboard/GetMyDashboardQuery.cs b/src/Backend/SolutionErp.Application/Reports/Queries/GetMyDashboard/GetMyDashboardQuery.cs new file mode 100644 index 0000000..bbf681e --- /dev/null +++ b/src/Backend/SolutionErp.Application/Reports/Queries/GetMyDashboard/GetMyDashboardQuery.cs @@ -0,0 +1,79 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using SolutionErp.Application.Common.Exceptions; +using SolutionErp.Application.Common.Interfaces; +using SolutionErp.Domain.Contracts; +using SolutionErp.Domain.Identity; + +namespace SolutionErp.Application.Reports.Queries.GetMyDashboard; + +// Per-user dashboard stats — role-aware. Drafter sees their own drafts; +// approvers see what's waiting on their desk; everyone sees SLA pressure +// relevant to them. +public record GetMyDashboardQuery : IRequest; + +public record MyDashboardDto( + int DraftsInProgress, // HĐ tôi là Drafter + đang ở DangSoanThao/DangGopY/DangDamPhan + int PendingMyApproval, // HĐ ở phase mà role tôi eligible advance (không đếm HĐ mình là Drafter) + int DueSoon, // Trong 24h tới (intersect với 2 ở trên) + int Overdue, // Đã quá hạn (intersect) + decimal DraftsTotalValue); + +public class GetMyDashboardQueryHandler( + IApplicationDbContext db, + ICurrentUser currentUser, + IDateTime clock) : IRequestHandler +{ + // Same map as ContractFeatures.GetMyInboxQuery — roles eligible for each phase + private static readonly Dictionary PhaseActorRoles = new() + { + [ContractPhase.DangSoanThao] = [AppRoles.Drafter, AppRoles.DeptManager], + [ContractPhase.DangGopY] = [AppRoles.ProjectManager, AppRoles.Procurement, AppRoles.CostControl, AppRoles.Finance, AppRoles.Accounting, AppRoles.Equipment], + [ContractPhase.DangDamPhan] = [AppRoles.Drafter, AppRoles.DeptManager, AppRoles.ProjectManager], + [ContractPhase.DangInKy] = [AppRoles.Drafter, AppRoles.DeptManager, AppRoles.ProjectManager], + [ContractPhase.DangKiemTraCCM] = [AppRoles.CostControl], + [ContractPhase.DangTrinhKy] = [AppRoles.Director, AppRoles.AuthorizedSigner], + [ContractPhase.DangDongDau] = [AppRoles.HrAdmin], + }; + + public async Task Handle(GetMyDashboardQuery request, CancellationToken ct) + { + var userId = currentUser.UserId ?? throw new UnauthorizedException(); + var userRoles = currentUser.Roles; + var now = clock.UtcNow; + var dayFromNow = now.AddHours(24); + + var eligiblePhases = PhaseActorRoles + .Where(kv => kv.Value.Any(r => userRoles.Contains(r))) + .Select(kv => kv.Key) + .ToList(); + + var contracts = db.Contracts.AsNoTracking() + .Where(c => c.Phase != ContractPhase.DaPhatHanh && c.Phase != ContractPhase.TuChoi); + + // Drafts I'm authoring — I'm the Drafter + phase is pre-signing + var inProgressPhases = new[] { + ContractPhase.DangSoanThao, ContractPhase.DangGopY, ContractPhase.DangDamPhan, + }; + var myDrafts = contracts.Where(c => c.DrafterUserId == userId && inProgressPhases.Contains(c.Phase)); + + var draftsCount = await myDrafts.CountAsync(ct); + var draftsValue = await myDrafts.SumAsync(c => (decimal?)c.GiaTri, ct) ?? 0m; + + // Pending my approval — phase is in my eligibility + I'm NOT the drafter + // (drafters shouldn't self-approve). Empty if user has no relevant role. + var pendingMine = eligiblePhases.Count == 0 + ? contracts.Where(c => false) + : contracts.Where(c => eligiblePhases.Contains(c.Phase) && c.DrafterUserId != userId); + + var pendingCount = await pendingMine.CountAsync(ct); + + // SLA pressure across both sets — a contract I'm drafting + near deadline, + // OR one I should approve + near deadline. + var mine = myDrafts.Union(pendingMine); + var dueSoon = await mine.CountAsync(c => c.SlaDeadline != null && c.SlaDeadline >= now && c.SlaDeadline < dayFromNow, ct); + var overdue = await mine.CountAsync(c => c.SlaDeadline != null && c.SlaDeadline < now, ct); + + return new MyDashboardDto(draftsCount, pendingCount, dueSoon, overdue, draftsValue); + } +} diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs index 71588ba..87dacd7 100644 --- a/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging; using SolutionErp.Domain.Contracts; using SolutionErp.Domain.Forms; using SolutionErp.Domain.Identity; +using SolutionErp.Domain.Master; namespace SolutionErp.Infrastructure.Persistence; @@ -29,6 +30,8 @@ public static class DbInitializer await SeedAdminAsync(userManager, logger); await SeedMenuTreeAsync(db, logger); await SeedAdminPermissionsAsync(db, roleManager, logger); + await SeedDepartmentsAsync(db, logger); + await SeedDemoMasterDataAsync(db, logger); await SeedContractTemplatesAsync(db, logger); await WarnDefaultAdminPasswordAsync(userManager, logger); } @@ -145,6 +148,70 @@ public static class DbInitializer } } + // 9 departments từ QT-TP-NCC.docx — reference data, không phải demo. + // Mỗi role-based check trên workflow tham chiếu phòng ban này. + private static async Task SeedDepartmentsAsync(ApplicationDbContext db, ILogger logger) + { + var departments = new (string Code, string Name, string? Note)[] + { + ("PM", "Ban Quản lý Dự án", "PM/PD: Giám đốc Dự án, Giám đốc Thi công"), + ("QS", "Phòng Quantity Surveyor", "QS công trường — tính khối lượng + soạn HĐ"), + ("CCM", "Phòng Kiểm soát Chi phí", "Cost Control Management — review HĐ trước khi BOD ký"), + ("PRO", "Phòng Cung ứng", "Procurement — NCC vật tư / thầu phụ"), + ("FIN", "Phòng Tài chính", "Financial — điều khoản thanh toán, bảo lãnh"), + ("ACT", "Phòng Kế toán", "Accounting — thuế, hóa đơn"), + ("EQU", "Phòng Thiết bị", "Equipment — thuê/mua máy móc"), + ("HRA", "Phòng Nhân sự - Hành chính", "HRA/ISO — đóng dấu HĐ sau BOD ký"), + ("BOD", "Ban Giám đốc", "Board of Directors — ký duyệt HĐ"), + }; + + var existingCodes = await db.Departments.Select(d => d.Code).ToListAsync(); + var added = 0; + foreach (var (code, name, note) in departments) + { + if (existingCodes.Contains(code)) continue; + db.Departments.Add(new Department { Code = code, Name = name, Note = note }); + added++; + } + if (added > 0) + { + await db.SaveChangesAsync(); + logger.LogInformation("Seeded {Count} departments", added); + } + } + + // Sample master data for UAT/demo. Gated by: only seed if tables are empty + // (respects admin's real data once they start adding). + private static async Task SeedDemoMasterDataAsync(ApplicationDbContext db, ILogger logger) + { + if (await db.Suppliers.AnyAsync() && await db.Projects.AnyAsync()) return; + + if (!await db.Suppliers.AnyAsync()) + { + db.Suppliers.AddRange( + new Supplier { Code = "NCC-VLXD", Name = "Công ty TNHH Vật liệu Xây dựng ABC", Type = SupplierType.NhaCungCap, TaxCode = "0100000001", Phone = "024 3888 1111", Email = "contact@vlxd-abc.vn", ContactPerson = "Nguyễn Văn An", Address = "123 Láng Hạ, Đống Đa, Hà Nội" }, + new Supplier { Code = "NTP-XD", Name = "Công ty CP Xây dựng Thăng Long", Type = SupplierType.NhaThauPhu, TaxCode = "0100000002", Phone = "024 3888 2222", Email = "info@thanglong-xd.vn", ContactPerson = "Trần Thị Bình", Address = "45 Nguyễn Chí Thanh, Đống Đa, Hà Nội" }, + new Supplier { Code = "TD-NEHOANG", Name = "Tổ đội Hoàng Nam", Type = SupplierType.ToDoi, Phone = "098 111 2233", ContactPerson = "Phạm Hoàng Nam", Address = "Cầu Giấy, Hà Nội", Note = "Tổ đội cốp pha 15 người" }, + new Supplier { Code = "DV-CLEAN", Name = "Công ty TNHH Vệ sinh Công nghiệp Clean Pro", Type = SupplierType.DonViDichVu, TaxCode = "0100000004", Phone = "024 3888 4444", Email = "sales@cleanpro.vn", Address = "Trung Hòa, Cầu Giấy, Hà Nội" }, + new Supplier { Code = "CDT-VIN", Name = "Tập đoàn Vingroup", Type = SupplierType.ChuDauTu, TaxCode = "0100109106", Phone = "024 3974 9999", Email = "contact@vingroup.net", Address = "7 Bằng Lăng 1, Vinhomes Riverside, Long Biên, Hà Nội", Note = "Chủ đầu tư - bypass CCM" } + ); + logger.LogInformation("Seeded 5 demo suppliers"); + } + + if (!await db.Projects.AnyAsync()) + { + var now = DateTime.UtcNow; + db.Projects.AddRange( + new Project { Code = "FLOCK01", Name = "Dự án FLOCK 01 — Khu đô thị Mỹ Đình", StartDate = now.AddMonths(-3), EndDate = now.AddMonths(18), BudgetTotal = 120_000_000_000m, Note = "Dự án mẫu — demo" }, + new Project { Code = "SKYGARDEN", Name = "Sky Garden Residence", StartDate = now.AddMonths(-6), EndDate = now.AddMonths(12), BudgetTotal = 85_000_000_000m, Note = "Dự án mẫu — demo" }, + new Project { Code = "INDUSTRIAL", Name = "Nhà máy Công nghiệp Yên Phong", StartDate = now.AddMonths(-1), EndDate = now.AddMonths(9), BudgetTotal = 45_000_000_000m, Note = "Dự án mẫu — demo" } + ); + logger.LogInformation("Seeded 3 demo projects"); + } + + await db.SaveChangesAsync(); + } + private static async Task SeedContractTemplatesAsync(ApplicationDbContext db, ILogger logger) { // Chỉ IsActive=true nếu file thực tế tồn tại trong wwwroot/templates/.