emloyee and user fixings

This commit is contained in:
2026-09-11 10:32:47 +05:30
parent 629f3d1f44
commit cb41d503c4
61 changed files with 35831 additions and 1228 deletions
@@ -0,0 +1,39 @@
namespace ERPCore.Infra.Auth;
/// <summary>
/// Best-effort "Browser on OS" label from a raw User-Agent string, for display in the
/// login-device history — not a full parsing library, just enough to be readable
/// (docs call this out: exact device model detection is out of scope).
/// </summary>
public static class UserAgentLabeler
{
public static string Label(string? userAgent)
{
if (string.IsNullOrWhiteSpace(userAgent)) return "Unknown device";
var os = DetectOs(userAgent);
var browser = DetectBrowser(userAgent);
return $"{browser} on {os}";
}
private static string DetectOs(string ua)
{
if (ua.Contains("Windows", StringComparison.OrdinalIgnoreCase)) return "Windows";
if (ua.Contains("Mac OS X", StringComparison.OrdinalIgnoreCase) || ua.Contains("Macintosh", StringComparison.OrdinalIgnoreCase)) return "macOS";
if (ua.Contains("Android", StringComparison.OrdinalIgnoreCase)) return "Android";
if (ua.Contains("iPhone", StringComparison.OrdinalIgnoreCase) || ua.Contains("iPad", StringComparison.OrdinalIgnoreCase)) return "iOS";
if (ua.Contains("Linux", StringComparison.OrdinalIgnoreCase)) return "Linux";
return "an unknown OS";
}
private static string DetectBrowser(string ua)
{
// Order matters: Edge/OPR/Chrome all also contain "Safari"/"Chrome" tokens.
if (ua.Contains("Edg/", StringComparison.OrdinalIgnoreCase)) return "Edge";
if (ua.Contains("OPR/", StringComparison.OrdinalIgnoreCase) || ua.Contains("Opera", StringComparison.OrdinalIgnoreCase)) return "Opera";
if (ua.Contains("Firefox", StringComparison.OrdinalIgnoreCase)) return "Firefox";
if (ua.Contains("CriOS", StringComparison.OrdinalIgnoreCase) || ua.Contains("Chrome", StringComparison.OrdinalIgnoreCase)) return "Chrome";
if (ua.Contains("Safari", StringComparison.OrdinalIgnoreCase)) return "Safari";
return "an unknown browser";
}
}
@@ -21,7 +21,7 @@ public static class AuditScribe
private static readonly HashSet<Type> Excluded =
[
typeof(AuditLog), typeof(JournalEntryStub), typeof(NumberSequence),
typeof(StockLedger), typeof(StockLayer),
typeof(StockLedger), typeof(StockLayer), typeof(LoginActivity),
];
private static readonly JsonSerializerOptions Json = new()
@@ -0,0 +1,23 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class LoginActivityConfiguration : IEntityTypeConfiguration<LoginActivity>
{
public void Configure(EntityTypeBuilder<LoginActivity> builder)
{
builder.ToTable("login_activities");
builder.HasKey(l => l.LoginActivityId);
builder.Property(l => l.IpAddress).HasMaxLength(64);
builder.Property(l => l.UserAgent).HasMaxLength(500);
builder.Property(l => l.DeviceLabel).HasMaxLength(200);
builder.Property(l => l.LoginAt).IsRequired();
builder.HasOne(l => l.User).WithMany().HasForeignKey(l => l.UserId).OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(l => new { l.UserId, l.LoginAt });
}
}
@@ -29,7 +29,8 @@ public sealed class NavItemConfiguration : IEntityTypeConfiguration<NavItem>
builder.HasData(
new NavItem { NavItemId = 1, Code = "dashboard", Label = "Dashboard", Href = "/dashboard", SortOrder = 1 },
new NavItem { NavItemId = 2, Code = "products", Label = "Products", Href = "/dashboard/products", SortOrder = 2 },
new NavItem { NavItemId = 3, Code = "vendors", Label = "Vendors", Href = "/dashboard/vendors", SortOrder = 3 },
// NavItemId 3 ("vendors") retired 2026-09-09 — Vendors moved under Procurement as a
// sub-item (see SubNavItemConfiguration/PermissionConfiguration), matching the sidebar.
new NavItem { NavItemId = 4, Code = "procurement", Label = "Procurement", Href = "/dashboard/procurement", SortOrder = 4 },
new NavItem { NavItemId = 5, Code = "receiving", Label = "Receiving", Href = "/dashboard/receiving/grn", SortOrder = 5 },
new NavItem { NavItemId = 6, Code = "stock", Label = "Stock", Href = "/dashboard/stock", SortOrder = 6 },
@@ -26,7 +26,9 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
builder.HasData(
new Permission { PermissionId = 1, Code = "NAV:dashboard", NavItemId = 1 },
new Permission { PermissionId = 2, Code = "NAV:products", NavItemId = 2 },
new Permission { PermissionId = 3, Code = "NAV:vendors", NavItemId = 3 },
// Moved under Procurement as a sub-item (2026-09-09) — same PermissionId (3), just
// repointed to the new SubNavItem, so any role already granted this permission keeps it.
new Permission { PermissionId = 3, Code = "NAV:procurement.vendors", SubNavItemId = 30 },
new Permission { PermissionId = 4, Code = "NAV:procurement", NavItemId = 4 },
new Permission { PermissionId = 5, Code = "NAV:receiving", NavItemId = 5 },
new Permission { PermissionId = 6, Code = "NAV:stock", NavItemId = 6 },
@@ -67,7 +69,21 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
new Permission { PermissionId = 37, Code = "NAV:production.runs", SubNavItemId = 25 },
new Permission { PermissionId = 38, Code = "NAV:production.produced", SubNavItemId = 26 },
new Permission { PermissionId = 39, Code = "NAV:procurement.purchase-invoices", SubNavItemId = 28 },
new Permission { PermissionId = 40, Code = "NAV:settings.branches", SubNavItemId = 29 }
new Permission { PermissionId = 40, Code = "NAV:settings.branches", SubNavItemId = 29 },
new Permission { PermissionId = 41, Code = "NAV:stock.ledger", SubNavItemId = 31 },
new Permission { PermissionId = 42, Code = "NAV:stock.enquiry", SubNavItemId = 32 },
new Permission { PermissionId = 43, Code = "NAV:stock.valuation", SubNavItemId = 33 },
new Permission { PermissionId = 44, Code = "NAV:stock.transfers", SubNavItemId = 34 },
new Permission { PermissionId = 45, Code = "NAV:stock.adjustments", SubNavItemId = 35 },
new Permission { PermissionId = 46, Code = "NAV:stock.counts", SubNavItemId = 36 },
new Permission { PermissionId = 47, Code = "NAV:stock.reorder-alerts", SubNavItemId = 37 },
new Permission { PermissionId = 48, Code = "NAV:stock.wastage", SubNavItemId = 38 },
// Sales (NavItemId 13) children — SubNavItemId 23 had no permission row before now
// (mirrors SubNavItemConfiguration's rename of that row to sales.bundle-templates).
new Permission { PermissionId = 49, Code = "NAV:sales.bundle-templates", SubNavItemId = 23 },
new Permission { PermissionId = 50, Code = "NAV:sales.invoices", SubNavItemId = 40 },
new Permission { PermissionId = 51, Code = "NAV:sales.free-issues", SubNavItemId = 41 },
new Permission { PermissionId = 52, Code = "NAV:sales.day-end", SubNavItemId = 42 }
);
}
}
@@ -35,10 +35,19 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
new SubNavItem { SubNavItemId = 7, NavItemId = 9, Code = "settings.roles", Label = "Roles", Href = "/dashboard/settings/roles", SortOrder = 1 },
new SubNavItem { SubNavItemId = 8, NavItemId = 9, Code = "settings.users", Label = "Users", Href = "/dashboard/settings/users", SortOrder = 2 },
new SubNavItem { SubNavItemId = 24, NavItemId = 9, Code = "settings.notification-config", Label = "Notification Config", Href = "/dashboard/settings/notification-config", SortOrder = 3 },
new SubNavItem { SubNavItemId = 23, NavItemId = 13, Code = "sales.bundle-sales", Label = "Bundle Sales", Href = "/dashboard/sales/bundles", SortOrder = 1 },
// Sales (NavItemId 13) children — mirror the sidebar order (AppSidebar.tsx). SubNavItemId
// 23 is renamed in place (was "sales.bundle-sales" / Bundle Sales) rather than
// delete+recreate, so any role already granted this permission keeps it; the rest are new.
new SubNavItem { SubNavItemId = 40, NavItemId = 13, Code = "sales.invoices", Label = "Sales", Href = "/dashboard/sales/invoices", SortOrder = 1 },
new SubNavItem { SubNavItemId = 23, NavItemId = 13, Code = "sales.bundle-templates", Label = "Bundle Templates", Href = "/dashboard/sales/bundles/templates", SortOrder = 2 },
new SubNavItem { SubNavItemId = 41, NavItemId = 13, Code = "sales.free-issues", Label = "Free Issues", Href = "/dashboard/sales/free-issues", SortOrder = 3 },
new SubNavItem { SubNavItemId = 42, NavItemId = 13, Code = "sales.day-end", Label = "Day End", Href = "/dashboard/sales/day-end", SortOrder = 4 },
// Procurement (NavItemId 4) children — mirror the hub page order.
// IDs 17-20 (not 9-12): 9-12 were already claimed by the Ledgers sub-items below;
// these procurement rows were never actually migrated into the database before now.
// Vendors (SubNavItemId 30) reuses PermissionId 3, which used to point at the retired
// top-level "vendors" NavItem — so any role already granted this permission keeps it.
new SubNavItem { SubNavItemId = 30, NavItemId = 4, Code = "procurement.vendors", Label = "Vendors", Href = "/dashboard/vendors", SortOrder = 0 },
new SubNavItem { SubNavItemId = 17, NavItemId = 4, Code = "procurement.requisitions", Label = "Requisitions", Href = "/dashboard/procurement/requisitions", SortOrder = 1 },
new SubNavItem { SubNavItemId = 18, NavItemId = 4, Code = "procurement.rfqs", Label = "RFQs", Href = "/dashboard/procurement/rfqs", SortOrder = 2 },
new SubNavItem { SubNavItemId = 19, NavItemId = 4, Code = "procurement.purchase-orders", Label = "Purchase Orders", Href = "/dashboard/procurement/purchase-orders", SortOrder = 3 },
@@ -64,7 +73,18 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
// FixProductionTemplatesSubNavItemIdConflict for the data repair.
new SubNavItem { SubNavItemId = 27, NavItemId = 14, Code = "production.templates", Label = "Templates", Href = "/dashboard/production/templates", SortOrder = 1 },
new SubNavItem { SubNavItemId = 25, NavItemId = 14, Code = "production.runs", Label = "Runs", Href = "/dashboard/production/runs", SortOrder = 2 },
new SubNavItem { SubNavItemId = 26, NavItemId = 14, Code = "production.produced", Label = "Produced", Href = "/dashboard/production/produced", SortOrder = 3 }
new SubNavItem { SubNavItemId = 26, NavItemId = 14, Code = "production.produced", Label = "Produced", Href = "/dashboard/production/produced", SortOrder = 3 },
// Stock (NavItemId 6) children — mirror the sidebar order (AppSidebar.tsx). These rows
// never existed in the database before now, so "stock" is still bypassed frontend-side
// in AppSidebar.tsx until roles are actually granted these permissions.
new SubNavItem { SubNavItemId = 31, NavItemId = 6, Code = "stock.ledger", Label = "Stock Ledger", Href = "/dashboard/stock/ledger", SortOrder = 1 },
new SubNavItem { SubNavItemId = 32, NavItemId = 6, Code = "stock.enquiry", Label = "Stock Enquiry", Href = "/dashboard/stock/enquiry", SortOrder = 2 },
new SubNavItem { SubNavItemId = 33, NavItemId = 6, Code = "stock.valuation", Label = "Valuation", Href = "/dashboard/stock/valuation", SortOrder = 3 },
new SubNavItem { SubNavItemId = 34, NavItemId = 6, Code = "stock.transfers", Label = "Transfers", Href = "/dashboard/stock/transfers", SortOrder = 4 },
new SubNavItem { SubNavItemId = 35, NavItemId = 6, Code = "stock.adjustments", Label = "Adjustments", Href = "/dashboard/stock/adjustments", SortOrder = 5 },
new SubNavItem { SubNavItemId = 36, NavItemId = 6, Code = "stock.counts", Label = "Counts", Href = "/dashboard/stock/counts", SortOrder = 6 },
new SubNavItem { SubNavItemId = 37, NavItemId = 6, Code = "stock.reorder-alerts", Label = "Reorder Alerts", Href = "/dashboard/stock/reorder-alerts", SortOrder = 7 },
new SubNavItem { SubNavItemId = 38, NavItemId = 6, Code = "stock.wastage", Label = "Wastage", Href = "/dashboard/stock/wastage", SortOrder = 8 }
);
}
}
@@ -114,6 +114,7 @@ public class ErpDbContext : DbContext
// --- Cross-cutting: audit trail + GL-ready journal (docs/10 Part C.7) ---
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
public DbSet<JournalEntryStub> JournalEntryStubs => Set<JournalEntryStub>();
public DbSet<LoginActivity> LoginActivities => Set<LoginActivity>();
// --- HRM: org masters (docs/12-BACKEND-HRM.md Part C.1) ---
public DbSet<Branch> Branches => Set<Branch>();