Compare commits

...

2 Commits

Author SHA1 Message Date
3197e68749 user management 2026-03-15 14:47:58 -05:00
857d34397e auditing 2026-03-15 14:11:21 -05:00
30 changed files with 2003 additions and 120 deletions

View File

@@ -23,6 +23,8 @@ MRP Codex is a modular Manufacturing Resource Planning platform intended to be a
- projects with customer/commercial/shipment linkage, owners, due dates, notes, and attachments
- manufacturing work orders with project linkage, station master data, item operation templates, auto-generated work-order operations, and attachments
- planning gantt timelines backed by live project and manufacturing schedule data
- admin diagnostics with runtime footprint, record counts, and persisted audit-trail visibility
- admin user management with account creation, activation, role assignment, and role-permission editing
- Puppeteer PDF foundation
- single-container Docker deployment
@@ -119,8 +121,8 @@ If implementation changes invalidate those docs, update them in the same change
Near-term priorities are:
1. Broader audit-trail coverage and operational diagnostics
2. Code-splitting and bundle-size reduction
1. CRM/shipping audit coverage and richer startup validation
2. Backup/restore workflow documentation and support-oriented admin tooling
When adding new modules, preserve the ability to extend the system without refactoring the existing app shell.

View File

@@ -6,6 +6,10 @@ This file is the running release and change log for MRP Codex. Keep it updated w
### Added
- Admin user-management screen with account creation, activation control, role assignment, and role-permission editing
- Route-level lazy loading and vendor chunking across the client so major operational modules no longer ship in the initial bundle
- Persisted audit events for core settings, inventory, purchasing, sales, project, and manufacturing write actions
- Admin diagnostics page with runtime footprint, storage-path visibility, key record counts, and recent audit activity
- Inventory transfers with paired physical stock movement posting between warehouses and locations
- Manual inventory reservations plus automatic work-order-driven component reservations
- Reserved and available stock visibility on inventory item detail and stock-by-location views
@@ -37,7 +41,9 @@ This file is the running release and change log for MRP Codex. Keep it updated w
- Project detail now surfaces linked work orders and can launch pre-seeded manufacturing records
- Purchase-order detail now links back to the vendor CRM record and supports direct supporting-document management on the PO itself
- Vendor CRM detail now exposes purchasing activity and can launch pre-seeded purchase orders
- Roadmap and project docs now treat broader audit-trail coverage and operational diagnostics as the next active priority after the inventory-control slice
- The client entry bundle now stays lighter by loading major modules on demand instead of importing all route pages eagerly in `main.tsx`
- Company settings now acts as the staging area for admin surfaces while user administration lives on its own dedicated page instead of inside the company-profile form
- Roadmap and project docs now treat CRM/shipping audit coverage and richer startup validation as the next active priority after the user-management slice
## 2026-03-15

View File

@@ -27,6 +27,8 @@ This repository implements the platform foundation milestone:
- projects with customer/commercial/shipment linkage, owners, due dates, notes, attachments, and dashboard visibility
- manufacturing work orders with project linkage, station master data, item operation templates, auto-generated work-order operations, attachments, and dashboard visibility
- planning gantt timelines backed by live project and manufacturing schedule data
- admin diagnostics with runtime footprint, storage visibility, record counts, and recent audit activity
- admin user management with account creation, activation, role assignment, and role-permission editing
- Dockerized single-container deployment
- Puppeteer PDF pipeline foundation
@@ -62,5 +64,5 @@ This repository implements the platform foundation milestone:
## Next roadmap candidates
- broader audit and operations maturity
- code-splitting and bundle-size reduction
- CRM/shipping audit coverage and richer startup validation
- backup/restore workflow depth and support-oriented admin tooling

View File

@@ -26,6 +26,9 @@ Current foundation scope includes:
- projects with customer/commercial/shipment linkage, owners, due dates, notes, and attachments
- manufacturing work orders with project linkage, station-based operation templates, material issue posting, completion posting, and work-order attachments
- planning gantt timelines with live project and manufacturing schedule data
- admin diagnostics with runtime footprint, record counts, and recent audit-trail visibility
- admin user management with account creation, activation, role assignment, and role-permission editing
- route-level code-splitting and vendor chunking for lighter initial client loads
- file storage and PDF rendering
## Product Map
@@ -40,19 +43,20 @@ Current completed foundation areas:
- projects foundation
- manufacturing foundation
- planning foundation
- audit and diagnostics foundation
- user and role administration foundation
- branding, attachments, auth/RBAC, and PDF infrastructure
Near-term priorities:
1. Broader audit-trail coverage and operational diagnostics
2. Code-splitting and bundle-size reduction
1. CRM/shipping audit coverage and richer startup validation
2. Backup/restore workflow documentation and support-oriented admin tooling
Revisit / deferred items:
- local Windows Prisma migration reliability
- frontend code-splitting and bundle-size reduction
- inventory transfers, reservations, and deeper stock controls
- deeper audit-trail coverage
- deeper support diagnostics and startup validation
- backup/restore workflow depth and support tooling
Dashboard direction:
@@ -337,13 +341,28 @@ As of March 14, 2026, the latest committed domain migrations include:
Recent roadmap-driving migrations should always be applied before validating new CRM, inventory, sales, shipping, or purchasing features in a running environment.
## Audit And Diagnostics
The current admin operations slice supports:
- persisted audit events for core settings, inventory, purchasing, sales, project, and manufacturing write actions
- an admin diagnostics page for runtime footprint, data/storage path visibility, key record counts, and recent audit activity
- a dedicated user-management page for account creation, activation, role assignment, password reset-style updates, and role-permission administration
- operator-facing review of recent high-impact changes without direct database access
Current follow-up direction:
- deeper audit coverage across CRM and shipping mutations
- richer environment validation and startup diagnostics
- backup/restore workflow guidance and support-oriented admin tooling
## UI Notes
- Dark mode persistence is handled through the frontend theme provider and should remain stable across page navigation.
- The shell layout is tuned for wider desktop use than the original foundation build, and now exposes Dashboard, CRM, inventory, sales, shipping, projects, manufacturing, settings, and planning modules from the same app shell.
- The active module screens now follow a tighter density baseline for forms, tables, and detail cards.
- The dashboard should continue evolving as a modular metric board for future purchasing, shipping, planning, and audit data.
- The client build still emits a Vite chunk-size warning because the app has not been code-split yet.
- The client now ships with route-level lazy loading and vendor chunking, so future frontend work should preserve that split instead of re-centralizing module imports in `main.tsx`.
## PDF Generation

View File

@@ -47,6 +47,10 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni
- Manufacturing stations, item routing templates, and automatic work-order operation planning for gantt scheduling
- Vendor invoice/supporting-document attachments directly on purchase orders
- Vendor-detail purchasing visibility with recent purchase-order activity
- Audit trail coverage across core settings, inventory, purchasing, project, sales, and manufacturing write flows
- Admin diagnostics screen with runtime footprint, record counts, storage-path visibility, and recent audit activity
- Dedicated user-management screen for account creation, activation, role assignment, and role-permission editing
- Route-level frontend code-splitting and vendor chunking to keep the initial client payload lighter
- SKU-searchable BOM component selection for inventory-scale datasets
- Theme persistence fixes and denser responsive workspace layouts
- Full-site density normalization pass across active CRM, inventory, settings, dashboard, and login screens
@@ -58,7 +62,6 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni
### Current known gaps in the foundation
- Prisma migration execution is committed and documented, but local Windows Node 24 schema-engine behavior remains inconsistent; use Node 22 or Docker for migration execution
- The frontend bundle is functional but should be code-split later, especially around the gantt module
- CRM reporting is now functional, but broader account-role depth and downstream document rollups can still evolve later
- The current sales/purchasing/shipping foundation now includes sales approvals and revision history, but still needs vendor exception handling, deeper carrier integration, and richer document comparison tooling
- The dashboard is now live-data driven, but still needs richer KPI widgets, alerts, recent-activity queues, and exception reporting as more transactional depth is added
@@ -247,6 +250,12 @@ QOL subfeatures:
### Phase 8: Security, audit, and operations maturity
Foundation slice shipped:
- Audit trail coverage across core write flows for settings, inventory, sales, purchasing, projects, and manufacturing
- Admin diagnostics screen for runtime footprint, storage visibility, key record counts, and recent audit activity
- Expanded role-management UI with account creation, activation, role assignment, and permission administration
- Expanded role management UI
- Permission assignment administration
- Audit trail coverage across critical records
@@ -265,10 +274,7 @@ QOL subfeatures:
## Revisit / Deferred Items
- Local Windows Prisma migration reliability still needs a cleaner documented workflow or tooling wrapper
- Frontend bundle splitting is still deferred; the Vite chunk-size warning remains
- Inventory transactions exist, but transfers, reservations, and more advanced stock controls still need follow-up
- CRM document rollups and broader account-role depth were deferred until more downstream modules exist
- Audit-trail depth is still thin outside the current record/update flows
- Some generated document and workflow screens still need additional polish for dense, keyboard-efficient operational use
- Dashboard cards now use live data, but richer recent-activity widgets and exception queues are still deferred
@@ -283,5 +289,5 @@ QOL subfeatures:
## Near-term priority order
1. Broader audit-trail coverage and operational diagnostics
2. Code-splitting and bundle-size reduction
1. CRM/shipping audit coverage plus richer startup validation
2. Backup/restore workflow documentation and support-oriented admin tooling

View File

@@ -1,4 +1,10 @@
import type {
AdminDiagnosticsDto,
AdminPermissionOptionDto,
AdminRoleDto,
AdminRoleInput,
AdminUserDto,
AdminUserInput,
ApiResponse,
CompanyProfileDto,
CompanyProfileInput,
@@ -127,6 +133,30 @@ export const api = {
me(token: string) {
return request<LoginResponse["user"]>("/api/v1/auth/me", undefined, token);
},
getAdminDiagnostics(token: string) {
return request<AdminDiagnosticsDto>("/api/v1/admin/diagnostics", undefined, token);
},
getAdminPermissions(token: string) {
return request<AdminPermissionOptionDto[]>("/api/v1/admin/permissions", undefined, token);
},
getAdminRoles(token: string) {
return request<AdminRoleDto[]>("/api/v1/admin/roles", undefined, token);
},
createAdminRole(token: string, payload: AdminRoleInput) {
return request<AdminRoleDto>("/api/v1/admin/roles", { method: "POST", body: JSON.stringify(payload) }, token);
},
updateAdminRole(token: string, roleId: string, payload: AdminRoleInput) {
return request<AdminRoleDto>(`/api/v1/admin/roles/${roleId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
},
getAdminUsers(token: string) {
return request<AdminUserDto[]>("/api/v1/admin/users", undefined, token);
},
createAdminUser(token: string, payload: AdminUserInput) {
return request<AdminUserDto>("/api/v1/admin/users", { method: "POST", body: JSON.stringify(payload) }, token);
},
updateAdminUser(token: string, userId: string, payload: AdminUserInput) {
return request<AdminUserDto>(`/api/v1/admin/users/${userId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
},
getCompanyProfile(token: string) {
return request<CompanyProfileDto>("/api/v1/company-profile", undefined, token);
},

View File

@@ -9,38 +9,111 @@ import { ProtectedRoute } from "./components/ProtectedRoute";
import { AuthProvider } from "./auth/AuthProvider";
import { DashboardPage } from "./modules/dashboard/DashboardPage";
import { LoginPage } from "./modules/login/LoginPage";
import { CompanySettingsPage } from "./modules/settings/CompanySettingsPage";
import { CrmDetailPage } from "./modules/crm/CrmDetailPage";
import { CrmFormPage } from "./modules/crm/CrmFormPage";
import { CustomersPage } from "./modules/crm/CustomersPage";
import { VendorsPage } from "./modules/crm/VendorsPage";
import { GanttPage } from "./modules/gantt/GanttPage";
import { InventoryDetailPage } from "./modules/inventory/InventoryDetailPage";
import { InventoryFormPage } from "./modules/inventory/InventoryFormPage";
import { InventoryItemsPage } from "./modules/inventory/InventoryItemsPage";
import { ManufacturingPage } from "./modules/manufacturing/ManufacturingPage";
import { WorkOrderDetailPage } from "./modules/manufacturing/WorkOrderDetailPage";
import { WorkOrderFormPage } from "./modules/manufacturing/WorkOrderFormPage";
import { PurchaseDetailPage } from "./modules/purchasing/PurchaseDetailPage";
import { PurchaseFormPage } from "./modules/purchasing/PurchaseFormPage";
import { PurchaseListPage } from "./modules/purchasing/PurchaseListPage";
import { ProjectDetailPage } from "./modules/projects/ProjectDetailPage";
import { ProjectFormPage } from "./modules/projects/ProjectFormPage";
import { ProjectsPage } from "./modules/projects/ProjectsPage";
import { WarehouseDetailPage } from "./modules/inventory/WarehouseDetailPage";
import { WarehouseFormPage } from "./modules/inventory/WarehouseFormPage";
import { WarehousesPage } from "./modules/inventory/WarehousesPage";
import { SalesDetailPage } from "./modules/sales/SalesDetailPage";
import { SalesFormPage } from "./modules/sales/SalesFormPage";
import { SalesListPage } from "./modules/sales/SalesListPage";
import { ShipmentDetailPage } from "./modules/shipping/ShipmentDetailPage";
import { ShipmentFormPage } from "./modules/shipping/ShipmentFormPage";
import { ShipmentListPage } from "./modules/shipping/ShipmentListPage";
import { ThemeProvider } from "./theme/ThemeProvider";
import "./index.css";
const queryClient = new QueryClient();
const CompanySettingsPage = React.lazy(() =>
import("./modules/settings/CompanySettingsPage").then((module) => ({ default: module.CompanySettingsPage }))
);
const AdminDiagnosticsPage = React.lazy(() =>
import("./modules/settings/AdminDiagnosticsPage").then((module) => ({ default: module.AdminDiagnosticsPage }))
);
const UserManagementPage = React.lazy(() =>
import("./modules/settings/UserManagementPage").then((module) => ({ default: module.UserManagementPage }))
);
const CustomersPage = React.lazy(() =>
import("./modules/crm/CustomersPage").then((module) => ({ default: module.CustomersPage }))
);
const VendorsPage = React.lazy(() =>
import("./modules/crm/VendorsPage").then((module) => ({ default: module.VendorsPage }))
);
const CrmDetailPage = React.lazy(() =>
import("./modules/crm/CrmDetailPage").then((module) => ({ default: module.CrmDetailPage }))
);
const CrmFormPage = React.lazy(() =>
import("./modules/crm/CrmFormPage").then((module) => ({ default: module.CrmFormPage }))
);
const InventoryItemsPage = React.lazy(() =>
import("./modules/inventory/InventoryItemsPage").then((module) => ({ default: module.InventoryItemsPage }))
);
const InventoryDetailPage = React.lazy(() =>
import("./modules/inventory/InventoryDetailPage").then((module) => ({ default: module.InventoryDetailPage }))
);
const InventoryFormPage = React.lazy(() =>
import("./modules/inventory/InventoryFormPage").then((module) => ({ default: module.InventoryFormPage }))
);
const WarehousesPage = React.lazy(() =>
import("./modules/inventory/WarehousesPage").then((module) => ({ default: module.WarehousesPage }))
);
const WarehouseDetailPage = React.lazy(() =>
import("./modules/inventory/WarehouseDetailPage").then((module) => ({ default: module.WarehouseDetailPage }))
);
const WarehouseFormPage = React.lazy(() =>
import("./modules/inventory/WarehouseFormPage").then((module) => ({ default: module.WarehouseFormPage }))
);
const ProjectsPage = React.lazy(() =>
import("./modules/projects/ProjectsPage").then((module) => ({ default: module.ProjectsPage }))
);
const ProjectDetailPage = React.lazy(() =>
import("./modules/projects/ProjectDetailPage").then((module) => ({ default: module.ProjectDetailPage }))
);
const ProjectFormPage = React.lazy(() =>
import("./modules/projects/ProjectFormPage").then((module) => ({ default: module.ProjectFormPage }))
);
const ManufacturingPage = React.lazy(() =>
import("./modules/manufacturing/ManufacturingPage").then((module) => ({ default: module.ManufacturingPage }))
);
const WorkOrderDetailPage = React.lazy(() =>
import("./modules/manufacturing/WorkOrderDetailPage").then((module) => ({ default: module.WorkOrderDetailPage }))
);
const WorkOrderFormPage = React.lazy(() =>
import("./modules/manufacturing/WorkOrderFormPage").then((module) => ({ default: module.WorkOrderFormPage }))
);
const PurchaseListPage = React.lazy(() =>
import("./modules/purchasing/PurchaseListPage").then((module) => ({ default: module.PurchaseListPage }))
);
const PurchaseDetailPage = React.lazy(() =>
import("./modules/purchasing/PurchaseDetailPage").then((module) => ({ default: module.PurchaseDetailPage }))
);
const PurchaseFormPage = React.lazy(() =>
import("./modules/purchasing/PurchaseFormPage").then((module) => ({ default: module.PurchaseFormPage }))
);
const SalesListPage = React.lazy(() =>
import("./modules/sales/SalesListPage").then((module) => ({ default: module.SalesListPage }))
);
const SalesDetailPage = React.lazy(() =>
import("./modules/sales/SalesDetailPage").then((module) => ({ default: module.SalesDetailPage }))
);
const SalesFormPage = React.lazy(() =>
import("./modules/sales/SalesFormPage").then((module) => ({ default: module.SalesFormPage }))
);
const ShipmentListPage = React.lazy(() =>
import("./modules/shipping/ShipmentListPage").then((module) => ({ default: module.ShipmentListPage }))
);
const ShipmentDetailPage = React.lazy(() =>
import("./modules/shipping/ShipmentDetailPage").then((module) => ({ default: module.ShipmentDetailPage }))
);
const ShipmentFormPage = React.lazy(() =>
import("./modules/shipping/ShipmentFormPage").then((module) => ({ default: module.ShipmentFormPage }))
);
const GanttPage = React.lazy(() =>
import("./modules/gantt/GanttPage").then((module) => ({ default: module.GanttPage }))
);
function RouteFallback() {
return (
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">
Loading module...
</div>
);
}
function lazyElement(element: React.ReactNode) {
return <React.Suspense fallback={<RouteFallback />}>{element}</React.Suspense>;
}
const router = createBrowserRouter([
{ path: "/login", element: <LoginPage /> },
{
@@ -52,121 +125,128 @@ const router = createBrowserRouter([
{ path: "/", element: <DashboardPage /> },
{
element: <ProtectedRoute requiredPermissions={[permissions.companyRead]} />,
children: [{ path: "/settings/company", element: <CompanySettingsPage /> }],
children: [{ path: "/settings/company", element: lazyElement(<CompanySettingsPage />) }],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.adminManage]} />,
children: [
{ path: "/settings/admin-diagnostics", element: lazyElement(<AdminDiagnosticsPage />) },
{ path: "/settings/users", element: lazyElement(<UserManagementPage />) },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.crmRead]} />,
children: [
{ path: "/crm/customers", element: <CustomersPage /> },
{ path: "/crm/customers/:customerId", element: <CrmDetailPage entity="customer" /> },
{ path: "/crm/vendors", element: <VendorsPage /> },
{ path: "/crm/vendors/:vendorId", element: <CrmDetailPage entity="vendor" /> },
{ path: "/crm/customers", element: lazyElement(<CustomersPage />) },
{ path: "/crm/customers/:customerId", element: lazyElement(<CrmDetailPage entity="customer" />) },
{ path: "/crm/vendors", element: lazyElement(<VendorsPage />) },
{ path: "/crm/vendors/:vendorId", element: lazyElement(<CrmDetailPage entity="vendor" />) },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.inventoryRead]} />,
children: [
{ path: "/inventory/items", element: <InventoryItemsPage /> },
{ path: "/inventory/items/:itemId", element: <InventoryDetailPage /> },
{ path: "/inventory/warehouses", element: <WarehousesPage /> },
{ path: "/inventory/warehouses/:warehouseId", element: <WarehouseDetailPage /> },
{ path: "/inventory/items", element: lazyElement(<InventoryItemsPage />) },
{ path: "/inventory/items/:itemId", element: lazyElement(<InventoryDetailPage />) },
{ path: "/inventory/warehouses", element: lazyElement(<WarehousesPage />) },
{ path: "/inventory/warehouses/:warehouseId", element: lazyElement(<WarehouseDetailPage />) },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.projectsRead]} />,
children: [
{ path: "/projects", element: <ProjectsPage /> },
{ path: "/projects/:projectId", element: <ProjectDetailPage /> },
{ path: "/projects", element: lazyElement(<ProjectsPage />) },
{ path: "/projects/:projectId", element: lazyElement(<ProjectDetailPage />) },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.manufacturingRead]} />,
children: [
{ path: "/manufacturing/work-orders", element: <ManufacturingPage /> },
{ path: "/manufacturing/work-orders/:workOrderId", element: <WorkOrderDetailPage /> },
{ path: "/manufacturing/work-orders", element: lazyElement(<ManufacturingPage />) },
{ path: "/manufacturing/work-orders/:workOrderId", element: lazyElement(<WorkOrderDetailPage />) },
],
},
{
element: <ProtectedRoute requiredPermissions={["purchasing.read"]} />,
children: [
{ path: "/purchasing/orders", element: <PurchaseListPage /> },
{ path: "/purchasing/orders/:orderId", element: <PurchaseDetailPage /> },
{ path: "/purchasing/orders", element: lazyElement(<PurchaseListPage />) },
{ path: "/purchasing/orders/:orderId", element: lazyElement(<PurchaseDetailPage />) },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.salesRead]} />,
children: [
{ path: "/sales/quotes", element: <SalesListPage entity="quote" /> },
{ path: "/sales/quotes/:quoteId", element: <SalesDetailPage entity="quote" /> },
{ path: "/sales/orders", element: <SalesListPage entity="order" /> },
{ path: "/sales/orders/:orderId", element: <SalesDetailPage entity="order" /> },
{ path: "/sales/quotes", element: lazyElement(<SalesListPage entity="quote" />) },
{ path: "/sales/quotes/:quoteId", element: lazyElement(<SalesDetailPage entity="quote" />) },
{ path: "/sales/orders", element: lazyElement(<SalesListPage entity="order" />) },
{ path: "/sales/orders/:orderId", element: lazyElement(<SalesDetailPage entity="order" />) },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.shippingRead]} />,
children: [
{ path: "/shipping/shipments", element: <ShipmentListPage /> },
{ path: "/shipping/shipments/:shipmentId", element: <ShipmentDetailPage /> },
{ path: "/shipping/shipments", element: lazyElement(<ShipmentListPage />) },
{ path: "/shipping/shipments/:shipmentId", element: lazyElement(<ShipmentDetailPage />) },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.crmWrite]} />,
children: [
{ path: "/crm/customers/new", element: <CrmFormPage entity="customer" mode="create" /> },
{ path: "/crm/customers/:customerId/edit", element: <CrmFormPage entity="customer" mode="edit" /> },
{ path: "/crm/vendors/new", element: <CrmFormPage entity="vendor" mode="create" /> },
{ path: "/crm/vendors/:vendorId/edit", element: <CrmFormPage entity="vendor" mode="edit" /> },
{ path: "/crm/customers/new", element: lazyElement(<CrmFormPage entity="customer" mode="create" />) },
{ path: "/crm/customers/:customerId/edit", element: lazyElement(<CrmFormPage entity="customer" mode="edit" />) },
{ path: "/crm/vendors/new", element: lazyElement(<CrmFormPage entity="vendor" mode="create" />) },
{ path: "/crm/vendors/:vendorId/edit", element: lazyElement(<CrmFormPage entity="vendor" mode="edit" />) },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.projectsWrite]} />,
children: [
{ path: "/projects/new", element: <ProjectFormPage mode="create" /> },
{ path: "/projects/:projectId/edit", element: <ProjectFormPage mode="edit" /> },
{ path: "/projects/new", element: lazyElement(<ProjectFormPage mode="create" />) },
{ path: "/projects/:projectId/edit", element: lazyElement(<ProjectFormPage mode="edit" />) },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.manufacturingWrite]} />,
children: [
{ path: "/manufacturing/work-orders/new", element: <WorkOrderFormPage mode="create" /> },
{ path: "/manufacturing/work-orders/:workOrderId/edit", element: <WorkOrderFormPage mode="edit" /> },
{ path: "/manufacturing/work-orders/new", element: lazyElement(<WorkOrderFormPage mode="create" />) },
{ path: "/manufacturing/work-orders/:workOrderId/edit", element: lazyElement(<WorkOrderFormPage mode="edit" />) },
],
},
{
element: <ProtectedRoute requiredPermissions={["purchasing.write"]} />,
children: [
{ path: "/purchasing/orders/new", element: <PurchaseFormPage mode="create" /> },
{ path: "/purchasing/orders/:orderId/edit", element: <PurchaseFormPage mode="edit" /> },
{ path: "/purchasing/orders/new", element: lazyElement(<PurchaseFormPage mode="create" />) },
{ path: "/purchasing/orders/:orderId/edit", element: lazyElement(<PurchaseFormPage mode="edit" />) },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.salesWrite]} />,
children: [
{ path: "/sales/quotes/new", element: <SalesFormPage entity="quote" mode="create" /> },
{ path: "/sales/quotes/:quoteId/edit", element: <SalesFormPage entity="quote" mode="edit" /> },
{ path: "/sales/orders/new", element: <SalesFormPage entity="order" mode="create" /> },
{ path: "/sales/orders/:orderId/edit", element: <SalesFormPage entity="order" mode="edit" /> },
{ path: "/sales/quotes/new", element: lazyElement(<SalesFormPage entity="quote" mode="create" />) },
{ path: "/sales/quotes/:quoteId/edit", element: lazyElement(<SalesFormPage entity="quote" mode="edit" />) },
{ path: "/sales/orders/new", element: lazyElement(<SalesFormPage entity="order" mode="create" />) },
{ path: "/sales/orders/:orderId/edit", element: lazyElement(<SalesFormPage entity="order" mode="edit" />) },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.shippingWrite]} />,
children: [
{ path: "/shipping/shipments/new", element: <ShipmentFormPage mode="create" /> },
{ path: "/shipping/shipments/:shipmentId/edit", element: <ShipmentFormPage mode="edit" /> },
{ path: "/shipping/shipments/new", element: lazyElement(<ShipmentFormPage mode="create" />) },
{ path: "/shipping/shipments/:shipmentId/edit", element: lazyElement(<ShipmentFormPage mode="edit" />) },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.inventoryWrite]} />,
children: [
{ path: "/inventory/items/new", element: <InventoryFormPage mode="create" /> },
{ path: "/inventory/items/:itemId/edit", element: <InventoryFormPage mode="edit" /> },
{ path: "/inventory/warehouses/new", element: <WarehouseFormPage mode="create" /> },
{ path: "/inventory/warehouses/:warehouseId/edit", element: <WarehouseFormPage mode="edit" /> },
{ path: "/inventory/items/new", element: lazyElement(<InventoryFormPage mode="create" />) },
{ path: "/inventory/items/:itemId/edit", element: lazyElement(<InventoryFormPage mode="edit" />) },
{ path: "/inventory/warehouses/new", element: lazyElement(<WarehouseFormPage mode="create" />) },
{ path: "/inventory/warehouses/:warehouseId/edit", element: lazyElement(<WarehouseFormPage mode="edit" />) },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.ganttRead]} />,
children: [{ path: "/planning/gantt", element: <GanttPage /> }],
children: [{ path: "/planning/gantt", element: lazyElement(<GanttPage />) }],
},
],
},

View File

@@ -0,0 +1,172 @@
import type { AdminDiagnosticsDto } from "@mrp/shared";
import { Link } from "react-router-dom";
import { useEffect, useState } from "react";
import { useAuth } from "../../auth/AuthProvider";
import { api } from "../../lib/api";
function formatDateTime(value: string) {
return new Date(value).toLocaleString();
}
function parseMetadata(metadataJson: string) {
try {
return JSON.parse(metadataJson) as Record<string, unknown>;
} catch {
return {};
}
}
export function AdminDiagnosticsPage() {
const { token } = useAuth();
const [diagnostics, setDiagnostics] = useState<AdminDiagnosticsDto | null>(null);
const [status, setStatus] = useState("Loading diagnostics...");
useEffect(() => {
if (!token) {
return;
}
let active = true;
api
.getAdminDiagnostics(token)
.then((nextDiagnostics) => {
if (!active) {
return;
}
setDiagnostics(nextDiagnostics);
setStatus("Diagnostics loaded.");
})
.catch((error: Error) => {
if (!active) {
return;
}
setStatus(error.message || "Unable to load diagnostics.");
});
return () => {
active = false;
};
}, [token]);
if (!diagnostics) {
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
}
const summaryCards = [
["Server time", formatDateTime(diagnostics.serverTime)],
["Node runtime", diagnostics.nodeVersion],
["Audit events", diagnostics.auditEventCount.toString()],
["Active users", `${diagnostics.activeUserCount} / ${diagnostics.userCount}`],
["Sales docs", diagnostics.salesDocumentCount.toString()],
["Work orders", diagnostics.workOrderCount.toString()],
["Projects", diagnostics.projectCount.toString()],
["Attachments", diagnostics.attachmentCount.toString()],
];
const footprintCards = [
["Database URL", diagnostics.databaseUrl],
["Data directory", diagnostics.dataDir],
["Uploads directory", diagnostics.uploadsDir],
["Client origin", diagnostics.clientOrigin],
["Company profile", diagnostics.companyProfilePresent ? "Present" : "Missing"],
["Roles / permissions", `${diagnostics.roleCount} / ${diagnostics.permissionCount}`],
["Customers / vendors", `${diagnostics.customerCount} / ${diagnostics.vendorCount}`],
["Inventory / warehouses", `${diagnostics.inventoryItemCount} / ${diagnostics.warehouseCount}`],
["Purchase orders", diagnostics.purchaseOrderCount.toString()],
["Shipments", diagnostics.shipmentCount.toString()],
];
return (
<div className="space-y-6">
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Admin Diagnostics</p>
<h3 className="mt-2 text-lg font-bold text-text">Operational runtime and audit visibility</h3>
<p className="mt-2 max-w-3xl text-sm text-muted">
This view surfaces environment footprint, record counts, and recent change activity so admin review does not require direct database access.
</p>
</div>
<div className="flex flex-wrap gap-3">
<Link to="/settings/users" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
User management
</Link>
<Link to="/settings/company" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
Company settings
</Link>
</div>
</div>
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{summaryCards.map(([label, value]) => (
<div key={label} className="rounded-3xl border border-line/70 bg-page/70 p-4">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted">{label}</p>
<p className="mt-3 text-lg font-bold text-text">{value}</p>
</div>
))}
</div>
</section>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">System Footprint</p>
<div className="mt-5 grid gap-3 xl:grid-cols-2">
{footprintCards.map(([label, value]) => (
<div key={label} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{label}</p>
<p className="mt-2 break-all text-sm text-text">{value}</p>
</div>
))}
</div>
</section>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Recent Audit Trail</p>
<h3 className="mt-2 text-lg font-bold text-text">Latest cross-module write activity</h3>
</div>
<p className="text-sm text-muted">{status}</p>
</div>
<div className="mt-5 overflow-x-auto">
<table className="min-w-full divide-y divide-line/70 text-sm">
<thead>
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
<th className="px-3 py-3">When</th>
<th className="px-3 py-3">Actor</th>
<th className="px-3 py-3">Entity</th>
<th className="px-3 py-3">Action</th>
<th className="px-3 py-3">Summary</th>
<th className="px-3 py-3">Metadata</th>
</tr>
</thead>
<tbody className="divide-y divide-line/70">
{diagnostics.recentAuditEvents.map((event) => {
const metadata = parseMetadata(event.metadataJson);
return (
<tr key={event.id} className="align-top">
<td className="px-3 py-3 text-muted">{formatDateTime(event.createdAt)}</td>
<td className="px-3 py-3 text-text">{event.actorName ?? "System"}</td>
<td className="px-3 py-3 text-text">
<div>{event.entityType}</div>
{event.entityId ? <div className="text-xs text-muted">{event.entityId}</div> : null}
</td>
<td className="px-3 py-3">
<span className="rounded-full bg-page px-2 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-text">
{event.action}
</span>
</td>
<td className="px-3 py-3 text-text">{event.summary}</td>
<td className="px-3 py-3 text-xs text-muted">
{Object.keys(metadata).length > 0 ? JSON.stringify(metadata) : "No metadata"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import type { CompanyProfileInput } from "@mrp/shared";
import { Link } from "react-router-dom";
import { useEffect, useState } from "react";
import { useAuth } from "../../auth/AuthProvider";
@@ -6,7 +7,7 @@ import { api } from "../../lib/api";
import { useTheme } from "../../theme/ThemeProvider";
export function CompanySettingsPage() {
const { token } = useAuth();
const { token, user } = useAuth();
const { applyBrandProfile } = useTheme();
const [form, setForm] = useState<CompanyProfileInput | null>(null);
const [companyId, setCompanyId] = useState<string | null>(null);
@@ -145,6 +146,25 @@ export function CompanySettingsPage() {
return (
<form className="space-y-6" onSubmit={handleSave}>
{user?.permissions.includes("admin.manage") ? (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Admin</p>
<h3 className="mt-2 text-lg font-bold text-text">Admin access and diagnostics</h3>
<p className="mt-2 text-sm text-muted">Manage users, roles, and system diagnostics from the linked admin surfaces.</p>
</div>
<div className="flex flex-wrap gap-3">
<Link to="/settings/users" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
User management
</Link>
<Link to="/settings/admin-diagnostics" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
Open diagnostics
</Link>
</div>
</div>
</section>
) : null}
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
<div>

View File

@@ -0,0 +1,363 @@
import type { AdminPermissionOptionDto, AdminRoleDto, AdminRoleInput, AdminUserDto, AdminUserInput } from "@mrp/shared";
import { Link } from "react-router-dom";
import { useEffect, useState } from "react";
import { useAuth } from "../../auth/AuthProvider";
import { api } from "../../lib/api";
const emptyUserForm: AdminUserInput = {
email: "",
firstName: "",
lastName: "",
isActive: true,
roleIds: [],
password: "",
};
const emptyRoleForm: AdminRoleInput = {
name: "",
description: "",
permissionKeys: [],
};
export function UserManagementPage() {
const { token } = useAuth();
const [users, setUsers] = useState<AdminUserDto[]>([]);
const [roles, setRoles] = useState<AdminRoleDto[]>([]);
const [permissions, setPermissions] = useState<AdminPermissionOptionDto[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string>("new");
const [selectedRoleId, setSelectedRoleId] = useState<string>("new");
const [userForm, setUserForm] = useState<AdminUserInput>(emptyUserForm);
const [roleForm, setRoleForm] = useState<AdminRoleInput>(emptyRoleForm);
const [status, setStatus] = useState("Loading admin access controls...");
useEffect(() => {
if (!token) {
return;
}
let active = true;
Promise.all([api.getAdminUsers(token), api.getAdminRoles(token), api.getAdminPermissions(token)])
.then(([nextUsers, nextRoles, nextPermissions]) => {
if (!active) {
return;
}
setUsers(nextUsers);
setRoles(nextRoles);
setPermissions(nextPermissions);
setStatus("User management loaded.");
})
.catch((error: Error) => {
if (!active) {
return;
}
setStatus(error.message || "Unable to load admin access controls.");
});
return () => {
active = false;
};
}, [token]);
useEffect(() => {
if (selectedUserId === "new") {
setUserForm(emptyUserForm);
return;
}
const selectedUser = users.find((user) => user.id === selectedUserId);
if (!selectedUser) {
setUserForm(emptyUserForm);
return;
}
setUserForm({
email: selectedUser.email,
firstName: selectedUser.firstName,
lastName: selectedUser.lastName,
isActive: selectedUser.isActive,
roleIds: selectedUser.roleIds,
password: "",
});
}, [selectedUserId, users]);
useEffect(() => {
if (selectedRoleId === "new") {
setRoleForm(emptyRoleForm);
return;
}
const selectedRole = roles.find((role) => role.id === selectedRoleId);
if (!selectedRole) {
setRoleForm(emptyRoleForm);
return;
}
setRoleForm({
name: selectedRole.name,
description: selectedRole.description,
permissionKeys: selectedRole.permissionKeys,
});
}, [roles, selectedRoleId]);
if (!token) {
return null;
}
const authToken = token;
async function refreshData(nextStatus: string) {
const [nextUsers, nextRoles, nextPermissions] = await Promise.all([
api.getAdminUsers(authToken),
api.getAdminRoles(authToken),
api.getAdminPermissions(authToken),
]);
setUsers(nextUsers);
setRoles(nextRoles);
setPermissions(nextPermissions);
setStatus(nextStatus);
}
async function handleUserSave(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (selectedUserId === "new") {
const createdUser = await api.createAdminUser(authToken, userForm);
await refreshData(`Created user ${createdUser.email}.`);
setSelectedUserId(createdUser.id);
return;
}
const updatedUser = await api.updateAdminUser(authToken, selectedUserId, userForm);
await refreshData(`Updated user ${updatedUser.email}.`);
setSelectedUserId(updatedUser.id);
}
async function handleRoleSave(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (selectedRoleId === "new") {
const createdRole = await api.createAdminRole(authToken, roleForm);
await refreshData(`Created role ${createdRole.name}.`);
setSelectedRoleId(createdRole.id);
return;
}
const updatedRole = await api.updateAdminRole(authToken, selectedRoleId, roleForm);
await refreshData(`Updated role ${updatedRole.name}.`);
setSelectedRoleId(updatedRole.id);
}
function toggleUserRole(roleId: string) {
setUserForm((current) => ({
...current,
roleIds: current.roleIds.includes(roleId)
? current.roleIds.filter((currentRoleId) => currentRoleId !== roleId)
: [...current.roleIds, roleId],
}));
}
function toggleRolePermission(permissionKey: string) {
setRoleForm((current) => ({
...current,
permissionKeys: current.permissionKeys.includes(permissionKey)
? current.permissionKeys.filter((currentPermissionKey) => currentPermissionKey !== permissionKey)
: [...current.permissionKeys, permissionKey],
}));
}
return (
<div className="space-y-6">
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">User Management</p>
<h3 className="mt-2 text-lg font-bold text-text">Accounts, roles, and permission assignment</h3>
<p className="mt-2 max-w-3xl text-sm text-muted">
Manage user accounts and the role-permission model from one admin surface so onboarding and access control stay tied together.
</p>
</div>
<div className="flex flex-wrap gap-3">
<Link to="/settings/company" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
Company settings
</Link>
<Link to="/settings/admin-diagnostics" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
Diagnostics
</Link>
</div>
</div>
</section>
<section className="grid gap-6 xl:grid-cols-2">
<form className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5" onSubmit={handleUserSave}>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Users</p>
<h3 className="mt-2 text-lg font-bold text-text">Account generation and role assignment</h3>
</div>
<select
value={selectedUserId}
onChange={(event) => setSelectedUserId(event.target.value)}
className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
>
<option value="new">New user</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.firstName} {user.lastName} ({user.email})
</option>
))}
</select>
</div>
<div className="mt-5 grid gap-4 md:grid-cols-2">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Email</span>
<input
value={userForm.email}
onChange={(event) => setUserForm((current) => ({ ...current, email: event.target.value }))}
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Password</span>
<input
type="password"
value={userForm.password ?? ""}
onChange={(event) => setUserForm((current) => ({ ...current, password: event.target.value }))}
placeholder={selectedUserId === "new" ? "Required for new user" : "Leave blank to keep current password"}
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">First name</span>
<input
value={userForm.firstName}
onChange={(event) => setUserForm((current) => ({ ...current, firstName: event.target.value }))}
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Last name</span>
<input
value={userForm.lastName}
onChange={(event) => setUserForm((current) => ({ ...current, lastName: event.target.value }))}
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
/>
</label>
</div>
<label className="mt-4 flex items-center gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-text">
<input
type="checkbox"
checked={userForm.isActive}
onChange={(event) => setUserForm((current) => ({ ...current, isActive: event.target.checked }))}
/>
User can sign in
</label>
<div className="mt-5">
<p className="text-sm font-semibold text-text">Assigned roles</p>
<div className="mt-3 grid gap-3">
{roles.map((role) => (
<label key={role.id} className="flex items-start gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-text">
<input
type="checkbox"
checked={userForm.roleIds.includes(role.id)}
onChange={() => toggleUserRole(role.id)}
/>
<span>
<span className="block font-semibold">{role.name}</span>
<span className="block text-xs text-muted">{role.description || "No description"}</span>
</span>
</label>
))}
</div>
</div>
<div className="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<span className="text-sm text-muted">{status}</span>
<button type="submit" className="rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white">
{selectedUserId === "new" ? "Create user" : "Save user"}
</button>
</div>
</form>
<form className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5" onSubmit={handleRoleSave}>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Roles</p>
<h3 className="mt-2 text-lg font-bold text-text">Permission assignment administration</h3>
</div>
<select
value={selectedRoleId}
onChange={(event) => setSelectedRoleId(event.target.value)}
className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
>
<option value="new">New role</option>
{roles.map((role) => (
<option key={role.id} value={role.id}>
{role.name}
</option>
))}
</select>
</div>
<div className="mt-5 grid gap-4">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Role name</span>
<input
value={roleForm.name}
onChange={(event) => setRoleForm((current) => ({ ...current, name: event.target.value }))}
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Description</span>
<textarea
value={roleForm.description}
onChange={(event) => setRoleForm((current) => ({ ...current, description: event.target.value }))}
rows={3}
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
/>
</label>
</div>
<div className="mt-5">
<p className="text-sm font-semibold text-text">Role permissions</p>
<div className="mt-3 grid gap-3 md:grid-cols-2">
{permissions.map((permission) => (
<label key={permission.key} className="flex items-start gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-text">
<input
type="checkbox"
checked={roleForm.permissionKeys.includes(permission.key)}
onChange={() => toggleRolePermission(permission.key)}
/>
<span>
<span className="block font-semibold">{permission.key}</span>
<span className="block text-xs text-muted">{permission.description}</span>
</span>
</label>
))}
</div>
</div>
<div className="mt-5 grid gap-3 md:grid-cols-3">
{roles.map((role) => (
<div key={role.id} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<p className="text-sm font-semibold text-text">{role.name}</p>
<p className="mt-1 text-xs text-muted">{role.userCount} assigned users</p>
<p className="mt-2 text-xs text-muted">{role.permissionKeys.length} permissions</p>
</div>
))}
</div>
<div className="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<span className="text-sm text-muted">{status}</span>
<button type="submit" className="rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white">
{selectedRoleId === "new" ? "Create role" : "Save role"}
</button>
</div>
</form>
</section>
</div>
);
}

View File

@@ -4,6 +4,24 @@ import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes("@svar-ui/react-gantt")) {
return "gantt-vendor";
}
if (id.includes("@tanstack/react-query")) {
return "query-vendor";
}
if (id.includes("react-router-dom") || id.includes("react-dom") || id.includes(`${path.sep}react${path.sep}`)) {
return "react-vendor";
}
return undefined;
},
},
},
},
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
@@ -16,4 +34,3 @@ export default defineConfig({
},
},
});

View File

@@ -0,0 +1,21 @@
-- CreateTable
CREATE TABLE "AuditEvent" (
"id" TEXT NOT NULL PRIMARY KEY,
"actorId" TEXT,
"entityType" TEXT NOT NULL,
"entityId" TEXT,
"action" TEXT NOT NULL,
"summary" TEXT NOT NULL,
"metadataJson" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditEvent_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "AuditEvent_createdAt_idx" ON "AuditEvent"("createdAt");
-- CreateIndex
CREATE INDEX "AuditEvent_entityType_entityId_createdAt_idx" ON "AuditEvent"("entityType", "entityId", "createdAt");
-- CreateIndex
CREATE INDEX "AuditEvent_actorId_createdAt_idx" ON "AuditEvent"("actorId", "createdAt");

View File

@@ -29,6 +29,7 @@ model User {
salesQuoteRevisionsCreated SalesQuoteRevision[] @relation("SalesQuoteRevisionCreatedBy")
salesOrderRevisionsCreated SalesOrderRevision[] @relation("SalesOrderRevisionCreatedBy")
inventoryTransfersCreated InventoryTransfer[] @relation("InventoryTransferCreatedBy")
auditEvents AuditEvent[]
}
model Role {
@@ -698,3 +699,19 @@ model PurchaseReceiptLine {
@@index([purchaseReceiptId])
@@index([purchaseOrderLineId])
}
model AuditEvent {
id String @id @default(cuid())
actorId String?
entityType String
entityId String?
action String
summary String
metadataJson String
createdAt DateTime @default(now())
actor User? @relation(fields: [actorId], references: [id], onDelete: SetNull)
@@index([createdAt])
@@index([entityType, entityId, createdAt])
@@index([actorId, createdAt])
}

View File

@@ -11,6 +11,7 @@ import { paths } from "./config/paths.js";
import { verifyToken } from "./lib/auth.js";
import { getCurrentUserById } from "./lib/current-user.js";
import { fail, ok } from "./lib/http.js";
import { adminRouter } from "./modules/admin/router.js";
import { authRouter } from "./modules/auth/router.js";
import { crmRouter } from "./modules/crm/router.js";
import { documentsRouter } from "./modules/documents/router.js";
@@ -53,6 +54,7 @@ export function createApp() {
app.get("/api/v1/health", (_request, response) => ok(response, { status: "ok" }));
app.use("/api/v1/auth", authRouter);
app.use("/api/v1/admin", adminRouter);
app.use("/api/v1", settingsRouter);
app.use("/api/v1/files", filesRouter);
app.use("/api/v1/crm", crmRouter);

27
server/src/lib/audit.ts Normal file
View File

@@ -0,0 +1,27 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "./prisma.js";
type AuditClient = Prisma.TransactionClient | typeof prisma;
interface LogAuditEventInput {
actorId?: string | null;
entityType: string;
entityId?: string | null;
action: string;
summary: string;
metadata?: Record<string, unknown>;
}
export async function logAuditEvent(input: LogAuditEventInput, client: AuditClient = prisma) {
await client.auditEvent.create({
data: {
actorId: input.actorId ?? null,
entityType: input.entityType,
entityId: input.entityId ?? null,
action: input.action,
summary: input.summary,
metadataJson: JSON.stringify(input.metadata ?? {}),
},
});
}

View File

@@ -0,0 +1,119 @@
import { permissions } from "@mrp/shared";
import { Router } from "express";
import { z } from "zod";
import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js";
import {
createAdminRole,
createAdminUser,
getAdminDiagnostics,
listAdminPermissions,
listAdminRoles,
listAdminUsers,
updateAdminRole,
updateAdminUser,
} from "./service.js";
export const adminRouter = Router();
const roleSchema = z.object({
name: z.string().trim().min(1).max(120),
description: z.string(),
permissionKeys: z.array(z.string().trim().min(1)),
});
const userSchema = z.object({
email: z.string().email(),
firstName: z.string().trim().min(1).max(120),
lastName: z.string().trim().min(1).max(120),
isActive: z.boolean(),
roleIds: z.array(z.string().trim().min(1)),
password: z.string().min(8).nullable(),
});
function getRouteParam(value: unknown) {
return typeof value === "string" ? value : null;
}
adminRouter.get("/diagnostics", requirePermissions([permissions.adminManage]), async (_request, response) => {
return ok(response, await getAdminDiagnostics());
});
adminRouter.get("/permissions", requirePermissions([permissions.adminManage]), async (_request, response) => {
return ok(response, await listAdminPermissions());
});
adminRouter.get("/roles", requirePermissions([permissions.adminManage]), async (_request, response) => {
return ok(response, await listAdminRoles());
});
adminRouter.post("/roles", requirePermissions([permissions.adminManage]), async (request, response) => {
const parsed = roleSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Role payload is invalid.");
}
const result = await createAdminRole(parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.role, 201);
});
adminRouter.put("/roles/:roleId", requirePermissions([permissions.adminManage]), async (request, response) => {
const roleId = getRouteParam(request.params.roleId);
if (!roleId) {
return fail(response, 400, "INVALID_INPUT", "Role id is invalid.");
}
const parsed = roleSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Role payload is invalid.");
}
const result = await updateAdminRole(roleId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.role);
});
adminRouter.get("/users", requirePermissions([permissions.adminManage]), async (_request, response) => {
return ok(response, await listAdminUsers());
});
adminRouter.post("/users", requirePermissions([permissions.adminManage]), async (request, response) => {
const parsed = userSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "User payload is invalid.");
}
const result = await createAdminUser(parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.user, 201);
});
adminRouter.put("/users/:userId", requirePermissions([permissions.adminManage]), async (request, response) => {
const userId = getRouteParam(request.params.userId);
if (!userId) {
return fail(response, 400, "INVALID_INPUT", "User id is invalid.");
}
const parsed = userSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "User payload is invalid.");
}
const result = await updateAdminUser(userId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.user);
});

View File

@@ -0,0 +1,545 @@
import type {
AdminDiagnosticsDto,
AdminPermissionOptionDto,
AdminRoleDto,
AdminRoleInput,
AdminUserDto,
AdminUserInput,
AuditEventDto,
} from "@mrp/shared";
import fs from "node:fs/promises";
import { env } from "../../config/env.js";
import { paths } from "../../config/paths.js";
import { logAuditEvent } from "../../lib/audit.js";
import { hashPassword } from "../../lib/password.js";
import { prisma } from "../../lib/prisma.js";
function mapAuditEvent(record: {
id: string;
actorId: string | null;
entityType: string;
entityId: string | null;
action: string;
summary: string;
metadataJson: string;
createdAt: Date;
actor: {
firstName: string;
lastName: string;
} | null;
}): AuditEventDto {
return {
id: record.id,
actorId: record.actorId,
actorName: record.actor ? `${record.actor.firstName} ${record.actor.lastName}`.trim() : null,
entityType: record.entityType,
entityId: record.entityId,
action: record.action,
summary: record.summary,
metadataJson: record.metadataJson,
createdAt: record.createdAt.toISOString(),
};
}
function mapRole(record: {
id: string;
name: string;
description: string;
createdAt: Date;
updatedAt: Date;
rolePermissions: Array<{
permission: {
key: string;
};
}>;
_count: {
userRoles: number;
};
}): AdminRoleDto {
return {
id: record.id,
name: record.name,
description: record.description,
permissionKeys: record.rolePermissions.map((rolePermission) => rolePermission.permission.key).sort(),
userCount: record._count.userRoles,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
};
}
function mapUser(record: {
id: string;
email: string;
firstName: string;
lastName: string;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
userRoles: Array<{
role: {
id: string;
name: string;
rolePermissions: Array<{
permission: {
key: string;
};
}>;
};
}>;
}): AdminUserDto {
const permissionKeys = new Set<string>();
for (const userRole of record.userRoles) {
for (const rolePermission of userRole.role.rolePermissions) {
permissionKeys.add(rolePermission.permission.key);
}
}
return {
id: record.id,
email: record.email,
firstName: record.firstName,
lastName: record.lastName,
isActive: record.isActive,
roleIds: record.userRoles.map((userRole) => userRole.role.id),
roleNames: record.userRoles.map((userRole) => userRole.role.name),
permissionKeys: [...permissionKeys].sort(),
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
};
}
async function validatePermissionKeys(permissionKeys: string[]) {
const uniquePermissionKeys = [...new Set(permissionKeys)];
const permissions = await prisma.permission.findMany({
where: {
key: {
in: uniquePermissionKeys,
},
},
select: {
id: true,
key: true,
},
});
if (permissions.length !== uniquePermissionKeys.length) {
return { ok: false as const, reason: "One or more selected permissions are invalid." };
}
return { ok: true as const, permissions };
}
async function validateRoleIds(roleIds: string[]) {
const uniqueRoleIds = [...new Set(roleIds)];
const roles = await prisma.role.findMany({
where: {
id: {
in: uniqueRoleIds,
},
},
select: {
id: true,
name: true,
},
});
if (roles.length !== uniqueRoleIds.length) {
return { ok: false as const, reason: "One or more selected roles are invalid." };
}
return { ok: true as const, roles };
}
export async function listAdminPermissions(): Promise<AdminPermissionOptionDto[]> {
const permissions = await prisma.permission.findMany({
orderBy: [{ key: "asc" }],
});
return permissions.map((permission) => ({
key: permission.key,
description: permission.description,
}));
}
export async function listAdminRoles(): Promise<AdminRoleDto[]> {
const roles = await prisma.role.findMany({
include: {
rolePermissions: {
include: {
permission: {
select: {
key: true,
},
},
},
},
_count: {
select: {
userRoles: true,
},
},
},
orderBy: [{ name: "asc" }],
});
return roles.map(mapRole);
}
export async function createAdminRole(payload: AdminRoleInput, actorId?: string | null) {
const validatedPermissions = await validatePermissionKeys(payload.permissionKeys);
if (!validatedPermissions.ok) {
return { ok: false as const, reason: validatedPermissions.reason };
}
const role = await prisma.role.create({
data: {
name: payload.name.trim(),
description: payload.description,
rolePermissions: {
create: validatedPermissions.permissions.map((permission) => ({
permissionId: permission.id,
})),
},
},
include: {
rolePermissions: {
include: {
permission: {
select: {
key: true,
},
},
},
},
_count: {
select: {
userRoles: true,
},
},
},
});
await logAuditEvent({
actorId,
entityType: "role",
entityId: role.id,
action: "created",
summary: `Created role ${role.name}.`,
metadata: {
name: role.name,
permissionKeys: role.rolePermissions.map((rolePermission) => rolePermission.permission.key),
},
});
return { ok: true as const, role: mapRole(role) };
}
export async function updateAdminRole(roleId: string, payload: AdminRoleInput, actorId?: string | null) {
const existingRole = await prisma.role.findUnique({
where: { id: roleId },
select: { id: true, name: true },
});
if (!existingRole) {
return { ok: false as const, reason: "Role was not found." };
}
const validatedPermissions = await validatePermissionKeys(payload.permissionKeys);
if (!validatedPermissions.ok) {
return { ok: false as const, reason: validatedPermissions.reason };
}
const role = await prisma.role.update({
where: { id: roleId },
data: {
name: payload.name.trim(),
description: payload.description,
rolePermissions: {
deleteMany: {},
create: validatedPermissions.permissions.map((permission) => ({
permissionId: permission.id,
})),
},
},
include: {
rolePermissions: {
include: {
permission: {
select: {
key: true,
},
},
},
},
_count: {
select: {
userRoles: true,
},
},
},
});
await logAuditEvent({
actorId,
entityType: "role",
entityId: role.id,
action: "updated",
summary: `Updated role ${role.name}.`,
metadata: {
previousName: existingRole.name,
name: role.name,
permissionKeys: role.rolePermissions.map((rolePermission) => rolePermission.permission.key),
},
});
return { ok: true as const, role: mapRole(role) };
}
export async function listAdminUsers(): Promise<AdminUserDto[]> {
const users = await prisma.user.findMany({
include: {
userRoles: {
include: {
role: {
include: {
rolePermissions: {
include: {
permission: {
select: {
key: true,
},
},
},
},
},
},
},
},
},
orderBy: [{ firstName: "asc" }, { lastName: "asc" }, { email: "asc" }],
});
return users.map(mapUser);
}
export async function createAdminUser(payload: AdminUserInput, actorId?: string | null) {
if (!payload.password || payload.password.trim().length < 8) {
return { ok: false as const, reason: "A password with at least 8 characters is required for new users." };
}
const validatedRoles = await validateRoleIds(payload.roleIds);
if (!validatedRoles.ok) {
return { ok: false as const, reason: validatedRoles.reason };
}
const user = await prisma.user.create({
data: {
email: payload.email.trim().toLowerCase(),
firstName: payload.firstName.trim(),
lastName: payload.lastName.trim(),
isActive: payload.isActive,
passwordHash: await hashPassword(payload.password.trim()),
userRoles: {
create: validatedRoles.roles.map((role) => ({
roleId: role.id,
assignedBy: actorId ?? null,
})),
},
},
include: {
userRoles: {
include: {
role: {
include: {
rolePermissions: {
include: {
permission: {
select: {
key: true,
},
},
},
},
},
},
},
},
},
});
await logAuditEvent({
actorId,
entityType: "user",
entityId: user.id,
action: "created",
summary: `Created user account for ${user.email}.`,
metadata: {
email: user.email,
isActive: user.isActive,
roleNames: user.userRoles.map((userRole) => userRole.role.name),
},
});
return { ok: true as const, user: mapUser(user) };
}
export async function updateAdminUser(userId: string, payload: AdminUserInput, actorId?: string | null) {
const existingUser = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
},
});
if (!existingUser) {
return { ok: false as const, reason: "User was not found." };
}
const validatedRoles = await validateRoleIds(payload.roleIds);
if (!validatedRoles.ok) {
return { ok: false as const, reason: validatedRoles.reason };
}
const data = {
email: payload.email.trim().toLowerCase(),
firstName: payload.firstName.trim(),
lastName: payload.lastName.trim(),
isActive: payload.isActive,
...(payload.password?.trim()
? {
passwordHash: await hashPassword(payload.password.trim()),
}
: {}),
userRoles: {
deleteMany: {},
create: validatedRoles.roles.map((role) => ({
roleId: role.id,
assignedBy: actorId ?? null,
})),
},
};
const user = await prisma.user.update({
where: { id: userId },
data,
include: {
userRoles: {
include: {
role: {
include: {
rolePermissions: {
include: {
permission: {
select: {
key: true,
},
},
},
},
},
},
},
},
},
});
await logAuditEvent({
actorId,
entityType: "user",
entityId: user.id,
action: "updated",
summary: `Updated user account for ${user.email}.`,
metadata: {
previousEmail: existingUser.email,
email: user.email,
isActive: user.isActive,
roleNames: user.userRoles.map((userRole) => userRole.role.name),
passwordReset: Boolean(payload.password?.trim()),
},
});
return { ok: true as const, user: mapUser(user) };
}
export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> {
const [
companyProfile,
userCount,
activeUserCount,
roleCount,
permissionCount,
customerCount,
vendorCount,
inventoryItemCount,
warehouseCount,
workOrderCount,
projectCount,
purchaseOrderCount,
salesQuoteCount,
salesOrderCount,
shipmentCount,
attachmentCount,
auditEventCount,
recentAuditEvents,
] = await Promise.all([
prisma.companyProfile.findFirst({ where: { isActive: true }, select: { id: true } }),
prisma.user.count(),
prisma.user.count({ where: { isActive: true } }),
prisma.role.count(),
prisma.permission.count(),
prisma.customer.count(),
prisma.vendor.count(),
prisma.inventoryItem.count(),
prisma.warehouse.count(),
prisma.workOrder.count(),
prisma.project.count(),
prisma.purchaseOrder.count(),
prisma.salesQuote.count(),
prisma.salesOrder.count(),
prisma.shipment.count(),
prisma.fileAttachment.count(),
prisma.auditEvent.count(),
prisma.auditEvent.findMany({
include: {
actor: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: [{ createdAt: "desc" }],
take: 25,
}),
]);
await Promise.all([fs.access(paths.dataDir), fs.access(paths.uploadsDir)]);
return {
serverTime: new Date().toISOString(),
nodeVersion: process.version,
databaseUrl: env.DATABASE_URL,
dataDir: paths.dataDir,
uploadsDir: paths.uploadsDir,
clientOrigin: env.CLIENT_ORIGIN,
companyProfilePresent: Boolean(companyProfile),
userCount,
activeUserCount,
roleCount,
permissionCount,
customerCount,
vendorCount,
inventoryItemCount,
warehouseCount,
workOrderCount,
projectCount,
purchaseOrderCount,
salesDocumentCount: salesQuoteCount + salesOrderCount,
shipmentCount,
attachmentCount,
auditEventCount,
recentAuditEvents: recentAuditEvents.map(mapAuditEvent),
};
}

View File

@@ -148,7 +148,7 @@ inventoryRouter.post("/items", requirePermissions([permissions.inventoryWrite]),
return fail(response, 400, "INVALID_INPUT", "Inventory item payload is invalid.");
}
const item = await createInventoryItem(parsed.data);
const item = await createInventoryItem(parsed.data, request.authUser?.id);
if (!item) {
return fail(response, 400, "INVALID_INPUT", "Inventory item BOM references are invalid.");
}
@@ -167,7 +167,7 @@ inventoryRouter.put("/items/:itemId", requirePermissions([permissions.inventoryW
return fail(response, 400, "INVALID_INPUT", "Inventory item payload is invalid.");
}
const item = await updateInventoryItem(itemId, parsed.data);
const item = await updateInventoryItem(itemId, parsed.data, request.authUser?.id);
if (!item) {
return fail(response, 400, "INVALID_INPUT", "Inventory item or BOM references are invalid.");
}
@@ -224,7 +224,7 @@ inventoryRouter.post("/items/:itemId/reservations", requirePermissions([permissi
return fail(response, 400, "INVALID_INPUT", "Inventory reservation payload is invalid.");
}
const result = await createInventoryReservation(itemId, parsed.data);
const result = await createInventoryReservation(itemId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
@@ -256,7 +256,7 @@ inventoryRouter.post("/warehouses", requirePermissions([permissions.inventoryWri
return fail(response, 400, "INVALID_INPUT", "Warehouse payload is invalid.");
}
return ok(response, await createWarehouse(parsed.data), 201);
return ok(response, await createWarehouse(parsed.data, request.authUser?.id), 201);
});
inventoryRouter.put("/warehouses/:warehouseId", requirePermissions([permissions.inventoryWrite]), async (request, response) => {
@@ -270,7 +270,7 @@ inventoryRouter.put("/warehouses/:warehouseId", requirePermissions([permissions.
return fail(response, 400, "INVALID_INPUT", "Warehouse payload is invalid.");
}
const warehouse = await updateWarehouse(warehouseId, parsed.data);
const warehouse = await updateWarehouse(warehouseId, parsed.data, request.authUser?.id);
if (!warehouse) {
return fail(response, 404, "WAREHOUSE_NOT_FOUND", "Warehouse was not found.");
}

View File

@@ -25,6 +25,7 @@ import type {
InventoryUnitOfMeasure,
} from "@mrp/shared/dist/inventory/types.js";
import { logAuditEvent } from "../../lib/audit.js";
import { prisma } from "../../lib/prisma.js";
type BomLineRecord = {
@@ -841,6 +842,21 @@ export async function createInventoryTransaction(itemId: string, payload: Invent
},
});
await logAuditEvent({
actorId: createdById,
entityType: "inventory-item",
entityId: itemId,
action: "transaction.created",
summary: `Posted ${payload.transactionType.toLowerCase()} transaction for inventory item ${itemId}.`,
metadata: {
transactionType: payload.transactionType,
quantity: payload.quantity,
warehouseId: payload.warehouseId,
locationId: payload.locationId,
reference: payload.reference.trim(),
},
});
const nextDetail = await getInventoryItemById(itemId);
return nextDetail ? { ok: true as const, item: nextDetail } : { ok: false as const, reason: "Unable to load updated inventory item." };
}
@@ -921,11 +937,26 @@ export async function createInventoryTransfer(itemId: string, payload: Inventory
});
});
await logAuditEvent({
actorId: createdById,
entityType: "inventory-item",
entityId: itemId,
action: "transfer.created",
summary: `Transferred ${payload.quantity} units for inventory item ${itemId}.`,
metadata: {
quantity: payload.quantity,
fromWarehouseId: payload.fromWarehouseId,
fromLocationId: payload.fromLocationId,
toWarehouseId: payload.toWarehouseId,
toLocationId: payload.toLocationId,
},
});
const nextDetail = await getInventoryItemById(itemId);
return nextDetail ? { ok: true as const, item: nextDetail } : { ok: false as const, reason: "Unable to load updated inventory item." };
}
export async function createInventoryReservation(itemId: string, payload: InventoryReservationInput) {
export async function createInventoryReservation(itemId: string, payload: InventoryReservationInput, createdById?: string | null) {
const item = await prisma.inventoryItem.findUnique({
where: { id: itemId },
select: { id: true },
@@ -969,11 +1000,25 @@ export async function createInventoryReservation(itemId: string, payload: Invent
},
});
await logAuditEvent({
actorId: createdById,
entityType: "inventory-item",
entityId: itemId,
action: "reservation.created",
summary: `Created manual reservation for inventory item ${itemId}.`,
metadata: {
quantity: payload.quantity,
warehouseId: payload.warehouseId,
locationId: payload.locationId,
sourceType: "MANUAL",
},
});
const nextDetail = await getInventoryItemById(itemId);
return nextDetail ? { ok: true as const, item: nextDetail } : { ok: false as const, reason: "Unable to load updated inventory item." };
}
export async function createInventoryItem(payload: InventoryItemInput) {
export async function createInventoryItem(payload: InventoryItemInput, actorId?: string | null) {
const validatedBom = await validateBomLines(null, payload.bomLines);
if (!validatedBom.ok) {
return null;
@@ -1012,10 +1057,24 @@ export async function createInventoryItem(payload: InventoryItemInput) {
},
});
await logAuditEvent({
actorId,
entityType: "inventory-item",
entityId: item.id,
action: "created",
summary: `Created inventory item ${payload.sku}.`,
metadata: {
sku: payload.sku,
name: payload.name,
type: payload.type,
status: payload.status,
},
});
return getInventoryItemById(item.id);
}
export async function updateInventoryItem(itemId: string, payload: InventoryItemInput) {
export async function updateInventoryItem(itemId: string, payload: InventoryItemInput, actorId?: string | null) {
const existingItem = await prisma.inventoryItem.findUnique({
where: { id: itemId },
});
@@ -1061,6 +1120,20 @@ export async function updateInventoryItem(itemId: string, payload: InventoryItem
},
});
await logAuditEvent({
actorId,
entityType: "inventory-item",
entityId: item.id,
action: "updated",
summary: `Updated inventory item ${payload.sku}.`,
metadata: {
sku: payload.sku,
name: payload.name,
type: payload.type,
status: payload.status,
},
});
return getInventoryItemById(item.id);
}
@@ -1092,7 +1165,7 @@ export async function getWarehouseById(warehouseId: string) {
return warehouse ? mapWarehouseDetail(warehouse) : null;
}
export async function createWarehouse(payload: WarehouseInput) {
export async function createWarehouse(payload: WarehouseInput, actorId?: string | null) {
const locations = normalizeWarehouseLocations(payload.locations);
const warehouse = await prisma.warehouse.create({
@@ -1113,10 +1186,23 @@ export async function createWarehouse(payload: WarehouseInput) {
},
});
await logAuditEvent({
actorId,
entityType: "warehouse",
entityId: warehouse.id,
action: "created",
summary: `Created warehouse ${warehouse.code}.`,
metadata: {
code: warehouse.code,
name: warehouse.name,
locationCount: warehouse.locations.length,
},
});
return mapWarehouseDetail(warehouse);
}
export async function updateWarehouse(warehouseId: string, payload: WarehouseInput) {
export async function updateWarehouse(warehouseId: string, payload: WarehouseInput, actorId?: string | null) {
const existingWarehouse = await prisma.warehouse.findUnique({
where: { id: warehouseId },
});
@@ -1145,5 +1231,18 @@ export async function updateWarehouse(warehouseId: string, payload: WarehouseInp
},
});
await logAuditEvent({
actorId,
entityType: "warehouse",
entityId: warehouse.id,
action: "updated",
summary: `Updated warehouse ${warehouse.code}.`,
metadata: {
code: warehouse.code,
name: warehouse.name,
locationCount: warehouse.locations.length,
},
});
return mapWarehouseDetail(warehouse);
}

View File

@@ -86,7 +86,7 @@ manufacturingRouter.post("/stations", requirePermissions([permissions.manufactur
return fail(response, 400, "INVALID_INPUT", "Manufacturing station payload is invalid.");
}
return ok(response, await createManufacturingStation(parsed.data), 201);
return ok(response, await createManufacturingStation(parsed.data, request.authUser?.id), 201);
});
manufacturingRouter.get("/work-orders", requirePermissions([permissions.manufacturingRead]), async (request, response) => {
@@ -118,7 +118,7 @@ manufacturingRouter.post("/work-orders", requirePermissions([permissions.manufac
return fail(response, 400, "INVALID_INPUT", "Work-order payload is invalid.");
}
const result = await createWorkOrder(parsed.data);
const result = await createWorkOrder(parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
@@ -137,7 +137,7 @@ manufacturingRouter.put("/work-orders/:workOrderId", requirePermissions([permiss
return fail(response, 400, "INVALID_INPUT", "Work-order payload is invalid.");
}
const result = await updateWorkOrder(workOrderId, parsed.data);
const result = await updateWorkOrder(workOrderId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
@@ -156,7 +156,7 @@ manufacturingRouter.patch("/work-orders/:workOrderId/status", requirePermissions
return fail(response, 400, "INVALID_INPUT", "Work-order status payload is invalid.");
}
const result = await updateWorkOrderStatus(workOrderId, parsed.data.status);
const result = await updateWorkOrderStatus(workOrderId, parsed.data.status, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}

View File

@@ -12,6 +12,7 @@ import type {
WorkOrderSummaryDto,
} from "@mrp/shared";
import { logAuditEvent } from "../../lib/audit.js";
import { prisma } from "../../lib/prisma.js";
const workOrderModel = (prisma as any).workOrder;
@@ -634,7 +635,7 @@ export async function listManufacturingStations(): Promise<ManufacturingStationD
return stations.map(mapStation);
}
export async function createManufacturingStation(payload: ManufacturingStationInput) {
export async function createManufacturingStation(payload: ManufacturingStationInput, actorId?: string | null) {
const station = await prisma.manufacturingStation.create({
data: {
code: payload.code.trim(),
@@ -645,6 +646,20 @@ export async function createManufacturingStation(payload: ManufacturingStationIn
},
});
await logAuditEvent({
actorId,
entityType: "manufacturing-station",
entityId: station.id,
action: "created",
summary: `Created manufacturing station ${station.code}.`,
metadata: {
code: station.code,
name: station.name,
queueDays: station.queueDays,
isActive: station.isActive,
},
});
return mapStation(station);
}
@@ -715,7 +730,7 @@ export async function getWorkOrderById(workOrderId: string) {
return workOrder ? mapDetail(workOrder as WorkOrderRecord) : null;
}
export async function createWorkOrder(payload: WorkOrderInput) {
export async function createWorkOrder(payload: WorkOrderInput, actorId?: string | null) {
const validated = await validateWorkOrderInput(payload);
if (!validated.ok) {
return { ok: false as const, reason: validated.reason };
@@ -743,10 +758,26 @@ export async function createWorkOrder(payload: WorkOrderInput) {
await syncWorkOrderReservations(created.id);
const workOrder = await getWorkOrderById(created.id);
if (workOrder) {
await logAuditEvent({
actorId,
entityType: "work-order",
entityId: created.id,
action: "created",
summary: `Created work order ${workOrder.workOrderNumber}.`,
metadata: {
workOrderNumber: workOrder.workOrderNumber,
itemId: workOrder.itemId,
projectId: workOrder.projectId,
status: workOrder.status,
quantity: workOrder.quantity,
},
});
}
return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." };
}
export async function updateWorkOrder(workOrderId: string, payload: WorkOrderInput) {
export async function updateWorkOrder(workOrderId: string, payload: WorkOrderInput, actorId?: string | null) {
const existing = await workOrderModel.findUnique({
where: { id: workOrderId },
select: {
@@ -786,10 +817,26 @@ export async function updateWorkOrder(workOrderId: string, payload: WorkOrderInp
await syncWorkOrderReservations(workOrderId);
const workOrder = await getWorkOrderById(workOrderId);
if (workOrder) {
await logAuditEvent({
actorId,
entityType: "work-order",
entityId: workOrderId,
action: "updated",
summary: `Updated work order ${workOrder.workOrderNumber}.`,
metadata: {
workOrderNumber: workOrder.workOrderNumber,
itemId: workOrder.itemId,
projectId: workOrder.projectId,
status: workOrder.status,
quantity: workOrder.quantity,
},
});
}
return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." };
}
export async function updateWorkOrderStatus(workOrderId: string, status: WorkOrderStatus) {
export async function updateWorkOrderStatus(workOrderId: string, status: WorkOrderStatus, actorId?: string | null) {
const existing = await workOrderModel.findUnique({
where: { id: workOrderId },
select: {
@@ -822,6 +869,19 @@ export async function updateWorkOrderStatus(workOrderId: string, status: WorkOrd
await syncWorkOrderReservations(workOrderId);
const workOrder = await getWorkOrderById(workOrderId);
if (workOrder) {
await logAuditEvent({
actorId,
entityType: "work-order",
entityId: workOrderId,
action: "status.updated",
summary: `Updated work order ${workOrder.workOrderNumber} to ${status}.`,
metadata: {
workOrderNumber: workOrder.workOrderNumber,
status,
},
});
}
return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." };
}
@@ -910,6 +970,22 @@ export async function issueWorkOrderMaterial(workOrderId: string, payload: WorkO
await syncWorkOrderReservations(workOrderId);
const nextWorkOrder = await getWorkOrderById(workOrderId);
if (nextWorkOrder) {
await logAuditEvent({
actorId: createdById,
entityType: "work-order",
entityId: workOrderId,
action: "material.issued",
summary: `Issued material to work order ${nextWorkOrder.workOrderNumber}.`,
metadata: {
workOrderNumber: nextWorkOrder.workOrderNumber,
componentItemId: payload.componentItemId,
warehouseId: payload.warehouseId,
locationId: payload.locationId,
quantity: payload.quantity,
},
});
}
return nextWorkOrder ? { ok: true as const, workOrder: nextWorkOrder } : { ok: false as const, reason: "Unable to load updated work order." };
}
@@ -970,5 +1046,19 @@ export async function recordWorkOrderCompletion(workOrderId: string, payload: Wo
await syncWorkOrderReservations(workOrderId);
const nextWorkOrder = await getWorkOrderById(workOrderId);
if (nextWorkOrder) {
await logAuditEvent({
actorId: createdById,
entityType: "work-order",
entityId: workOrderId,
action: "completion.recorded",
summary: `Recorded completion against work order ${nextWorkOrder.workOrderNumber}.`,
metadata: {
workOrderNumber: nextWorkOrder.workOrderNumber,
quantity: payload.quantity,
status: nextWorkOrder.status,
},
});
}
return nextWorkOrder ? { ok: true as const, workOrder: nextWorkOrder } : { ok: false as const, reason: "Unable to load updated work order." };
}

View File

@@ -111,7 +111,7 @@ projectsRouter.post("/", requirePermissions([permissions.projectsWrite]), async
return fail(response, 400, "INVALID_INPUT", "Project payload is invalid.");
}
const result = await createProject(parsed.data);
const result = await createProject(parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
@@ -130,7 +130,7 @@ projectsRouter.put("/:projectId", requirePermissions([permissions.projectsWrite]
return fail(response, 400, "INVALID_INPUT", "Project payload is invalid.");
}
const result = await updateProject(projectId, parsed.data);
const result = await updateProject(projectId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}

View File

@@ -10,6 +10,7 @@ import type {
ProjectSummaryDto,
} from "@mrp/shared";
import { logAuditEvent } from "../../lib/audit.js";
import { prisma } from "../../lib/prisma.js";
const projectModel = (prisma as any).project;
@@ -356,7 +357,7 @@ export async function getProjectById(projectId: string) {
return project ? mapProjectDetail(project as ProjectRecord) : null;
}
export async function createProject(payload: ProjectInput) {
export async function createProject(payload: ProjectInput, actorId?: string | null) {
const validated = await validateProjectInput(payload);
if (!validated.ok) {
return { ok: false as const, reason: validated.reason };
@@ -383,10 +384,25 @@ export async function createProject(payload: ProjectInput) {
});
const project = await getProjectById(created.id);
if (project) {
await logAuditEvent({
actorId,
entityType: "project",
entityId: created.id,
action: "created",
summary: `Created project ${project.projectNumber}.`,
metadata: {
projectNumber: project.projectNumber,
customerId: project.customerId,
status: project.status,
priority: project.priority,
},
});
}
return project ? { ok: true as const, project } : { ok: false as const, reason: "Unable to load saved project." };
}
export async function updateProject(projectId: string, payload: ProjectInput) {
export async function updateProject(projectId: string, payload: ProjectInput, actorId?: string | null) {
const existing = await projectModel.findUnique({
where: { id: projectId },
select: { id: true },
@@ -421,5 +437,20 @@ export async function updateProject(projectId: string, payload: ProjectInput) {
});
const project = await getProjectById(projectId);
if (project) {
await logAuditEvent({
actorId,
entityType: "project",
entityId: projectId,
action: "updated",
summary: `Updated project ${project.projectNumber}.`,
metadata: {
projectNumber: project.projectNumber,
customerId: project.customerId,
status: project.status,
priority: project.priority,
},
});
}
return project ? { ok: true as const, project } : { ok: false as const, reason: "Unable to load saved project." };
}

View File

@@ -96,7 +96,7 @@ purchasingRouter.post("/orders", requirePermissions(["purchasing.write"]), async
return fail(response, 400, "INVALID_INPUT", "Purchase order payload is invalid.");
}
const result = await createPurchaseOrder(parsed.data);
const result = await createPurchaseOrder(parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
@@ -115,7 +115,7 @@ purchasingRouter.put("/orders/:orderId", requirePermissions(["purchasing.write"]
return fail(response, 400, "INVALID_INPUT", "Purchase order payload is invalid.");
}
const result = await updatePurchaseOrder(orderId, parsed.data);
const result = await updatePurchaseOrder(orderId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
@@ -134,7 +134,7 @@ purchasingRouter.patch("/orders/:orderId/status", requirePermissions(["purchasin
return fail(response, 400, "INVALID_INPUT", "Purchase order status payload is invalid.");
}
const result = await updatePurchaseOrderStatus(orderId, parsed.data.status);
const result = await updatePurchaseOrderStatus(orderId, parsed.data.status, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}

View File

@@ -2,6 +2,7 @@ import { Prisma } from "@prisma/client";
import type { PurchaseLineInput, PurchaseOrderDetailDto, PurchaseOrderInput, PurchaseOrderStatus, PurchaseOrderSummaryDto, PurchaseVendorOptionDto } from "@mrp/shared";
import type { PurchaseReceiptDto, PurchaseReceiptInput } from "@mrp/shared/dist/purchasing/types.js";
import { logAuditEvent } from "../../lib/audit.js";
import { prisma } from "../../lib/prisma.js";
const purchaseOrderModel = prisma.purchaseOrder;
@@ -530,7 +531,7 @@ export async function getPurchaseOrderById(documentId: string) {
return record ? mapPurchaseOrder(record as unknown as PurchaseOrderRecord) : null;
}
export async function createPurchaseOrder(payload: PurchaseOrderInput) {
export async function createPurchaseOrder(payload: PurchaseOrderInput, actorId?: string | null) {
const validatedLines = await validateLines(payload.lines);
if (!validatedLines.ok) {
return { ok: false as const, reason: validatedLines.reason };
@@ -564,10 +565,25 @@ export async function createPurchaseOrder(payload: PurchaseOrderInput) {
});
const detail = await getPurchaseOrderById(created.id);
if (detail) {
await logAuditEvent({
actorId,
entityType: "purchase-order",
entityId: created.id,
action: "created",
summary: `Created purchase order ${detail.documentNumber}.`,
metadata: {
documentNumber: detail.documentNumber,
vendorId: detail.vendorId,
status: detail.status,
total: detail.total,
},
});
}
return detail ? { ok: true as const, document: detail } : { ok: false as const, reason: "Unable to load saved purchase order." };
}
export async function updatePurchaseOrder(documentId: string, payload: PurchaseOrderInput) {
export async function updatePurchaseOrder(documentId: string, payload: PurchaseOrderInput, actorId?: string | null) {
const existing = await purchaseOrderModel.findUnique({
where: { id: documentId },
select: { id: true },
@@ -609,10 +625,25 @@ export async function updatePurchaseOrder(documentId: string, payload: PurchaseO
});
const detail = await getPurchaseOrderById(documentId);
if (detail) {
await logAuditEvent({
actorId,
entityType: "purchase-order",
entityId: documentId,
action: "updated",
summary: `Updated purchase order ${detail.documentNumber}.`,
metadata: {
documentNumber: detail.documentNumber,
vendorId: detail.vendorId,
status: detail.status,
total: detail.total,
},
});
}
return detail ? { ok: true as const, document: detail } : { ok: false as const, reason: "Unable to load saved purchase order." };
}
export async function updatePurchaseOrderStatus(documentId: string, status: PurchaseOrderStatus) {
export async function updatePurchaseOrderStatus(documentId: string, status: PurchaseOrderStatus, actorId?: string | null) {
const existing = await purchaseOrderModel.findUnique({
where: { id: documentId },
select: { id: true },
@@ -629,6 +660,19 @@ export async function updatePurchaseOrderStatus(documentId: string, status: Purc
});
const detail = await getPurchaseOrderById(documentId);
if (detail) {
await logAuditEvent({
actorId,
entityType: "purchase-order",
entityId: documentId,
action: "status.updated",
summary: `Updated purchase order ${detail.documentNumber} to ${status}.`,
metadata: {
documentNumber: detail.documentNumber,
status,
},
});
}
return detail ? { ok: true as const, document: detail } : { ok: false as const, reason: "Unable to load updated purchase order." };
}
@@ -677,6 +721,22 @@ export async function createPurchaseReceipt(orderId: string, payload: PurchaseRe
});
const detail = await getPurchaseOrderById(orderId);
if (detail) {
await logAuditEvent({
actorId: createdById,
entityType: "purchase-order",
entityId: orderId,
action: "receipt.created",
summary: `Received material against purchase order ${detail.documentNumber}.`,
metadata: {
documentNumber: detail.documentNumber,
warehouseId: payload.warehouseId,
locationId: payload.locationId,
receivedAt: payload.receivedAt,
lineCount: payload.lines.length,
},
});
}
return detail ? { ok: true as const, document: detail } : { ok: false as const, reason: "Unable to load updated purchase order." };
}

View File

@@ -9,6 +9,7 @@ import type {
SalesLineInput,
} from "@mrp/shared/dist/sales/types.js";
import { logAuditEvent } from "../../lib/audit.js";
import { prisma } from "../../lib/prisma.js";
export interface SalesDocumentPdfData {
@@ -581,6 +582,19 @@ export async function createSalesDocument(type: SalesDocumentType, payload: Sale
}
await createRevision(type, createdId, detail, payload.revisionReason?.trim() || "Initial issue", userId);
await logAuditEvent({
actorId: userId,
entityType: type === "QUOTE" ? "sales-quote" : "sales-order",
entityId: createdId,
action: "created",
summary: `Created ${type === "QUOTE" ? "quote" : "sales order"} ${detail.documentNumber}.`,
metadata: {
documentNumber: detail.documentNumber,
customerId: detail.customerId,
status: detail.status,
total: detail.total,
},
});
const refreshed = await getDocumentDetailOrNull(type, createdId);
return refreshed ? { ok: true as const, document: refreshed } : { ok: false as const, reason: "Unable to load saved document." };
}
@@ -636,6 +650,20 @@ export async function updateSalesDocument(type: SalesDocumentType, documentId: s
}
await createRevision(type, documentId, detail, payload.revisionReason?.trim() || "Document edited", userId);
await logAuditEvent({
actorId: userId,
entityType: type === "QUOTE" ? "sales-quote" : "sales-order",
entityId: documentId,
action: "updated",
summary: `Updated ${type === "QUOTE" ? "quote" : "sales order"} ${detail.documentNumber}.`,
metadata: {
documentNumber: detail.documentNumber,
customerId: detail.customerId,
status: detail.status,
total: detail.total,
revisionReason: payload.revisionReason?.trim() || null,
},
});
const refreshed = await getDocumentDetailOrNull(type, documentId);
return refreshed ? { ok: true as const, document: refreshed } : { ok: false as const, reason: "Unable to load saved document." };
}
@@ -666,6 +694,17 @@ export async function updateSalesDocumentStatus(type: SalesDocumentType, documen
}
await createRevision(type, documentId, detail, `Status changed to ${status}`, userId);
await logAuditEvent({
actorId: userId,
entityType: type === "QUOTE" ? "sales-quote" : "sales-order",
entityId: documentId,
action: "status.updated",
summary: `Updated ${type === "QUOTE" ? "quote" : "sales order"} ${detail.documentNumber} to ${status}.`,
metadata: {
documentNumber: detail.documentNumber,
status,
},
});
const refreshed = await getDocumentDetailOrNull(type, documentId);
return refreshed ? { ok: true as const, document: refreshed } : { ok: false as const, reason: "Unable to load updated document." };
}
@@ -700,6 +739,17 @@ export async function approveSalesDocument(type: SalesDocumentType, documentId:
}
await createRevision(type, documentId, detail, "Document approved", userId);
await logAuditEvent({
actorId: userId,
entityType: type === "QUOTE" ? "sales-quote" : "sales-order",
entityId: documentId,
action: "approved",
summary: `Approved ${type === "QUOTE" ? "quote" : "sales order"} ${detail.documentNumber}.`,
metadata: {
documentNumber: detail.documentNumber,
approvedAt: detail.approvedAt,
},
});
const refreshed = await getDocumentDetailOrNull(type, documentId);
return refreshed ? { ok: true as const, document: refreshed } : { ok: false as const, reason: "Unable to load approved document." };
}
@@ -751,6 +801,18 @@ export async function convertQuoteToSalesOrder(quoteId: string, userId?: string)
}
await createRevision("ORDER", createdId, order, `Converted from quote ${mappedQuote.documentNumber}`, userId);
await logAuditEvent({
actorId: userId,
entityType: "sales-order",
entityId: createdId,
action: "converted",
summary: `Converted quote ${mappedQuote.documentNumber} into sales order ${order.documentNumber}.`,
metadata: {
sourceQuoteId: quoteId,
sourceQuoteNumber: mappedQuote.documentNumber,
salesOrderNumber: order.documentNumber,
},
});
const refreshed = await getDocumentDetailOrNull("ORDER", createdId);
return refreshed ? { ok: true as const, document: refreshed } : { ok: false as const, reason: "Unable to load converted sales order." };
}

View File

@@ -40,6 +40,5 @@ settingsRouter.put("/company-profile", requirePermissions([permissions.companyWr
return fail(response, 400, "INVALID_INPUT", "Company settings payload is invalid.");
}
return ok(response, await updateActiveCompanyProfile(parsed.data));
return ok(response, await updateActiveCompanyProfile(parsed.data, request.authUser?.id));
});

View File

@@ -1,5 +1,6 @@
import type { CompanyProfileDto, CompanyProfileInput } from "@mrp/shared";
import { logAuditEvent } from "../../lib/audit.js";
import { prisma } from "../../lib/prisma.js";
type CompanyProfileRecord = Awaited<ReturnType<typeof prisma.companyProfile.findFirstOrThrow>>;
@@ -39,7 +40,7 @@ export async function getActiveCompanyProfile() {
);
}
export async function updateActiveCompanyProfile(payload: CompanyProfileInput) {
export async function updateActiveCompanyProfile(payload: CompanyProfileInput, actorId?: string | null) {
const current = await prisma.companyProfile.findFirstOrThrow({
where: { isActive: true },
});
@@ -67,6 +68,18 @@ export async function updateActiveCompanyProfile(payload: CompanyProfileInput) {
},
});
await logAuditEvent({
actorId,
entityType: "company-profile",
entityId: profile.id,
action: "updated",
summary: `Updated company profile for ${profile.companyName}.`,
metadata: {
companyName: profile.companyName,
legalName: profile.legalName,
logoFileId: profile.logoFileId,
},
});
return mapCompanyProfile(profile);
}

80
shared/src/admin/types.ts Normal file
View File

@@ -0,0 +1,80 @@
export interface AuditEventDto {
id: string;
actorId: string | null;
actorName: string | null;
entityType: string;
entityId: string | null;
action: string;
summary: string;
metadataJson: string;
createdAt: string;
}
export interface AdminPermissionOptionDto {
key: string;
description: string;
}
export interface AdminRoleDto {
id: string;
name: string;
description: string;
permissionKeys: string[];
userCount: number;
createdAt: string;
updatedAt: string;
}
export interface AdminRoleInput {
name: string;
description: string;
permissionKeys: string[];
}
export interface AdminUserDto {
id: string;
email: string;
firstName: string;
lastName: string;
isActive: boolean;
roleIds: string[];
roleNames: string[];
permissionKeys: string[];
createdAt: string;
updatedAt: string;
}
export interface AdminUserInput {
email: string;
firstName: string;
lastName: string;
isActive: boolean;
roleIds: string[];
password: string | null;
}
export interface AdminDiagnosticsDto {
serverTime: string;
nodeVersion: string;
databaseUrl: string;
dataDir: string;
uploadsDir: string;
clientOrigin: string;
companyProfilePresent: boolean;
userCount: number;
activeUserCount: number;
roleCount: number;
permissionCount: number;
customerCount: number;
vendorCount: number;
inventoryItemCount: number;
warehouseCount: number;
workOrderCount: number;
projectCount: number;
purchaseOrderCount: number;
salesDocumentCount: number;
shipmentCount: number;
attachmentCount: number;
auditEventCount: number;
recentAuditEvents: AuditEventDto[];
}

View File

@@ -1,3 +1,4 @@
export * from "./admin/types.js";
export * from "./auth/permissions.js";
export * from "./auth/types.js";
export * from "./common/api.js";