[CLAUDE] App+Infra+FE-Admin: seed master data + MyDashboard widgets
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m48s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m48s
Task 1 — Seed master data unblock UAT/demo: - DbInitializer.SeedDepartmentsAsync: 9 departments từ QT-TP-NCC.docx (PM/QS/CCM/PRO/FIN/ACT/EQU/HRA/BOD) — reference data không phải demo. - DbInitializer.SeedDemoMasterDataAsync: 5 demo suppliers (NCC VLXD, NTP Xây dựng, TĐ Hoàng Nam, DV Clean, CĐT Vingroup — covers cả 5 SupplierType) + 3 demo projects (FLOCK01, SkyGarden, Industrial). Chỉ seed nếu tables empty — respect admin's real data khi họ add. Task 2 — Roles CRUD đã có sẵn trong UsersPage (Shield icon button mở dialog gán 12 roles từ AppRoles.cs). Skip. Task 3 — MyDashboard role-specific widgets: - GetMyDashboardQuery (Reports): returns DraftsInProgress (tôi là Drafter + phase soạn thảo), PendingMyApproval (phase eligible role tôi + không phải tôi drafter), DueSoon 24h, Overdue, DraftsTotalValue. - Endpoint GET /api/reports/my-dashboard. - FE MyDashboardRow ở đầu DashboardPage: 4 card hover → navigate. Admin ẩn row nếu tất cả = 0 (ERP noise reduction). 'Đang soạn thảo' + 'Chờ tôi duyệt' clickable → /contracts?filter=... (filter param để wire lần sau; row hiện chưa implement). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -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<MyDashboard>('/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 (
|
||||
<div className="mb-6">
|
||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wider text-slate-500">Của tôi</h2>
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<button
|
||||
onClick={() => navigate('/contracts?filter=my-drafts')}
|
||||
className="rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm transition hover:border-brand-300 hover:shadow"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-medium text-slate-500">Đang soạn thảo</div>
|
||||
<Pencil className="h-4 w-4 text-brand-600" />
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-bold text-slate-900 tabular-nums">{d.draftsInProgress}</div>
|
||||
<div className="mt-0.5 text-xs text-slate-400">{fmtMoney(d.draftsTotalValue)} VND</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/contracts?filter=pending-me')}
|
||||
className="rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm transition hover:border-brand-300 hover:shadow"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-medium text-slate-500">Chờ tôi duyệt</div>
|
||||
<Inbox className="h-4 w-4 text-brand-600" />
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-bold text-slate-900 tabular-nums">{d.pendingMyApproval}</div>
|
||||
<div className="mt-0.5 text-xs text-slate-400">Click để xem hộp thư</div>
|
||||
</button>
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-medium text-slate-500">Sắp quá hạn (24h)</div>
|
||||
<Clock className="h-4 w-4 text-amber-600" />
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-bold text-amber-600 tabular-nums">{d.dueSoon}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-medium text-slate-500">Đã quá hạn</div>
|
||||
<AlertTriangle className="h-4 w-4 text-red-600" />
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-bold text-red-600 tabular-nums">{d.overdue}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const stats = useQuery({
|
||||
queryKey: ['dashboard-stats'],
|
||||
@ -59,6 +130,9 @@ export function DashboardPage() {
|
||||
<div className="p-6">
|
||||
<PageHeader title="Tổng quan" description="Tình hình HĐ toàn hệ thống — cập nhật real-time khi refresh." />
|
||||
|
||||
<MyDashboardRow />
|
||||
|
||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wider text-slate-500">Toàn hệ thống</h2>
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-5">
|
||||
<StatCard icon={FileText} label="Tổng HĐ" value={d.totalContracts} />
|
||||
|
||||
@ -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<ActionResult<DashboardStatsDto>> Dashboard(CancellationToken ct)
|
||||
=> Ok(await mediator.Send(new GetDashboardStatsQuery(), ct));
|
||||
|
||||
[HttpGet("my-dashboard")]
|
||||
public async Task<ActionResult<MyDashboardDto>> MyDashboard(CancellationToken ct)
|
||||
=> Ok(await mediator.Send(new GetMyDashboardQuery(), ct));
|
||||
|
||||
[HttpGet("contracts/export")]
|
||||
public async Task<IActionResult> ExportContracts(
|
||||
[FromQuery] ContractPhase? phase,
|
||||
|
||||
@ -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<MyDashboardDto>;
|
||||
|
||||
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<GetMyDashboardQuery, MyDashboardDto>
|
||||
{
|
||||
// Same map as ContractFeatures.GetMyInboxQuery — roles eligible for each phase
|
||||
private static readonly Dictionary<ContractPhase, string[]> 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<MyDashboardDto> 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);
|
||||
}
|
||||
}
|
||||
@ -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/.
|
||||
|
||||
Reference in New Issue
Block a user