DOCS Technical Documentation

Indotalent Enterprise Kit Documentation

A comprehensive guide to the architecture, features, and development workflow of the Indotalent ASP.NET Core Blazor Server enterprise starter kit.

1. Architecture Overview

Indotalent uses Vertical Slice Architecture (VSA) with ASP.NET Core Areas. Each feature lives in its own self-contained folder, including its Razor pages, components, CQRS handlers, validators, and API endpoints. This eliminates the need to jump between multiple projects when working on a single feature.

Key Architectural Decisions
Aspect Implementation
ArchitectureVertical Slice via ASP.NET Core Areas
Backend APIMinimal API (retained for HTTP clients, downloads, and OpenAPI)
CQRSPlain handlers (no MediatR dependency)
DatabaseEF Core with multi-provider (InMemory / SQL Server / PostgreSQL)
Primary KeysString (GUID) - no auto-increment
Soft DeleteIHasIsDeleted + global query filter
AuditIHasAudit + auto-populated on SaveChanges
ValidationFluentValidation (server) + component-side Validate() (UX mirror)
FrontendBlazor Web App (interactive server render mode) + MudBlazor components
UI data accessHandlerInvoker - fresh DI scope per call, circuit user seeded for audit fields
AuthASP.NET Core Identity + JWT with Refresh Token Rotation + Firebase SSO
Rate LimitingSystem.Threading.RateLimiting - 4 policies
CORSConfig-gated via appsettings.json (CorsSettings:IsUsed) - disabled by default
Background JobsHangfire with built-in dashboard

2. Project Structure

The project is organized into ASP.NET Core Areas. Each area groups features by access level:

Area Purpose Auth Required
Areas/Public/Public-facing pages (Home, Privacy, Documentation)No
Areas/Identity/ASP.NET Core Identity Razor Pages (Login, Register, Manage)Mixed
Areas/Admin/Admin-only features (User, Role, Tax, Currency, etc.)Admin role
Areas/Main/Member features (Todo, Todo Dashboard, etc.)Member role
Areas/Blazor/Blazor shell: App.razor, Routes.razor, layouts, circuit services, shared componentsN/A
Areas/Components/Shared dialogs (standard delete confirmation, etc.)N/A
Feature Folder Convention (VSA)

Every feature follows this convention:

Areas/Admin/{EntityName}/
+-- Pages/
|   +-- Index.razor
|   +-- Create.razor
|   +-- Edit.razor
|   +-- Detail.razor
+-- Components/
|   +-- {EntityName}ModuleInfo.razor
+-- Cqrs/
|   +-- Get{EntityName}ListHandler.cs
|   +-- Get{EntityName}ByIdHandler.cs
|   +-- Create{EntityName}Handler.cs + Validator.cs
|   +-- Update{EntityName}Handler.cs + Validator.cs
|   +-- Delete{EntityName}Handler.cs
+-- Endpoints/{EntityName}Endpoint.cs
+-- {EntityName}Fields.cs   (declarative FieldDescriptor sets)

Master-Detail features (like Areas/Main/Todo) add feature-local components (Form, ItemsEditor, ItemDialog, Models.cs) and hand-written pages instead of the descriptor-driven Fields.cs.

3. Application Name

The application name - displayed in the browser title bar, top-left brand logo, footer, and page titles - is configured centrally through appsettings.json. This allows you to rebrand the entire application without editing any layout files manually.

File / PathDescription
Areas/Blazor/App.razorDocument shell: renders the app name in the browser title, navbar logo, and global assets
Areas/Blazor/Layouts/MainLayout.razorAuthenticated shell: app bar, brand logo, top navigation, Module Information drawer
appsettings.json → AppSettingsCentral application name configuration

Configure the application name in appsettings.json under AppSettings:

appsettings.json - AppSettings
"AppSettings": {
    "Name": "Indotalent"
}

To rebrand the application, simply change the "Name" value. The layouts read this value at runtime via @Configuration["AppSettings:Name"], so the title, logo, and footer update automatically across both the public area and the authenticated area layouts.

4. Functional Features

Indotalent's business functionality is organized into ASP.NET Core Areas. Each area groups the screens for one part of the clinic: administration, people and suppliers, inventory, sales, purchasing, scheduling, medical records, and AI assistance.

Common behavior
  • List screens use server-side search, sorting, and paging; selecting a row reveals Detail and Edit, Create New adds a record, and Export XLSX exports the current filtered list.
  • Routes below are index screens; CRUD modules also expose Create, Edit, and Detail pages.
  • Forms validate on the server and mirror the same rules in the UI; optional lookup fields can be left blank.
  • Where a delete action exists, deletion is a soft delete; protected system records cannot be deleted, and audit fields record who created or last updated a record.
  • Auto-numbered modules generate a stable number per entity and year; leave the number blank on create to use the sequence.
  • Menus and pages are role-based per area.
End-to-end workflows
FlowSteps
Care deliveryPatient → Medical record (SOAP notes) → Medical image → Radiology order → Reported
SalesSales order → Invoice → Payment receive → Sales, invoice, payment, and commission reports
PurchasingPurchase order → Bill → Payment disbursement → Purchase, bill, and payment reports
SchedulingBooking resource and group → Booking → Booking scheduler
ProductivityTodo with checklist items and attachments → Todo dashboard
Role-based access
AreaRoles
Admin & SetupAdmin
OrganizationAdmin, Organization
Third PartyAdmin, ThirdParty
InventoryAdmin, Inventory
SalesAdmin, Sales
PurchaseAdmin, Purchase
UtilitiesAdmin, Utilities
Main / Medical & WorkspaceAdmin, Member, Main
Medical Image LibraryAdmin, Member, Main, ThirdParty
Radiology OrdersAdmin, Member, Main
AI ChatAdmin, AI

4.1 Admin & Setup

Administration covers identity, reference data, and operational visibility. All pages in this area require the Admin role.

ModuleRouteWhat you can doKey rules
User Management/Admin/User/IndexCreate and maintain sign-in accounts; edit profile, avatar, roles, password, email confirmation, and personal parameters.Creating an account returns a generated 12-character password; role assignment replaces the full role set; search covers username, email, and full name; deleted accounts are soft-deleted.
Role List/Admin/Role/IndexBrowse and inspect the application roles.Read-only; the seeded roles are Guest, Member, Admin, and AI; roles are assigned from User → Roles; no create, edit, or delete.
Company/Admin/Company/IndexMaintain the organization master profile: name, currency, tax ID, business license, logo, address, contact details, and social links.Single-record UI; no create or delete path; edit from Detail; the seeded company is flagged as the default.
Auto Number Sequence/Admin/AutoNumberSequence/IndexInspect and adjust per-entity, per-year counters, prefix/suffix templates, and zero-padding.Rows are created automatically on first use; edits affect only numbers generated afterward; lowering a counter can cause duplicate numbers.
Tax/Admin/Tax/IndexMaintain tax rates used by sales and purchase orders.Percentage is 0-100; code is unique and back-filled from the auto number when blank; search matches code or name; deletion is a soft delete.
Currency/Admin/Currency/IndexMaintain currencies with code, name, symbol, and optional country.Code is unique; seeded currencies are USD, EUR, GBP, IDR, SGD, INR, AUD, and CNY; companies reference currencies.
Country/Admin/Country/IndexMaintain country reference data used by currencies.Code is unique; seeded countries are US, EU, GB, ID, SG, IN, AU, and CN; deletion is a soft delete.
System File Viewer/Admin/SystemFile/IndexRead appsettings.json, Dockerfile, and docker-compose.yaml; reveal, copy, and refresh the displayed content.Sensitive values are masked by default; Reveal asks for confirmation; demo mode never returns raw secrets; only whitelisted files are available.
Serilog Files/Admin/Serilog/IndexBrowse daily log files, open or download one, read it in windows, and delete old files.Today's active log cannot be deleted; a daily job removes logs older than 3 days; search filters by file name; the detail view loads 500 lines at a time (up to 2000).
System Monitoring/Admin/HealthCheck/IndexReview overall health, database and storage checks, application information, traffic KPIs, and recent endpoint calls.Database and storage checks are configurable; traffic is in-memory and resets on restart; the metrics endpoint requires the Admin role.
Hangfire Job Dashboard/Admin/JobDashboard/IndexOpen the background-job dashboard to inspect and manage scheduled jobs.Admin-only; the page redirects to /hangfire.

4.2 Organization

The staff directory used by clinical, sales, and commission workflows. Requires Admin or Organization.

ModuleRouteWhat you can doKey rules
Employee/Organization/Employee/IndexMaintain staff profiles with contact details, job title, commission rate, and optional group/category.Commission is 0-100; employee number is unique and falls back to the auto number; search covers name, number, title, email, group, and category.
Employee Groups/Organization/EmployeeGroup/IndexCreate and maintain employee group lookup values.Standard lookup behavior: unique active names, auto number, optional description, search, XLSX export, soft delete.
Employee Categories/Organization/EmployeeCategory/IndexCreate and maintain employee category lookup values.Standard lookup behavior: unique active names, auto number, optional description, search, XLSX export, soft delete.

4.3 Third Party

Patients, suppliers, and their contacts. Requires Admin or ThirdParty.

ModuleRouteWhat you can doKey rules
Patient/ThirdParty/Patient/IndexMaintain patient/customer profiles with any number of embedded contacts.Patient names are unique among active records; contacts are saved together with the patient; deleting a patient soft-deletes its contacts; referenced by sales orders, medical records, and bookings.
Patient Groups/ThirdParty/PatientGroup/IndexCreate and maintain patient group lookup values.Standard lookup behavior: unique active names, auto number, optional description, search, XLSX export, soft delete.
Patient Categories/ThirdParty/PatientCategory/IndexCreate and maintain patient category lookup values.Standard lookup behavior: unique active names, auto number, optional description, search, XLSX export, soft delete.
Vendor/ThirdParty/Vendor/IndexMaintain supplier profiles with any number of embedded contacts.Vendor names are unique among active records; contacts are saved with the vendor; deleting a vendor soft-deletes its contacts; referenced by purchase orders.
Vendor Groups/ThirdParty/VendorGroup/IndexCreate and maintain vendor group lookup values.Standard lookup behavior: unique active names, auto number, optional description, search, XLSX export, soft delete.
Vendor Categories/ThirdParty/VendorCategory/IndexCreate and maintain vendor category lookup values.Standard lookup behavior: unique active names, auto number, optional description, search, XLSX export, soft delete.

4.4 Inventory

The product catalog and storage locations used by sales and purchasing. Requires Admin or Inventory.

ModuleRouteWhat you can doKey rules
Product/Inventory/Product/IndexMaintain items sold on sales orders and purchased on purchase orders, with unit price, physical/service flag, unit measure, and product group.Unit price defaults to zero and cannot be negative; physical products and services are distinguished by the physical flag; search covers name, unit measure, and product group.
Product Groups/Inventory/ProductGroup/IndexCreate and maintain product group lookup values.Standard lookup behavior: unique active names, auto number, optional description, search, XLSX export, soft delete.
Unit Measures/Inventory/UnitMeasure/IndexCreate and maintain unit measure lookup values.Standard lookup behavior: unique active names, auto number, optional description, search, XLSX export, soft delete.
Warehouse/Inventory/Warehouse/IndexMaintain storage locations.Six seeded system warehouses are marked Yes and cannot be deleted; other warehouses use soft delete.

4.5 Sales

Quote-to-cash screens and reports. Requires Admin or Sales.

ModuleRouteWhat you can doKey rules
Sales Order/Sales/SalesOrder/IndexCreate sales documents with a header and any number of product lines; print a PDF.Order date and customer are required; at least one line is required; subtotal is the sum of line totals; tax is subtotal x tax percentage / 100; statuses are Draft, Confirmed, Cancelled, and Archived.
Sales Order Groups/Sales/SalesOrderGroup/IndexCreate and maintain sales order group lookup values.Standard lookup behavior: unique active names, auto number, optional description, search, XLSX export, soft delete.
Sales Order Categories/Sales/SalesOrderCategory/IndexCreate and maintain sales order category lookup values.Standard lookup behavior: unique active names, auto number, optional description, search, XLSX export, soft delete.
Invoice/Sales/Invoice/IndexCreate an invoice from a confirmed sales order and preview the parent order lines.Only Confirmed sales orders are offered; totals are copied from the order; status updates automatically with payments (Draft, Confirmed, Partial Paid, Full Paid, Cancelled); deletion is a soft delete.
Payment Receive/Sales/PaymentReceive/IndexRecord money received against an invoice.Amount cannot exceed the outstanding balance; a confirmed payment moves the invoice to Partial Paid or Full Paid; deleting the last payment returns it to Confirmed.
Sales Report/Sales/SalesReport/IndexReview sales-order lines by order, customer, group, category, product, and summary.Server-side search and XLSX export honor the current filter.
Invoice Report/Sales/InvoiceReport/IndexReview invoices by number, sales order, and customer.Server-side search and XLSX export honor the current filter.
Payment Receive Report/Sales/PaymentReceiveReport/IndexReview received payments by number, invoice, customer, and method.Server-side search and XLSX export honor the current filter.
Commission Report/Sales/CommissionReport/IndexReview commission earned by order, customer, employee, and product.Server-side search and XLSX export honor the current filter.

4.6 Purchase

Procure-to-pay screens and reports. Requires Admin or Purchase.

ModuleRouteWhat you can doKey rules
Purchase Order/Purchase/PurchaseOrder/IndexCreate purchase documents with a header and any number of product lines; print a PDF.Order date and vendor are required; at least one line is required; subtotal is the sum of line totals; tax is subtotal x tax percentage / 100; statuses are Draft, Confirmed, Cancelled, and Archived.
Bill/Purchase/Bill/IndexCreate a bill from a confirmed purchase order and preview the parent order lines.Only Confirmed purchase orders are offered; totals are copied from the order; status updates automatically with disbursements; deletion is a soft delete.
Payment Disburse/Purchase/PaymentDisburse/IndexRecord money paid against a supplier bill.Amount cannot exceed the outstanding balance; a confirmed payment moves the bill to Partial Paid or Full Paid; deleting the last payment returns it to Confirmed.
Purchase Report/Purchase/PurchaseReport/IndexReview purchase-order lines by order, vendor, product, and summary.Server-side search and XLSX export honor the current filter.
Bill Report/Purchase/BillReport/IndexReview bills by number, purchase order, and vendor.Server-side search and XLSX export honor the current filter.
Payment Disburse Report/Purchase/PaymentDisburseReport/IndexReview disbursements by number, bill, vendor, and method.Server-side search and XLSX export honor the current filter.

4.7 Utilities

Resource scheduling and shared payment lookups. Requires Admin or Utilities.

ModuleRouteWhat you can doKey rules
Booking/Utilities/Booking/IndexSchedule a booking resource for a time window using separate date and time pickers.End must be after Start; statuses are Cancelled, Draft, Confirmed, OnProgress, and Done; bookings appear in the Booking Scheduler.
Booking Groups/Utilities/BookingGroup/IndexCreate and maintain booking group lookup values.Standard lookup behavior: unique active names, auto number, optional description, search, XLSX export, soft delete.
Booking Resources/Utilities/BookingResource/IndexMaintain bookable people, rooms, or equipment, each belonging to a booking group.Resources are referenced by bookings and the scheduler; search matches name or description; deletion is a soft delete.
Booking Scheduler/Utilities/BookingScheduler/IndexView bookings on a timeline grouped by resource, filter by date range and resource, and create a new booking.Displays the same booking records; there is no separate data store.
Payment Methods/Utilities/PaymentMethod/IndexCreate and maintain payment method lookup values used by payment screens.Standard lookup behavior: unique active names, auto number, optional description, search, XLSX export, soft delete.

4.8 Medical & Workspace

The clinic workspace: dashboard, medical records, imaging, radiology, and task management. Requires Admin, Member, or Main; the Medical Image Library additionally allows ThirdParty, while Radiology Orders allow Admin/Member/Main.

ModuleRouteWhat you can doKey rules
CMS Dashboard/Main/CmsDashboard/IndexMonitor patients, bookings, medical records, employees, sales MTD, receivables, purchase MTD, payables, collection rate, booking completion, and cash flow; refresh on demand.Read-only metrics view; Refresh reloads the figures.
Medical Record/Main/MedicalRecord/IndexCapture a patient encounter with visit date, attending employee, vitals, and SOAP notes (Subjective, Objective, Assessment, Planning).Patient is required; employee, group, and category are optional; status moves Draft, Examining, Completed, Cancelled; search matches patient, employee, and group/category names.
Medical Image Library/Main/MedicalImage/IndexUpload, browse, view, download, and archive clinical images in a PACS-style library; link each image to a patient and optional medical record/order; replace the stored file; gallery panels appear on Patient, MedicalRecord, and RadiologyOrder detail pages.JPEG, PNG, and WebP only, with server-side content verification; defaults are 10 MB per file and 5 files per upload; patient is fixed after upload; archiving keeps the stored file; files live outside the web root under App_Data/pacs; roles Admin, Member, Main, ThirdParty.
Radiology Order/Main/RadiologyOrder/IndexCreate an imaging request with examination type, clinical indication, findings, and conclusion; link images from the library.Draft → Ordered → Completed → Reported; Draft and Ordered can be cancelled; findings and conclusion are required when Reported; Reported and Cancelled are final; an order with linked images cannot be deleted; types are X-Ray, Ultrasound, Panoramic Radiograph, CT, MRI, and Other; roles Admin, Member, Main.
Medical Record Groups/Main/MedicalRecordGroup/IndexCreate and maintain medical record group lookup values.Standard lookup behavior: unique active names, auto number, optional description, search, XLSX export, soft delete.
Medical Record Categories/Main/MedicalRecordCategory/IndexCreate and maintain medical record category lookup values.Standard lookup behavior: unique active names, auto number, optional description, search, XLSX export, soft delete.
Todo/Main/Todo/IndexManage tasks with title, description, priority, category, due date/time, progress, completion state, and owner; add child checklist items and image/file attachments.Progress is 0-100 and independent of the completed flag; search covers name, description, tags, priority, category, auto number, and owner email; records keep audit fields and use soft delete.
Todo Dashboard/Main/TodoDashboard/IndexReview KPI widgets, priority and category charts, six-month task flow, and the ten latest to-dos; refresh on demand.Read-only; all widgets use one payload; soft-deleted records are excluded; today/overdue use UTC; there is no live push update.

4.9 AI Chat

Conversational assistance for users with Admin or AI.

ModuleRouteWhat you can doKey rules
AI Chat/AI/Chat/IndexSend a message and receive a markdown-formatted reply from the configured provider; see a thinking indicator and an error banner on failure.Providers are ChatGPT, Claude, Gemini, and DeepSeek via AiSettings; requests are single-turn; the conversation is kept in memory and resets on page reload.

5. Enterprise Features

Authentication

Full-featured authentication with ASP.NET Core Identity, JWT access tokens with refresh token rotation, and optional Firebase SSO.

File / PathDescription
Infrastructures/Authentications/Jwt/JwtService.csJWT token generation, refresh token creation, hashing, and validation
Infrastructures/Authentications/Jwt/JwtAuthEndpoints.csMinimal API endpoints: POST /api/auth/*
Infrastructures/Authentications/Firebase/Firebase token verification on server side
Areas/Identity/Pages/Account/Razor Pages for Login, Register, Manage, etc.
Configappsettings.json → JwtSettings
JWT + Refresh Token Flow
// 1. Login → POST /api/auth/login with email+password
// 2. Response returns: { token, refreshToken, expiresAt, user }
// 3. When access token expires → POST /api/auth/refresh
//    with { refreshToken } → new token pair (rotation)
// 4. Refresh token is hashed (SHA256) and stored in DB

Role-Based Authorization

Pre-configured roles: Guest, Member, and Admin. New users automatically receive the Guest role.

File / PathDescription
Infrastructures/Authorizations/Identity/ApplicationRoles.csRole constants
Infrastructures/Databases/DatabaseSeeder.csSeeds Admin user + roles on startup
Areas/Admin/*/Pages/*.razor@attribute [Authorize(Roles = ...)]
ApplicationRoles.cs
public static class ApplicationRoles
{
    public const string AdminConst  = "Admin";
    public const string MemberConst = "Member";
    public const string GuestConst  = "Guest";
}

SSO Firebase

Indotalent supports Firebase Single Sign-On (SSO) as an optional authentication method. When enabled, users can sign in using their Google account via Firebase Authentication. The Firebase configuration is stored in appsettings.json under the SsoFirebase section.

File / PathDescription
Infrastructures/Authentications/Firebase/Firebase token verification service
appsettings.json → SsoFirebaseFirebase project configuration

To enable Firebase SSO, configure the following in appsettings.json:

appsettings.json - SsoFirebase
"SsoFirebase": {
    "IsUsed": true,
    "ProjectId": "xxx",
    "ApiKey": "xxx",
    "AuthDomain": "xxx.firebaseapp.com",
    "StorageBucket": "xxx.firebasestorage.app",
    "MessagingSenderId": "xxx",
    "AppId": "xxx"
}

Set "IsUsed": true to enable Firebase SSO. Replace the placeholder values (xxx) with your actual Firebase project credentials from the Firebase Console. Set "IsUsed": false to disable Firebase SSO and use only the built-in Identity authentication.

AutoNumber Generation

Entities implementing IHasAutoNumber get auto-generated codes like COMP-0001.

File / PathDescription
Data/Interfaces/IHasAutoNumber.csInterface definition
Infrastructures/AutoNumberGenerator/AutoNumberGeneratorService.csNumber generation service
UsageAdd : BaseEntity, IHasAutoNumber to entity

Background Jobs (Hangfire)

Hangfire with built-in dashboard at /hangfire (Admin only). Supports recurring, fire-and-forget, and delayed jobs.

File / PathDescription
Infrastructures/BackgroundJobs/DI.csHangfire configuration + storage
Infrastructures/BackgroundJobs/HangfireAuthorizationFilter.csAdmin-only dashboard access
Infrastructures/BackgroundJobs/Jobs/SerilogCleanupJob.csSample recurring job

Multi-Database

Switch between InMemory, SQL Server, and PostgreSQL with a single config change. The application supports three database providers - simply toggle "IsUsed" to switch between them.

File / PathDescription
Infrastructures/Databases/DatabaseSettingsModel.csConfiguration model
Infrastructures/Databases/DI.csEF Core provider registration
appsettings.jsonSet "IsUsed": true for your provider

Configure your database provider in appsettings.json under DatabaseSettings:

appsettings.json - DatabaseSettings
"DatabaseSettings": {
    // InMemory (default, no external DB needed)
    "InMemory": {
        "IsUsed": true,
        "ConnectionString": "IndotalentDb",
        "TimeoutInSeconds": 1800
    },
    // Microsoft SQL Server
    "MsSQL": {
        "IsUsed": false,
        "ConnectionString": "Server=localhost\\SQLEXPRESS;Database=MyDb;Trusted_Connection=True;TrustServerCertificate=True",
        "TimeoutInSeconds": 1800
    },
    // PostgreSQL
    "PostgreSQL": {
        "IsUsed": false,
        "ConnectionString": "Host=localhost;Database=MyDb;Username=postgres;Password=yourpassword",
        "TimeoutInSeconds": 1800
    }
}

To switch providers, set the desired provider's "IsUsed" to true and the others to false. Only one provider can be active at a time. Update the ConnectionString to match your database server credentials.

Demo Mode

Indotalent includes a Demo Mode feature that, when enabled, automatically seeds the database with dummy demo data on application startup. This is useful for testing, presentations, or evaluation purposes without needing to manually enter data.

File / PathDescription
Infrastructures/Databases/DatabaseSeeder.csSeeds demo data when Demo Mode is active
appsettings.json → DemoModeToggle Demo Mode on/off

Configure Demo Mode in appsettings.json:

appsettings.json - DemoMode
"DemoMode": {
    "IsDemo": true
}

Set "IsDemo": true to enable Demo Mode - the application will seed dummy data (sample users, roles, and demo records) on every startup. Set "IsDemo": false to disable it and start with a clean database.

AI Chat

Indotalent includes an AI Chat feature that can be enabled by configuring your preferred AI provider's API key. The application supports multiple AI providers including ChatGPT, Claude, Gemini, and DeepSeek.

File / PathDescription
appsettings.json → AiSettingsAI provider selection and API keys

Configure AI Chat in appsettings.json under AiSettings:

appsettings.json - AiSettings
"AiSettings": {
    // Choose your provider: "ChatGPT", "Claude", "Gemini", or "DeepSeek"
    "Provider": "ChatGPT",
    "ChatGPT": {
        "ApiKey": "sk-your-chatgpt-api-key",
        "Model": "gpt-4o"
    },
    "Claude": {
        "ApiKey": "sk-ant-your-claude-api-key",
        "Model": "claude-3-opus-20240229"
    },
    "Gemini": {
        "ApiKey": "your-gemini-api-key",
        "Model": "gemini-1.5-pro"
    },
    "DeepSeek": {
        "ApiKey": "your-deepseek-api-key",
        "Model": "deepseek-v4-flash"
    }
}

To enable AI Chat, set the "Provider" field to your chosen provider name and fill in the corresponding "ApiKey" with your actual API key from that provider. Leave the API keys empty to disable the AI Chat feature.

Email Delivery

Multi-provider email service supporting SendGrid, Mailgun, SMTP, and Mailjet. Toggle "IsUsed" to switch between providers.

File / PathDescription
Infrastructures/Email/EmailSettingsModel.csProvider selection + API keys
Infrastructures/Email/EmailService.csMain email service with templates
Infrastructures/Email/SendGrid/, Mailgun/, etc.Provider implementations
Infrastructures/Email/IdentityEmailSenderAdapter.csIdentity integration

Configure email delivery in appsettings.json under EmailSettings:

appsettings.json - EmailSettings
"EmailSettings": {
    // SendGrid
    "SendGrid": {
        "IsUsed": false,
        "ApiKey": "SG.your-sendgrid-api-key",
        "FromEmail": "noreply@email.com"
    },
    // Mailgun
    "Mailgun": {
        "IsUsed": false,
        "ApiKey": "key-your-mailgun-api-key",
        "Domain": "mg.yourdomain.com",
        "FromEmail": "noreply@email.com"
    },
    // Mailjet
    "Mailjet": {
        "IsUsed": false,
        "ApiKey": "mj-your-public-key",
        "ApiSecret": "mj-your-private-key",
        "FromEmail": "noreply@email.com"
    },
    // SMTP (default)
    "Smtp": {
        "IsUsed": true,
        "Host": "smtp.gmail.com",
        "Port": 465,
        "UserName": "your-email@gmail.com",
        "Password": "your-app-password",
        "FromAddress": "your-email@gmail.com",
        "FromName": "no-reply"
    }
}

To switch email providers, set the desired provider's "IsUsed" to true and the others to false. Only one provider can be active at a time. Fill in the API keys and credentials for your chosen provider.

File Upload / Download

File storage service supporting local file system with upload, download, delete operations.

File / PathDescription
Infrastructures/File/FileStorageService.csCore service
Infrastructures/File/FileStorageSettingsModel.csStorage path, allowed extensions, max size
Infrastructures/File/Local/Local file system implementation

Health Checks

Built-in health check endpoints, hardened for container probes (for example Docker or a load balancer), plus a System Monitoring dashboard at /Admin/HealthCheck/Index.

File / PathDescription
Infrastructures/HealthChecks/DI.csHealth check registration and endpoint mapping
Program.csError interceptor bypasses /health* so probes keep raw 200/503
appsettings.json - HealthCheckSettingsToggles for the database and storage checks
Endpoints
EndpointPurposeBehavior
/health/liveLiveness (no dependencies)Always 200 while the process is up
/healthzLegacy alias of livenessSame as /health/live
/health/readyReadiness (database + storage)200 when Healthy/Degraded, 503 when Unhealthy
/readyLegacy alias of readinessSame as /health/ready
/healthFull JSON detail for the dashboard200/503 with one entry per check

All probe endpoints are public (no authentication, no rate limiting) so orchestrators can call them without credentials. The response is never redirected to the error page: an unhealthy readiness check returns a raw 503, which is exactly what container probes observe.

Degraded vs Unhealthy. Readiness maps Degraded to 200 on purpose: container probes (for example Docker) only inspect the HTTP status code, so a degraded dependency must not fail a container healthcheck. Only Unhealthy (for example, the database cannot be reached) produces a 503. Do not expect Degraded to fail a probe - only 503 does.

Docker container probe (example)

A HEALTHCHECK that probes the readiness endpoint - only a 503 response marks the container unhealthy.

Dockerfile HEALTHCHECK
HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \
    CMD curl -f http://localhost:80/health/ready || exit 1

curl must exist in the runtime image; orchestrators can alternatively point a readiness probe at /health/ready.

The dashboard also shows in-memory traffic metrics - see System Monitoring Dashboard (Ops Metrics) below.

System Monitoring Dashboard (Ops Metrics)

The Admin dashboard at /Admin/HealthCheck/Index shows the health cards plus an Application card, a rolling Traffic summary, and the most recent endpoints. It is fed by the Admin-only endpoint GET /api/ops/metrics.

File / PathDescription
Infrastructures/OpsMetrics/RequestMetricStore.csThread-safe in-memory rolling store (per-minute buckets + bounded latency histogram + recent requests)
Infrastructures/OpsMetrics/RequestMetricsMiddleware.csRecords each request; excludes health probes, static assets, and the metrics endpoint itself
Areas/Admin/HealthCheck/Endpoints/OpsEndpoint.csGET /api/ops/metrics minimal API (Admin role)
Infrastructures/OpsMetrics/DI.csAddOpsMetricsService + UseRequestMetricsMiddleware
appsettings.json - OpsMetricsSettingsWindow size, bucket count, recent-request cap

Configure the rolling window in appsettings.json:

appsettings.json - OpsMetricsSettings
"OpsMetricsSettings": {
    "WindowMinutes": 5,
    "MaxBuckets": 10,
    "MaxRecentRequests": 200
}

The snapshot returned by /api/ops/metrics uses camelCase keys:

GET /api/ops/metrics - response shape
{
  "app": {
    "name", "version", "environment", "startedAtUtc", "uptimeSeconds"
  },
  "window": {
    "minutes", "totalRequests", "success", "errors", "errorRate",
    "avgMs", "p95Ms", "p99Ms",
    "byStatus": { "2xx", "3xx", "4xx", "5xx" }
  },
  "recent": [
    { "method", "path", "status", "durationMs", "timestampUtc" }
  ],
  "sinceStart": {
    "totalRequests", "success", "errors", "errorRate",
    "avgMs", "p95Ms", "p99Ms"
  }
}
  • success counts 2xx responses; errors counts 5xx responses; latency is in milliseconds.
  • P95/P99 are approximated from a bounded per-minute histogram, so memory stays flat under load.
  • Health probes, static assets, and dashboard polls are excluded from the counts.
  • Numbers reset whenever the web process restarts - the store is in-memory only.

OpenTelemetry (OTLP Export)

A vendor-neutral observability foundation that exports metrics and traces over OTLP. It is disabled by default: no exporter runs and no diagnostic listeners start until you set OpenTelemetrySettings:Enabled to true. The in-memory traffic recording for the System Monitoring dashboard always runs and is independent of this exporter.

NuGet packages
PackagePurpose
OpenTelemetry.Extensions.HostingDI integration (services.AddOpenTelemetry())
OpenTelemetry.Instrumentation.AspNetCoreInbound HTTP server metrics and traces
OpenTelemetry.Instrumentation.HttpOutbound HttpClient metrics and traces
OpenTelemetry.Exporter.OpenTelemetryProtocolOTLP exporter (gRPC by default)
File / PathDescription
Infrastructures/OpenTelemetry/ApiMetrics.csStatic meter Indotalent.Api with counters and a duration histogram
Infrastructures/OpenTelemetry/DI.csAddOpenTelemetryService - only registers providers when enabled
Infrastructures/OpenTelemetry/OpenTelemetrySettingsModel.csSettings model for OpenTelemetrySettings
Infrastructures/DI.csRegistration inside AddInfrastructureDI
appsettings.json - OpenTelemetrySettingsMaster switch + OTLP endpoint and headers

Configure OpenTelemetry in appsettings.json:

appsettings.json - OpenTelemetrySettings
"OpenTelemetrySettings": {
    "Enabled": false,
    "Otlp": {
        "Endpoint": "",
        "Headers": ""
    }
}
  • Both Enabled: true and a non-empty Otlp:Endpoint are required before any exporter starts.
  • Exports the ASP.NET Core and HttpClient instrumentations plus the app meter Indotalent.Api with indotalent.api.requests, indotalent.api.errors, and indotalent.api.request.duration (tag http.response.status.family).
  • The OTLP exporter defaults to the gRPC protocol, so a plain http://host:4317 endpoint works with configuration only.
  • To use an HTTP/protobuf gateway, some telemetry backends publish an OTLP HTTP gateway endpoint - add otlp.Protocol = OtlpExportProtocol.HttpProtobuf; inside the AddOtlpExporter(...) call in Infrastructures/OpenTelemetry/DI.cs.
  • The default service.name resource attribute is the assembly name (Indotalent) - use it to identify the application in your telemetry backend.

Logging (Serilog)

Structured logging with Serilog. Writes to rolling files with automatic 3-day cleanup via Hangfire.

File / PathDescription
Infrastructures/Logging/Serilog/Serilog configuration
wwwroot/data/serilog/Log file output directory
Infrastructures/BackgroundJobs/Jobs/SerilogCleanupJob.csAuto-cleanup job (daily at midnight)

Rate Limiting

Four rate limiting policies using System.Threading.RateLimiting, configurable via appsettings.json.

PolicyScopeDefault
GlobalAll requests100 req/min
AuthenticatedAuthenticated users200 req/min
WritePOST/PUT/DELETE50 req/min
AdminAdmin role500 req/min

CORS (Cross-Origin Resource Sharing)

Browsers block cross-origin requests unless the server returns the appropriate CORS response headers. This project is a monolith - the frontend and the Web API are served from the same origin - so CORS is not needed today. The setting exists only to prepare for a future scenario where the Web API (/api/*) is consumed from a different domain (for example a separate SPA or a partner site). It is disabled by default, so there is zero behavior change and zero overhead until it is enabled.

File / PathDescription
Infrastructures/Cors/CorsSettingsModel.csSettings model for CorsSettings
Infrastructures/Cors/DI.csAddCorsService / UseCorsService - both no-ops when disabled
Infrastructures/DI.csAddCorsService registration inside AddInfrastructureDI
Program.csUseCorsService between UseRouting and UseAuthorization
appsettings.json - CorsSettingsMaster switch + origin / method / header allow-list
SettingDefaultDescription
IsUsedfalseMaster switch. When off, no CORS service, middleware, or headers.
AllowedOrigins[]Empty (or "*") allows any origin; a non-empty list acts as an allow-list.
AllowedMethods[]Empty allows any HTTP method.
AllowedHeaders[]Empty allows any request header.
ExposedHeaders[]Response headers the browser may expose to client code; applied only when non-empty.
AllowCredentialsfalseOnly honored with an explicit origin allow-list (CORS spec forbids allow-any-origin + credentials).

Default configuration in appsettings.json:

appsettings.json - CorsSettings
"CorsSettings": {
    "IsUsed": false,
    "AllowedOrigins": [],
    "AllowedMethods": [],
    "AllowedHeaders": [],
    "ExposedHeaders": [],
    "AllowCredentials": false
}

Example - enabling access for one cross-origin single-page app:

appsettings.json - CorsSettings (example)
"CorsSettings": {
    "IsUsed": true,
    "AllowedOrigins": [ "https://app.example.com" ],
    "AllowedMethods": [ "GET", "POST", "PUT", "DELETE" ],
    "AllowedHeaders": [ "Content-Type", "Authorization" ],
    "AllowCredentials": true
}
  • IsUsed: false means no CORS service, no CORS middleware, and no CORS headers - the default.
  • An empty AllowedOrigins (or one containing "*") allows any origin and cannot be combined with credentials.
  • AllowCredentials is applied only when an explicit origin allow-list is configured.
  • The policy is registered globally as the default policy, so it covers /api/* and the authentication endpoints.
  • Override per environment in appsettings.Development.json or with the environment variable CorsSettings__IsUsed=true.
  • After enabling, a request from an allowed origin returns Access-Control-Allow-Origin and the preflight OPTIONS request succeeds - verify in the browser DevTools Network tab or with curl -H "Origin: https://app.example.com" -i.

6. CQRS Pattern (Step-by-Step)

Every feature uses a simple CQRS pattern with plain C# handlers (no MediatR). Each CRUD operation has its own handler class with a single HandleAsync() method.

Step 1: List Handler
GetTaxListHandler.cs
public class GetTaxListHandler
{
    private readonly AppDbContext _context;

    public GetTaxListHandler(AppDbContext context) => _context = context;

    public async Task<ApiResponse<object>> HandleAsync(DataTableRequest request, CancellationToken cancellationToken = default)
    {
        var query = _context.Tax.AsQueryable();

        // Apply search filter
        if (!string.IsNullOrWhiteSpace(request.Search))
            query = query.Where(x => x.Name.Contains(request.Search) || x.Code.Contains(request.Search));

        var projected = query.Select(x => new TaxListItem { ... });

        // Shared extension: paging + total + response shape
        return await projected.ToDataTableAsync(request, "Tax list retrieved successfully", cancellationToken);
    }
}
Step 2: Create Handler
CreateTaxHandler.cs
public class CreateTaxHandler
{
    public async Task<ApiResponse<CreateTaxResponse>> HandleAsync(CreateTaxRequest request, CancellationToken cancellationToken = default)
    {
        // 1. Validate with FluentValidation
        var result = await _validator.ValidateAsync(request, cancellationToken);
        if (!result.IsValid)
            return ApiResponse<CreateTaxResponse>.Fail(
                "Validation failed", result.ToDictionary());

        // 2. Check for duplicate Code
        if (await _context.Tax.AnyAsync(x => x.Code == request.Code, cancellationToken))
            return ApiResponse<CreateTaxResponse>.Fail("Code already exists");

        // 3. Save to database
        var entity = new Tax
        {
            Code            = request.Code,
            Name            = request.Name,
            PercentageValue = request.PercentageValue,
            Description     = request.Description
        };
        _context.Tax.Add(entity);
        await _context.SaveChangesAsync(cancellationToken);

        return ApiResponse<CreateTaxResponse>.Ok(
            new CreateTaxResponse { Id = entity.Id, Code = entity.Code },
            "Tax has been created successfully");
    }
}
Step 3: Update Handler

Similar to Create but loads existing entity, validates it exists, updates properties, and saves - all with the same CancellationToken discipline.

Step 4: Delete Handler
DeleteTaxHandler.cs
public class DeleteTaxHandler
{
    public async Task<ApiResponse<object>> HandleAsync(string id, CancellationToken cancellationToken = default)
    {
        var entity = await _context.Tax.FindAsync(id, cancellationToken);
        if (entity == null)
            return ApiResponse<object>.Fail("Tax not found");

        // Soft delete only - the global query filter hides the row
        _context.SoftDelete(entity);
        await _context.SaveChangesAsync(cancellationToken);

        return ApiResponse<object>.Ok(new { id }, "Tax deleted successfully");
    }
}

The same handlers power both surfaces: Blazor pages resolve them through HandlerInvoker (fresh DI scope per call), while the Minimal API endpoints inject them and map the returned ApiResponse<T> to HTTP results.

Standard API Response

All handlers return ApiResponse<T> which wraps the result:

ApiResponse.cs
public class ApiResponse<T>
{
    public bool Success { get; set; }
    public string? Message { get; set; }
    public T? Data { get; set; }
    public IDictionary<string, string[]>? Errors { get; set; }
}

7. Minimal API Endpoints

The HTTP API surface uses ASP.NET Core Minimal API. Blazor pages call the same CQRS handlers in-process through HandlerInvoker; the endpoints remain for HTTP clients, file downloads, and the OpenAPI/Swagger surface. Each feature registers its endpoints in a single {EntityName}Endpoint.cs file, on ONE feature-scoped group (/api/{entityLower}).

MethodRouteActionAuth
GET/api/{entity}Paginated list with search & sortRequired
GET/api/{entity}/{id}Get by IDRequired
GET/api/{entity}/exportFull Excel export (same search filter)Required
GET/api/{entity}/{lookup}-lookupLookup list for autocompleteRequired
GET/api/{entity}/{id}/itemsMaster-detail items (master-detail features)Required
POST/api/{entity}Create new recordRequired
PUT/api/{entity}Update existing recordRequired
DELETE/api/{entity}/{id}Soft-delete recordRequired

Endpoints are registered in the area mapping (Areas/{Area}/Map{Area}Endpoints.cs) via app.Map{EntityName}Endpoints(); - never directly in Program.cs.

When the API is consumed from a different origin, enable CORS via CorsSettings - see the CORS section above.

8. Blazor Components Tutorial

The frontend is a Blazor Web App using interactive server render mode + MudBlazor. Every page is a Razor component (Pages/*.razor) rendered on the server; UI events travel over the SignalR circuit. There is no SPA routing, no client-side build tooling, and no per-page JavaScript - interactions are C# event handlers, and data access goes through CQRS handlers.

How the UI is Hosted
Areas/Blazor/
App.razor       -> document shell + global assets (MudBlazor, SheetJS, jsPDF, ECharts, interop helpers)
Routes.razor    -> router (interactive server render mode, MainLayout)
Layouts/        -> MainLayout.razor (app bar, top navigation, Module Information drawer) + WorkspaceTopNav.razor
Services/       -> HandlerInvoker, PageState, ModuleInfoState, CircuitUserState
Shared/         -> reusable components (Crud* pages, AuditTrailCard, LookupAutocomplete, chips, ...)

MudBlazor providers (theme, dialog, snackbar, popover) live in MainLayout.razor, so every page rendered inside the layout can use dialogs and snackbars without extra setup.

Basic Page Component Pattern

Every feature page follows this pattern:

Pages/Index.razor
@* Routable page: keeps the legacy /Area/Entity/Index URL *@
@page "/Main/Todo/Index"
@page "/Main/Todo"
@layout MainLayout
@attribute [Authorize(Roles = $"{ApplicationRoles.AdminConst},{ApplicationRoles.MemberConst}")]
@inject HandlerInvoker Invoker
@inject PageState PageState

<PageTitle>Todo Management - @Configuration["AppSettings:Name"]</PageTitle>

@if (!_ready)
{
    <div class="ui-empty" style="padding-top:4rem;">
        <MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Medium" />
        <p class="u-text-muted">Loading...</p>
    </div>
}
else
{
    @* page content *@
}

@code {
    private bool _ready;

    protected override void OnInitialized() => PageState.Set("Todo Management");

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (!firstRender || _ready) return;

        await Task.Delay(500);   // page gate (Section 3.15.1)
        await LoadAsync();
        _ready = true;
        StateHasChanged();
    }
}

Data access always goes through HandlerInvoker.InvokeAsync(sp => sp.GetRequiredService<GetTodoListHandler>().HandleAsync(...)) - never a direct handler instantiation and never a fetch call.

Example 1: Index Page (Search + Paged Grid + Export)

Pure Master and Lookup features use the declarative CrudIndex component - the page only wires descriptors and handler lambdas (see Areas/Admin/Country/Pages/Index.razor).

1 Declarative Index (CrudIndex)
Pages/Index.razor
<CrudIndex TItem="CountryListItem"
           Title="Country List"
           EntityLabel="country"
           SearchPlaceholder="Search by Code, Name, or Description..."
           EmptyText="No country records found."
           CreateUrl="/Admin/Country/Create"
           DetailUrlFormat="/Admin/Country/Detail/{0}"
           EditUrlFormat="/Admin/Country/Edit/{0}"
           Columns="_columns"
           Loader="LoadAsync"
           Exporter="ExportAsync"
           ExportFileNamePrefix="country"
           ExportSheetName="Countries"
           IdSelector="@(item => item.Id)"
           DefaultOrderColumn="2">
    <ModuleInfoContent>
        <CountryModuleInfo />
    </ModuleInfoContent>
</CrudIndex>

@code {
    private static readonly FieldDescriptor[] _columns =
    {
        new() { Name = nameof(CountryListItem.AutoNumber), Label = "Auto Number", ShowInIndex = true, SortOrder = 1, Format = CellFormat.Dash },
        new() { Name = nameof(CountryListItem.Code),  Label = "Code",  ShowInIndex = true, SortOrder = 2 },
        new() { Name = nameof(CountryListItem.Name),  Label = "Name",  ShowInIndex = true, SortOrder = 3 },
        new() { Name = nameof(CountryListItem.Description), Label = "Description", ShowInIndex = true, SortOrder = 4, Format = CellFormat.Dash }
    };

    private async Task<DataTableResponse<CountryListItem>?> LoadAsync(DataTableRequest request, CancellationToken cancellationToken)
    {
        var result = await Invoker.InvokeAsync(sp =>
            sp.GetRequiredService<GetCountryListHandler>().HandleAsync(request, cancellationToken));

        return result.Success && result.Data is DataTableResponse<CountryListItem> data ? data : null;
    }
}
2 Bespoke Grid Wiring (Master-Detail / custom)
MudDataGrid ServerData (see Areas/Main/Todo/Pages/Index.razor)
private async Task<GridData<TodoListItem>> LoadServerDataAsync(GridState<TodoListItem> state, CancellationToken cancellationToken)
{
    var sort = state.SortDefinitions.FirstOrDefault();
    var request = new DataTableRequest
    {
        Search      = _search,
        Page        = state.Page + 1,
        PageSize    = state.PageSize,
        Start       = 0,   // zero so ResolvePaging() cannot overwrite Page/PageSize
        Length      = 0,
        OrderColumn = MapSortColumn(sort?.SortBy) ?? 1,
        OrderDir    = sort is null ? "asc" : sort.Descending ? "desc" : "asc"
    };

    var result = await Invoker.InvokeAsync(sp =>
        sp.GetRequiredService<GetTodoListHandler>().HandleAsync(request, cancellationToken));

    return result.Success && result.Data is DataTableResponse<TodoListItem> data
        ? new GridData<TodoListItem> { Items = data.Items, TotalItems = data.Total }
        : new GridData<TodoListItem> { Items = Array.Empty<TodoListItem>(), TotalItems = 0 };
}

The search box is debounced in the page (300ms + cancelled request), sorting maps the grid column to the raw DataTableRequest.OrderColumn index, and the Export XLSX button builds friendly-header rows and calls blazorExport.xlsx(fileName, sheetName, rows) from the shared interop file.

Example 2: Create Form with Validation

Bespoke features use a {Entity}Form component (see Areas/Main/Todo/Components/TodoForm.razor); pure/lookup features get this for free from CrudEditPage + CrudForm.

1 Form Model & UI State
Components/TodoModels.cs
// Defaults live in property initializers - no post-render patching
public class TodoEditModel
{
    public string Name { get; set; } = string.Empty;
    public string? Description { get; set; }
    public TodoPriority? Priority { get; set; }
    public DateTime? DueDate { get; set; }
    public TimeSpan? DueTime { get; set; }
    public decimal Progress { get; set; }
    public string? OwnerUserId { get; set; }
    public bool IsCompleted { get; set; }
    public List<TodoItemModel> Items { get; set; } = new();
}

// Page fields: _model, _ready, _saving, _created, _createdId, _generalError, _errors
2 Component Validation (mirrors FluentValidation)
TodoForm.razor - Validate()
public bool Validate()
{
    ClearErrors();

    if (string.IsNullOrWhiteSpace(Model.Name))
        _errors["Name"] = "Todo Name is required";
    else if (Model.Name.Length > 100)
        _errors["Name"] = "Todo Name must not exceed 100 characters";

    if (Model.Description?.Length > 500)
        _errors["Description"] = "Description must not exceed 500 characters";

    if (Model.Progress < 0 || Model.Progress > 100)
        _errors["Progress"] = "Progress must be between 0 and 100";

    StateHasChanged();
    return _errors.Count == 0;
}

public void SetServerErrors(string? message, IDictionary<string, string[]>? errors)
{
    ClearErrors();
    if (errors is not null)
        foreach (var (key, messages) in errors)
            _errors[key] = messages.FirstOrDefault() ?? string.Empty;
    _generalError = message;
    StateHasChanged();
}
3 Submit with 2000ms Simulated Delay
Pages/Create.razor - SubmitAsync()
private async Task SubmitAsync()
{
    if (_form is null || !_form.Validate()) return;

    _saving = true;
    _form.ClearErrors();
    StateHasChanged();

    try
    {
        await Task.Delay(2000);   // Simulated processing delay

        var request = _model.ToCreateRequest();
        var result = await Invoker.InvokeAsync(sp =>
            sp.GetRequiredService<CreateTodoHandler>().HandleAsync(request));

        if (result.Success && result.Data is not null)
        {
            _created   = true;
            _createdId = result.Data.Id;
            Snackbar.Add($"Todo \"{_model.Name}\" has been created successfully.", Severity.Success);
        }
        else
        {
            _form.SetServerErrors(result.Message, result.Errors);
            Snackbar.Add(result.Message ?? "Failed to create todo", Severity.Error);
        }
    }
    catch
    {
        Snackbar.Add("An error occurred while submitting the form.", Severity.Error);
    }
    finally
    {
        _saving = false;
        StateHasChanged();
    }
}
Example 3: Loading States Pattern

Every page declares the same UI state fields for a polished UX:

Page state fields
// Page gate + spinner states (bound to Disabled / spinner visibility)
private bool _ready;        // controls the @if (!_ready) loading gate
private bool _saving;       // "Save" -> "Saving..." + spinner
private bool _exporting;    // "Export XLSX" -> "Exporting..." + spinner
private bool _deleting;     // "Delete" -> "Deleting..." + spinner
private string _error = string.Empty;   // inline error banner, auto-cleared after 4s

// Delays: page gate 500ms; submit + dialogs 2000ms; export + refresh 500ms
Example 4: Standard Delete Confirmation Dialog

Delete operations use the shared _DeleteConfirmationDialog component (never confirm()):

Pages/Detail.razor - ConfirmDeleteAsync()
private async Task ConfirmDeleteAsync()
{
    if (_data is null) return;

    var confirmed = await _DeleteConfirmationDialog.ShowAsync(
        DialogService,
        "Delete Todo",
        "Are you sure you want to delete this todo record?");
    if (!confirmed) return;

    _deleting = true;
    StateHasChanged();

    try
    {
        var result = await Invoker.InvokeAsync(sp =>
            sp.GetRequiredService<DeleteTodoHandler>().HandleAsync(Id));

        if (result.Success)
        {
            _deleted = true;
            Snackbar.Add($"Todo \"{_data.Name}\" has been deleted successfully.", Severity.Success);
            await Task.Delay(800);
            Navigation.NavigateTo("/Main/Todo/Index");
        }
        else
        {
            Snackbar.Add(result.Message ?? "Failed to delete", Severity.Error);
        }
    }
    finally
    {
        _deleting = false;
        StateHasChanged();
    }
}
Blazor Page Checklist

When creating a new Blazor page, ensure you include:

✓ @page + @layout MainLayout + @attribute [Authorize(Roles = ...)]
✓ _ready gate + PageState.Set(...) in OnInitialized
✓ Handlers resolved through HandlerInvoker - never new, never fetch
✓ JS interop only in OnAfterRenderAsync (charts, export, PDF)
✓ Numeric fields use the MudSlider + MudNumericField pair; no JS constants
✓ Shared ui-* CSS classes; no per-page <script>/<style>
✓ Loading state on every async button (Disabled + spinner + text change)
✓ Delete via _DeleteConfirmationDialog (not confirm())
✓ Delays: gate 500ms / submit 2000ms / export 500ms
✓ Success + error via Snackbar; error banners auto-clear after 4s

9. AI-Assisted Development

Indotalent ships with an automatic AI-assisted development pipeline driven by the .ai-assisted/ folder. The only file the developer writes is .ai-assisted/DATA-DICTIONARY.md - the AI generates everything else: the feature specification, the technical PRD, and the complete application.

How to Start the Development Sequence (automatic)
  1. Fill .ai-assisted/DATA-DICTIONARY.md - application name, persona, and feature description.
  2. Start the sequence - tell your AI coding agent exactly this command:
    start coding
  3. The AI runs the whole chain automatically: Gate 0 (identity check) → DATA-DICTIONARY review → Phase 0 (FEATURE.md) → Phase 1 (PRD.md) → Phase 2 (build the application).
  4. Done! A ready-to-use application, verified with dotnet build (0 errors) after every feature.
⚠

Important - before you start: make sure .ai-assisted/DATA-DICTIONARY.md has been updated to match the new application you are about to build. The AI builds exactly what that file describes - template placeholders ([ ... ]), the ## EXAMPLE app, or data from a previous project would be built as-is.

What the AI Generates Automatically

One command produces three deliverables:

✓ FEATURE.md - business source of truth (Phase 0)
✓ PRD.md - technical blueprint / build backlog (Phase 1)
✓ The full application, feature by feature (Phase 2)
Entity Types Auto-Detected by AI
Pattern in EntityDetected Type
public ICollection<T>? Items { get; set; }Master-Detail
public string {X}Id { get; set; } + navigation propertyWith Lookup
Neither pattern abovePure Master Data
: BaseEntity, IHasAutoNumberAdds auto-numbering
Each Feature Is Generated With 16+ Files (Blazor vertical slice)

For every feature, the AI creates the full vertical slice - no controllers, no per-page JavaScript:

✓ Entity + EF configuration
✓ 6-8 CQRS handlers + 2 validators
✓ {Entity}Endpoint.cs (feature-scoped group)
✓ 4 Razor pages (Index, Create, Edit, Detail)
✓ {Entity}Fields.cs + {Entity}ModuleInfo.razor
✓ Endpoint registration + top navigation menu item
✓ Master-detail adds Form, ItemsEditor, ItemDialog, Models components
✓ Optional (opt-in): attachments, Print PDF, ECharts dashboard
Maintenance Mode - Adding a Single Feature

Once the application is customized (AppSettings:Name is no longer Indotalent), the pipeline is inactive. To add a single feature, work directly with .ai-assisted/SKILL-SOFTWARE-ENGINEERING.md: create the entity class in Data/Entities/{Entity}.cs and let the AI generate the feature following the skill.

Prompt Examples - Copy & Use (maintenance mode)

These per-feature prompts apply when the pipeline is inactive (maintenance mode). For a greenfield project, use the single command start coding instead. Replace {Entity} with your entity name.

PURE MASTER DATA

Generate a simple CRUD feature with no relationships:

Generate full CRUD for {Entity}. Follow the skill.
WITH LOOKUP

Generate a feature that references another entity via foreign key:

Generate full CRUD for {Entity} with lookup to {LookupEntity}. Follow the skill.
MASTER-DETAIL

Generate a header-detail feature (e.g., Sales Order with line items):

Generate full CRUD for {MasterEntity} with {DetailEntity}. Follow the skill.
WITH SEED DATA

Generate a feature with pre-populated seed data:

Generate full CRUD for {Entity} with seed data. Follow the skill.
Smart Prompt Strategies

To get the best results from your AI agent and save tokens, use these strategies:

Limit Context to One Folder

"Read Areas/Admin/Currency/ and generate a new feature following the same pattern."
This restricts the AI to just the Currency feature folder, saving thousands of tokens.

Reference an Existing Entity

"Generate full CRUD for Category. Use Tax as the template. Follow the skill."
The AI will use Tax as a reference and adapt it for Category.

Avoid Vague Prompts

"Make me a CRUD" → Too vague. The AI doesn't know your patterns.
"Generate full CRUD for Category. Follow the skill." → The AI knows exactly what to do.

Chain Multiple Entities

"Generate full CRUD for Category, Product, and Customer. Follow the skill."
One prompt, multiple entities. The AI processes each independently.

For the complete set of ready-to-use prompts (Options 1–5), open .ai-assisted/SKILL-SOFTWARE-ENGINEERING.md → section "For Users: What to Say to Your AI".


Indotalent Enterprise Kit - Technical Documentation v1.0
Built with ASP.NET Core Blazor Server 10 · MudBlazor · Hangfire · Serilog · EF Core · VSA Architecture