Promisoft Guida

Guida dello sviluppatore

Come si crea un'applicazione sulla shell Promisoft Dashboard, che cosa porta la shell, dove sono le cuciture da riempire, come si installano i due moduli disponibili e come si fa generare una pagina CRUD dall'agente AI.

1. Com'è fatta

La shell è un pacchetto NuGet, non un template da copiare: l'applicazione la referenzia e prende gli aggiornamenti alzando un numero di versione. Tutto quello che sta nel progetto generato è tuo - non esiste più una zona da non toccare.

PezzoChe cos'è
Promisoft.Dashboard.Shell La shell come Razor Class Library: layout e chrome, pagine di amministrazione e impostazioni, servizi, DashboardDbContext con le sue migrazioni, risorse resx, asset statici (icone, CSS, JS). È il pacchetto che installi.
Promisoft.Dashboard.Templates Il template dotnet new promisoft-dashboard: la sola zona applicativa, con shell e moduli presi dal feed.
Promisoft.Dashboard.Modules.MultiTab Modulo aggiuntivo: layout multi-scheda (sopra la libreria Promisoft.Blazor.MultiTab, indipendente dalla shell).
Promisoft.Dashboard.Modules.Tasks Modulo aggiuntivo a pagamento: gestione attività - richieste di lavoro, progetti, Kanban, Gantt, scheda ore, tariffe e report.
Promisoft.Dashboard.Host L'applicazione dimostrativa (il banco di sviluppo della shell): Identity, le pagine /Account, i provider demo, le pagine CRUD su Northwind. Non viene mai distribuita: è il modello da cui copiare.

Due database, nessuna chiave esterna incrociata

Identity resta dell'applicazione (schema standard, connessione DefaultConnection); tutto quello che la shell persiste vive in DashboardDbContext (connessione DashboardConnection). Le righe della shell riferiscono gli account Identity logicamente, per UserId: nessuna FK attraversa i due database, e la pulizia alla cancellazione di un account è esplicita.

La regola di registrazione

I servizi della shell si registrano dentro la shell (AddPromisoftDashboardShell), mai nel Program.cs dell'applicazione. L'applicazione registra solo le proprie cuciture: IUserDirectory (che nomina il suo tipo utente), i provider di navigazione e dati, i descrittori dei moduli, i descrittori delle sorgenti di notifica.

2. Prerequisiti

  • .NET 10 SDK
  • SQL Server - qualsiasi edizione; per lo sviluppo va bene LocalDB
  • DevExpress Blazor 26.1 - serve un feed NuGet licenziato per ogni sviluppatore che compila. La shell dichiara DevExpress come dipendenza, non la include mai
  • Il feed Promisoft - dove vivono i pacchetti di shell, moduli e template: GitHub Packages dell'organizzazione Promisoft-rc, https://nuget.pkg.github.com/Promisoft-rc/index.json, configurato dal nuget.config che ogni progetto porta con sé. È privato e non consente lettura anonima: ogni sviluppatore registra la sorgente una volta per macchina

La registrazione della sorgente, con un token personale GitHub che abbia il solo scope read:packages (da PowerShell, che lascia intatti i %...%):

setx GITHUB_PACKAGES_TOKEN <token>
dotnet nuget add source https://nuget.pkg.github.com/Promisoft-rc/index.json -n promisoft -u <username> -p %GITHUB_PACKAGES_TOKEN% --store-password-in-clear-text

Il %GITHUB_PACKAGES_TOKEN% va lasciato scritto così: nel NuGet.Config finisce il segnaposto e non il token, e NuGet lo risolve al momento del download. Dopo setx serve un terminale nuovo, altrimenti il primo restore risponde 401.

3. Installare da template

È la strada consigliata: il progetto nasce già cucito, con lo scheletro delle risorse, i provider, le pagine /Account e la skill dell'agente AI dentro.

Una volta per macchina

dotnet new install Promisoft.Dashboard.Templates

Nessuna versione da indicare: viene installata la più recente pubblicata sul feed (anche se prerelease). Le credenziali sono quelle registrate al punto 2, valide per qualsiasi cartella. Più avanti, dotnet new update porta il template all'ultima versione.

Una volta per applicazione

dotnet new promisoft-dashboard -n easy.web -o D:\Projects\easy.web

Il valore di -n diventa il namespace radice (lettere, cifre e punti; nessuno spazio). Il progetto generato è la sola zona applicativa: un'app Blazor Server sottile che referenzia Promisoft.Dashboard.Shell e i moduli dal feed.

Database e primo avvio

dotnet restore
dotnet tool install dotnet-ef --create-manifest-if-needed
dotnet dotnet-ef database update --context ApplicationDbContext
dotnet dotnet-ef database update --context DashboardDbContext
dotnet run

Il dotnet restore non è di cortesia: i comandi dotnet-ef leggono i metadati del progetto da obj/project.assets.json, che nasce col ripristino. Senza, rispondono NETSDK1004: il file di risorse ... non è stato trovato.

Lo schema del dashboard arriva dalle migrazioni dentro il pacchetto della shell: non c'è niente da copiare. Le stringhe di connessione stanno in appsettings.json (DefaultConnection per Identity, DashboardConnection per la shell).

Il primo accesso

In sviluppo si entra direttamente con Bootstrap:AdminEmail / Bootstrap:AdminPassword (admin@example.com / Demo1! come spedito, in appsettings.Development.json): all'avvio l'account viene creato con il ruolo Admin se manca.

Attenzione Cambia quei valori e non portare mai Bootstrap:AdminPassword in produzione. Senza la password il flusso è: registra l'account AdminEmail, riavvia una volta - all'avvio successivo quell'account entra nel ruolo Admin.
Pagina di accesso con il marchio dell'installazione
La pagina di accesso: il marchio e il selettore di lingua e tema ci sono già, perché la barra superiore della shell è visibile anche da anonimi.

4. Aggiungere la shell a un'app esistente

Se l'applicazione esiste già (Blazor Server con render mode InteractiveServer), la shell si installa come pacchetto. Questi sono i punti da toccare - il template li contiene tutti già fatti, e resta il riferimento migliore.

  1. Riferimenti nel .csproj (più il nuget.config con il feed Promisoft):

    <PackageReference Include="Promisoft.Dashboard.Shell" Version="2.0.0-preview.25" />
    <PackageReference Include="DevExpress.Blazor" Version="26.1.*" />
  2. Stringhe di connessione: DashboardConnection (la shell la pretende all'avvio) accanto a quella di Identity.

  3. Program.cs: una sola chiamata porta dentro DevExpress, localizzazione, aspetto, moduli, notifiche, messaggi, preferiti, ricerca, job e repository. Il callback dichiara le famiglie di stringhe dell'applicazione.

    builder.Services.AddPromisoftDashboardShell(builder.Configuration, localization => localization
        .AddScope<NavigationStrings>("navigation", "Navigation")
        .AddScope<ProductsStrings>("products", "Products")
        .AddSeedAssembly(typeof(Program).Assembly));
  4. Le cuciture dell'applicazione: la shell vede solo interfacce.

    // Il ponte con il sistema di identità: nomina qui il tuo tipo utente.
    builder.Services.AddSingleton<IUserDirectory, IdentityUserDirectory<ApplicationUser>>();
    
    // Il rail di navigazione, la ricerca, i dati della home.
    builder.Services.AddScoped<INavigationProvider, AppNavigationProvider>();
    builder.Services.AddScoped<ISearchProvider, CustomerSearchProvider>();
    builder.Services.AddScoped<IDashboardDataProvider, AppDashboardDataProvider>();
  5. Router (Routes.razor): le pagine della shell (/admin/*, /settings/*, /search, /tasks, /messages) vivono nel suo assembly, quelle dei moduli nei loro; ShellPageAssemblies le elenca tutte, e il layout predefinito è quello della shell.

    @inject ShellPageAssemblies Pages
    
    <Router AppAssembly="typeof(Program).Assembly"
            AdditionalAssemblies="Pages.Additional">
        <Found Context="routeData">
            <AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)" />
        </Found>
    </Router>
  6. Endpoint (Program.cs, dopo UseRequestLocalization()):

    app.MapRazorComponents<App>()
        .AddInteractiveServerRenderMode()
        .AddShellPageAssemblies(app.Services)   // shell + moduli installati
        .AllowAnonymous();
    
    app.MapPromisoftDashboardShell();   // cookie della cultura, logo, export traduzioni
  7. App.razor: il tema DevExpress si registra per richiesta (niente lampo di tema sbagliato) e lo script della shell chiude il body.

    @DxResourceManager.RegisterTheme(ActiveTheme)
    @DxResourceManager.RegisterScripts()
    ...
    <script src="@Assets["_content/Promisoft.Dashboard.Shell/js/shell.js"]"></script>

    ActiveTheme viene da AppThemes.Resolve(cookie, Appearance.Current.Accent): il modo chiaro o scuro dal cookie del browser, l'accento dalle impostazioni dell'installazione.

  8. _Imports.razor: aggiungi Promisoft.Dashboard.Shell.Components.Layout, ...Shell.Resources, ...Shell.Services e DevExpress.Blazor.

Nota La shell è una RCL: non ha gli implicit usings del Web SDK. Se estendi il suo codice, gli using (per esempio System.Net.Http.Json) vanno scritti.

5. Aggiornare la shell

<PackageReference Include="Promisoft.Dashboard.Shell" Version="2.0.0-preview.25" />

Alza la versione, ripristina, esegui di nuovo il comando di migrazione del DashboardDbContext (le tabelle nuove della shell arrivano come migrazioni del pacchetto e sono sempre additive), riavvia. Il contenuto del database - marchio, attivazioni dei moduli, traduzioni, notifiche, preferiti - sopravvive a ogni aggiornamento: i seed riempiono solo i buchi e non sovrascrivono mai una modifica fatta da un amministratore.

dotnet dotnet-ef database update --context DashboardDbContext

Le versioni 2.0.0-preview.N possono ancora cambiare API tra un numero e l'altro; con la 2.0.0 parte il versionamento semantico vero (rotture solo nei major, annotate nel CHANGELOG.md).

6. Le funzionalità della shell

Quello che segue è ciò che l'applicazione ha senza scrivere una riga: pagine, servizi e cuciture. Per il punto di vista dell'utente finale, con le stesse schermate spiegate come si usano, vedi la guida dell'utente.

6.1 Chrome, rail, temi

La shell: barra superiore, rail di navigazione, pagina di contenuto
La shell con la home dimostrativa. Rail a sinistra con sezioni a soffietto, barra superiore con ricerca, tema, lingua, preferiti, messaggi, notifiche e menu utente.
  • Rail di navigazione - popolato da INavigationProvider: sezioni, voci, icone, sigle, voci "pinned" fuori sezione. Lo stato aperto/compresso è una classe CSS su <html> scritta dallo script della shell e persistita nel cookie shell.drawer, che App.razor rilegge lato server: nessun parametro da infilare nei link, e il comportamento è identico sulle pagine statiche.
  • Preferiti - la stella nella barra superiore aggiunge la pagina corrente alla sezione "Preferiti" del rail, per utente (IFavoritesService).
  • Tema chiaro/scuro - cookie shell.theme più un reload: le visualizzazioni DevExpress cuociono la palette nel loro SVG al momento del render, e un cambio a caldo lascerebbe assi bianchi su fondo bianco.
  • PageHeader e CommandBar - titolo, sottotitolo, breadcrumb e barra comandi che le pagine dichiarano dove vogliono e che atterrano in cima all'area di contenuto tramite SectionOutlet.
  • Icone - SVG usati come mask-image (20x20, tratto 1.5) registrati in icons.css e usati come IconCssClass="icon icon-<nome>": seguono il colore del tema per costruzione.
La stessa pagina in tema scuro
La stessa pagina in tema scuro: l'accento del marchio resta quello dell'installazione, la scala di grigi cambia.

6.2 Ricerca globale

IGlobalSearch interroga ogni ISearchProvider registrato. La shell ne porta uno suo (ogni pagina del rail trovabile per nome); l'applicazione e i moduli ne registrano uno per dominio ricercabile. La casella in barra superiore porta a /search, che raggruppa i risultati per categoria.

Pagina dei risultati della ricerca globale
/search: i risultati arrivano dai provider registrati - qui gli ordini demo.

6.3 Notifiche

Si emette attraverso INotificationPublisher: per utente, per ruolo o per unità organizzativa. Ogni emettitore dichiara un NotificationSourceDescriptor; il publisher, prima di consegnare, consulta le scelte del destinatario da /settings/notifications. I sottoscrittori ricaricano al segnale del dispatcher - il segnale non porta payload, quindi non ci sono dati stantii da conciliare.

services.AddSingleton(new NotificationSourceDescriptor(
    "fatture-scadute", "Fatture scadute", "Un avviso quando una fattura supera la scadenza."));

await publisher.PublishToUserAsync(userId, new Notification(
    "fatture-scadute", "Fattura 2026/1187 scaduta", "Logistica Adriatica - 4 giorni"));
Banco di prova delle notifiche
/admin/notifications: il banco di prova manda una notifica vera attraverso la pipeline vera.
Pannello delle notifiche aperto
Il pannello della campanella, con contatore, "segna tutte come lette" e "elimina lette".

6.4 Messaggi

Il gemello deliberato delle notifiche, per il contenuto invece che per le interruzioni: IMessagePublisher deposita copie per destinatario con una ThreadKey, la pagina /messages le mostra come conversazioni. Differenza di principio: le preferenze di notifica non filtrano mai un messaggio - silenziare una sorgente spegne la campanella, non il post.

La casella cresce solo, quindi ha un contrappeso: MessageRetentionJob con due finestre (Messages:RetentionDays per i letti, Messages:HardRetentionDays per tutto; zero disattiva la regola).

Pagina dei messaggi con l'elenco delle conversazioni
/messages: conversazioni a sinistra, messaggi a destra. Aprire una conversazione la legge.

6.5 Attività personali

/tasks è una lista di scadenze, non un calendario di appuntamenti. PersonalTask appartiene alla login (OwnerUserId): la scadenza è una DateOnly? con un TimeOnly? facoltativo, la ricorrenza (giornaliera, settimanale, mensile, annuale, con intervallo) materializza la riga successiva al completamento, e l'invariante CompletedUtc valorizzato ⇔ ProgressPercent == 100 la impone il servizio, mai la UI.

La pagina aggrega ogni ITaskListSource registrato - quella personale è l'unica modificabile: il modulo di gestione attività aggiunge le proprie sorgenti senza toccare la pagina.

Pagina delle attività personali con raggruppamento per scadenza
/tasks: righe raggruppate per finestra di scadenza, colonna "Origine" con la sorgente che le ha portate.

6.6 Processi in background

Le implementazioni di IShellJob si registrano in DI (lo stesso schema additivo delle sorgenti di notifica) e le esegue un solo ShellJobRunner: tick di 30 secondi, isolamento degli errori per job, nessuna sovrapposizione, stato (JobRun) persistito perché un riavvio non rispari tutto. Deliberatamente non Hangfire: la shell è un pacchetto e non impone un framework di scheduling.

Pagina dei processi in background
/admin/jobs: pianificazione, ultima e prossima esecuzione, esito, errore, ed "Esegui ora".

6.7 Aspetto e marchio

/settings/appearance è per installazione, condivisa da tutti: logotipo (solo testo, solo immagine, immagine e testo; una variante per fondi chiari e una per fondi scuri, PNG/WebP/SVG fino a 512 KB), colore del marchio (da cui DevExpress deriva l'intera scala primaria, che la shell rilegge per la propria interfaccia) e colori di superficie per rail e pannelli.

Pagina Aspetto in tema chiaro
Logotipo e colore del marchio.
Pagina Aspetto in tema scuro
La stessa pagina in scuro: le due varianti del logo servono a questo.

6.8 Impostazioni per installazione

IShellSettings conserva un blob JSON sotto una chiave scelta dal chiamante ("proprietario.impostazione") e lo restituisce tipizzato: lo store ignora il contenuto, la classe delle impostazioni vive con chi la possiede. È in cache come aspetto e moduli, quindi un chiamante consultato a ogni render può chiedere liberamente; niente di salvato si legge come i default dichiarati dalla classe. Un salvataggio alza Changed, ed è ciò che fa ridisegnare il rail quando un'impostazione letta da un provider di navigazione cambia.

sealed class FatturazioneSettings { public bool MostraBozze { get; set; } = true; }

var settings = shellSettings.Get<FatturazioneSettings>("fatturazione.opzioni");
await shellSettings.SaveAsync("fatturazione.opzioni", settings);
Pagina di impostazioni dell'applicazione dimostrativa
/settings/demo nell'host dimostrativo: l'esempio da copiare per una pagina di impostazioni tutta tua sopra IShellSettings.

6.9 Repository e pagine dati

Tre registrazioni open-generic coprono le pagine dati, senza registrazioni per entità:

InterfacciaQuando
IReadOnlyRepository<T, TKey, TContext>Sola lettura: combo su tabelle di riferimento, griglie non modificabili.
IRepository<T, TKey, TContext>CRUD completo quando lettura e modifica sono la stessa tabella.
IRepositoryDual<TRead, TWrite, TKey, TContext>La griglia legge una vista denormalizzata, la modifica lavora sulla tabella. È il modello canonico.

Il modello di riferimento per una pagina dati è ProductsList.razor dell'host: CommandBar con i comandi che agiscono sulla riga focalizzata, DxGrid dentro una Card, modifica ed eliminazione in DxPopup posseduti dalla pagina, toast per gli esiti.

Pagina Prodotti: griglia con filtri e totali
La griglia: riga filtro, menu filtro, pannello di raggruppamento, scelta colonne nativa, export.
Popup di modifica di un prodotto
Il popup di modifica: EditForm con validazione, combo per le chiavi esterne, eliminazione a sinistra.

7. Autenticazione, ruoli e persone

Chi possiede cosa

Identity è dell'applicazione, sempre. La shell non vede mai il tuo tipo utente: legge righe DirectoryUser e chiede modifiche attraverso IUserDirectory. La riga che nomina il tipo è una sola:

builder.Services.AddSingleton<IUserDirectory, IdentityUserDirectory<ApplicationUser>>();

È lì che un'applicazione con un sistema di login diverso (LDAP, un identity provider esterno, un archivio proprio) registra la propria implementazione e tiene tutte le pagine di amministrazione della shell.

L'autenticazione vera è quella standard di ASP.NET Core Identity, montata nel Program.cs dell'applicazione: cookie di Identity, AddIdentityCore<ApplicationUser> con AddRoles<IdentityRole>, store su Entity Framework, SignInManager, token provider predefiniti e IdentityRevalidatingAuthenticationStateProvider. Le pagine /Account/* sono sorgente tuo, in stile scaffolding: login, registrazione, recupero password, gestione profilo, 2FA, chiavi di recupero, dati personali.

Nota tecnica Le pagine /Account sono in SSR statico: l'attributo [ExcludeFromInteractiveRouting] nel loro _Imports.razor è portante - senza, il primo click ucciderebbe il circuito.

Ruoli

I ruoli restano roba di Identity. La shell ne conosce uno per nome, ShellRoles.Admin: è quello che apre /admin/*. All'avvio l'applicazione crea il ruolo se manca e ci mette dentro l'account di bootstrap - altrimenti non ci sarebbe modo di entrare nelle pagine di amministrazione, perché solo un Admin può concedere ruoli. Le pagine tue si proteggono come sempre: [Authorize(Roles = "Admin")].

/admin/roles crea ed elimina ruoli e mostra il numero di membri; l'assegnazione si fa dalla riga dell'utente in /admin/users. Due garanzie vivono nel servizio, non nella UI: l'ultimo amministratore non può essere rimosso dal ruolo (né disabilitato, né cancellato), e ogni cambio di ruolo ruota il security stamp, così le sessioni aperte perdono i vecchi claim.

Pagina dei ruoli
/admin/roles: ruoli e conteggio membri.
Pagina degli utenti con stati e ruoli
/admin/users: persone, stato derivato, ruoli, e la barra comandi che agisce sulla riga focalizzata.

Anagrafica people-first

Il record primario è la persona (UserProfile: nome, cognome, email); la login Identity è facoltativa e viene predisposta dal flusso di invito. È questo che permette di mettere qualcuno nell'organigramma prima che esista un account. L'email è immutabile dopo la creazione: è la login.

Gli stati sono derivati, non un campo:

StatoCome si ottiene
In attesaCreata (magari invitata) ma mai entrata: la login può ancora non esistere.
AttivoActivatedUtc valorizzato: la persona ha impostato la password seguendo il link di invito.
DisabilitatoDisabledUtc valorizzato: resta in anagrafica e nell'organigramma, la login è bloccata e le sessioni aperte muoiono.

Il flusso di invito

  1. Nuovo utente crea la persona: nessuna login, stato "In attesa". Assegnabile subito a un'unità organizzativa.
  2. Invia o reinvia l'invito predispone la login (email come username, nessuna password) ed emette un token di reset password. Richiamarlo emette semplicemente un token nuovo.
  3. Il link punta a /Account/AcceptInvitation. Quando la persona imposta la password, la shell conferma l'email (aver seguito il link prova la casella) e timbra ActivatedUtc.

Nessuna password viaggia per email, mai: viaggia solo un token a scadenza.

Da fare - invio dell'email di invito

L'invio esiste ed è implementato (IShellMailer su SendGrid via HttpClient: Email:SendGrid:ApiKey, Email:From - mittente verificato in SendGrid - e Email:FromName), ma non è configurato in nessuna installazione: nella pratica l'email non viene ancora spedita. Finché la chiave manca, o se il provider rifiuta, /admin/users mostra il link in un popup da copiare e consegnare a mano. È uno stato supportato di proposito, non un errore.

Restano da fare, quando si vorrà chiudere il capitolo:

  • configurare e collaudare davvero SendGrid (chiave, mittente verificato, recapito);
  • nessuna alternativa SMTP: oggi c'è solo SendGrid;
  • i testi dell'email sono nel codice della pagina utenti, non modificabili da amministratore né in template;
  • nessuna coda né ripetizione: un rifiuto del provider si vede come messaggio d'errore e finisce lì;
  • le email di Identity (conferma indirizzo, reset password) passano dallo stesso mailer solo se la chiave è configurata - altrimenti restano silenziose (IdentityNoOpEmailSender), quindi "Password dimenticata?" non recapita nulla.

Registrazione pubblica

Chiusa dietro Users:AllowSelfRegistration (false come spedito): con il flag a false il collegamento "Crea un account" non appare nella pagina di accesso, e le persone entrano solo per invito.

Organigramma

OrgUnit / OrgUnitMember riferiscono il profilo, non la login, così le persone entrano nei reparti prima di avere un account. L'indirizzamento colpisce il nodo esatto; la portata sul sottoalbero è un problema di chi legge (IOrgDirectory.GetSubtreeMemberIdsAsync). Le eliminazioni rifiutano le unità non vuote, gli spostamenti rifiutano i cicli.

Organigramma ad albero con conteggio membri
/admin/organization: gerarchia libera, azioni per riga (rinomina, membri, sotto-unità, elimina).
Popup dei membri di un'unità
I membri di un'unità: si scelgono tra le persone in anagrafica, login o no.

Estendere il profilo

La shell legge solo i claim dell'utente autenticato. Per portarle un campo tuo, aggiungilo a ApplicationUser ed esponilo come claim con un UserClaimsPrincipalFactory personalizzato: nessuna modifica alla shell.

8. Moduli aggiuntivi

8.1 Come funziona il sistema

Un modulo è un pacchetto NuGet, e basta. L'applicazione lo referenzia e lo accende in /admin/modules: non c'è una riga da scrivere nel suo Program.cs, né pagine da aggiungere al menu, né fogli di stile da collegare, né dotnet ef da lanciare. Per installazione si conserva solo lo stato di attivazione (più la chiave di licenza e le impostazioni del modulo).

È il modulo a dichiarare sé stesso, con un attributo di assembly e un IShellModule. La shell li cerca fra i pacchetti installati e registra quello che ciascuno dichiara.

[assembly: ShellModule(typeof(FatturazioneModule))]

public sealed class FatturazioneModule : IShellModule
{
    public ShellModuleDescriptor Descriptor =>
        new("fatturazione", "Fatturazione", "...", Version: "1.0", RequiresLicense: true)
        {
            SettingsEditorComponent = typeof(MioEditor),      // dentro la card di /admin/modules
            Localization = new("fatture", "Fatturazione", typeof(MieStringhe)),
            Stylesheets = ["fatturazione.css"],               // nel <head>, via <ModuleStyles />
            DatabaseContext = typeof(FattureDbContext),       // migrazioni applicate all'attivazione
            Navigation = [new("work", "Nav.Fatture", "fatture")]
        };

    public void ConfigureServices(ShellModuleContext context) { /* i servizi del modulo */ }
}
  • LayoutHostComponent - avvolge il corpo della pagina mentre il modulo è attivo (contratto: un parametro RenderFragment chiamato Body). Al massimo un modulo attivo può dichiararlo.
  • SettingsEditorComponent - viene reso dentro la card del modulo in /admin/modules; legge e salva le proprie impostazioni con IModuleService.
  • Localization - la famiglia di stringhe del modulo: registra lo scope, i file di seed, e risolve Name/Description della card e i testi delle voci di menu.
  • Navigation - le voci che il modulo porta nel rail, con la chiave della sezione in cui entrano; compaiono e spariscono con l'interruttore.
  • Stylesheets, PageAssembly, DatabaseContext - fogli di stile, pagine instradabili e tabelle proprie del modulo.

La scoperta legge il manifesto delle dipendenze (deps.json): sono candidate le librerie che dipendono dalla shell, e fra queste i moduli sono quelle che portano l'attributo. Non i riferimenti dell'assembly dell'applicazione: il compilatore non emette il riferimento a un assembly che il codice non usa mai - cioè esattamente il caso di un'applicazione che non nomina i suoi moduli da nessuna parte.

IModuleService.IsEnabled(chiave) è l'unica domanda che layout e pagine fanno: è vera quando il modulo è acceso per questa installazione e, se richiede licenza, la chiave salvata valida. Il validatore è una cucitura: ILicenseValidator. Quello che un modulo porta nelle cuciture additive della shell (IShellJob, ITaskListSource, ISearchProvider, sorgenti di notifica) è invece filtrato dalla shell stessa: un modulo spento è muto per costruzione, e torna a parlare appena lo si accende, senza riavvii.

Da fare Il validatore in uso è DemoLicenseValidator: accetta qualunque chiave non vuota. La verifica vera (firma, scadenza, vincolo all'installazione) va innestata lì, senza toccare il resto della shell.
Pagina dei moduli aggiuntivi con due card
/admin/modules: una card per modulo, interruttore di attivazione, versione, chiave di licenza e - se il modulo lo porta - il suo editor di impostazioni.

8.2 Modulo: layout multi-scheda

Promisoft.Dashboard.Modules.MultiTab apre le pagine in schede che restano vive: lo stato dei componenti è conservato quando si cambia scheda, le schede si riordinano trascinandole, si chiudono da menu contestuale e si ripristinano dopo un aggiornamento della pagina (persistite in localStorage). Sotto c'è la libreria Promisoft.Blazor.MultiTab, che non sa niente della shell.

Installazione

dotnet add package Promisoft.Dashboard.Modules.MultiTab --prerelease

Non serve altro: il modulo si registra da sé, e con lui la libreria sottostante.

Il --prerelease serve finché siamo alla 2.0.0-preview: senza versioni stabili pubblicate, dotnet add package si rifiuta di prenderne una e lo dice solo dopo aver scritto «Aggiunta dell'oggetto PackageReference», così sembra riuscito mentre il csproj è rimasto intatto. In alternativa si nomina la versione (-v 2.0.0-preview.6), che non ha bisogno del flag.

Il punto che si dimenticava Fino a preview.4 ogni assembly le cui pagine dovevano essere apribili in scheda andava nominato a mano nella registrazione, assembly dei moduli compresi, e una rotta che il sistema di schede non conosceva non si apriva - in silenzio, senza errori. Ora l'elenco arriva dalla shell (ShellPageAssemblies: shell, applicazione e ogni modulo installato), lo stesso che alimenta il Router e MapRazorComponents. Non c'è più niente da ricordare.

Impostazioni (dalla card in /admin/modules)

  • Numero massimo di schede aperte - al limite la scheda più vecchia, non attiva e chiudibile viene chiusa (comportamento LRU).
  • Quando le schede superano la finestra - vanno a capo su una nuova riga, oppure scorrono.
Barra delle schede con sei pagine aperte
Sei pagine aperte insieme: ognuna conserva griglia, filtri e riga focalizzata. Il titolo della scheda rispecchia il <PageTitle> della pagina.
Da sapere quando si sviluppa con il modulo attivo
  • Le schede non attive restano vive nel DOM: un querySelector globale può prendere la toolbar o il popup della pagina sbagliata. Vale per il codice JS e per i test end-to-end.
  • Una pagina tenuta viva non vede i dati creati altrove finché non si premono i suoi comandi di ricarica.
  • Lo stato di pagina va letto da rotta e query string, non da NavigationManager.Uri a ogni render: l'URL può essere quello di un'altra scheda.
  • Il layout host si applica solo ai circuiti interattivi e autenticati: in SSR statico (le pagine /Account) la shell rende il corpo classico.

8.3 Modulo: gestione attività

Promisoft.Dashboard.Modules.Tasks è il modulo a pagamento del lavoro: richieste tra colleghi e reparti, progetti con aree ad albero, bacheca Kanban, Gantt, scheda ore con tariffe e report mensile.

Installazione

dotnet add package Promisoft.Dashboard.Modules.Tasks --prerelease

Il --prerelease è obbligatorio finché non esce una versione stabile: vedi la nota al modulo multi-scheda, poco sopra.

Poi si accende in /admin/modules con la chiave di licenza. Non c'è altro: le sue pagine (/requests, /projects, /kanban, /gantt, /timesheet e le due amministrative) si instradano da sole, le sue voci compaiono nel rail tradotte, il suo foglio di stile entra nel <head> e le sue tabelle vengono create nel momento dell'attivazione.

Database

Il modulo ha il proprio DbContext sullo stesso database della shell (DashboardConnection) ma con una tabella di storia migrazioni separata, __TasksModuleMigrationsHistory. I riferimenti alle tabelle della shell (UserProfile, OrgUnit) e a Identity sono logici: le FK esistono solo tra tabelle del modulo. Le migrazioni le applica la shell quando un amministratore attiva il modulo; la card mostra quante ne restano in sospeso dopo un aggiornamento del pacchetto, con il pulsante per applicarle. Se il database non risponde il modulo resta spento, con il motivo scritto sulla card.

Attivazione

Il modulo richiede licenza: dopo l'installazione va acceso da /admin/modules inserendo una chiave (con il validatore demo qualunque testo non vuoto passa). Finché è spento è come se non ci fosse: niente voci nel menu, niente promemoria, niente richieste nelle attività personali. Le sue pagine restano raggiungibili per collegamento diretto, e spiegano che la licenza non è attiva.

Che cosa porta, in dettaglio

Richieste di lavoro (/requests) - il ciclo è Aperta → Presa in carico → Evasa, più Rifiutata e Annullata. L'autore è una login (CreatedByUserId); il destinatario è people-first: una persona oppure un'unità organizzativa, esattamente uno dei due. Scadenza come le attività personali (data più ora facoltativa), priorità, progetto e area facoltativi, conversazione in coda (i commenti non si cancellano). I permessi vivono nel servizio: il destinatario prende in carico, evade, rifiuta; l'autore modifica e annulla; commentano entrambi.

Pagina delle richieste, scheda Inviate
/requests: "Ricevute" e "Inviate", con la barra comandi che rispecchia i permessi sulla riga focalizzata.

Progetti e aree (/projects) - un progetto è ciò a cui si intestano attività e richieste. Visibilità: creatore o membro (filtrata in SQL); il creatore è membro per costruzione e l'unico che rinomina, archivia e gestisce le persone. I progetti non si cancellano, si archiviano: i riferimenti logici da altri contesti non devono restare appesi. Le aree sono un albero a profondità libera dentro il progetto (la forma dell'organigramma, riusandone la logica anti-ciclo); l'obbligo di scegliere un'area scatta se il progetto ha aree, e la loro esistenza è l'interruttore.

Pagina dei progetti
/projects: creatore, numero di membri, stato.

Kanban (/kanban) - la bacheca di un progetto, sul componente Kanban ufficiale DevExpress. Le colonne sono una lettura dell'avanzamento, non uno stato proprio: Da fare = 0%, In corso = 1-99%, Fatto = completata. Trascinare una card muta ProgressPercent passando dal servizio della shell, quindi bacheca e /tasks non possono mai essere in disaccordo. Le card sono le attività di tutti i membri (intestare a un progetto significa condividere); ognuno trascina solo le proprie, le altrui hanno il lucchetto - che il creatore del progetto può aprire.

Bacheca Kanban con tre colonne e card
/kanban: colonne riordinabili trascinando le intestazioni, iniziali del proprietario sulla card, percentuale su quelle in corso.

Gantt (/gantt) - il progetto su una linea del tempo, HTML e CSS puri su DxTreeList (nessuna dipendenza DevExtreme). Anche questo è una lettura: si modifica col popup delle attività, con gli stessi permessi della bacheca. Regole di onestà del disegno: barra solo con entrambe le date, una sola data = rombo di milestone, nessuna data = riga elencata senza barra; la data d'inizio non viene mai inferita. Le date di un'area sono il minimo e il massimo di tutto il suo sottoalbero, la percentuale è la media pesata sulla durata delle sole foglie - e senza denominatore si scrive "—", mai 0%.

Gantt ad albero con barre, milestone e righe senza barra
/gantt: colonne di sinistra congelate, scala mesi/settimane, weekend ombreggiati, linea di oggi.

Scheda ore, tariffe, report - /timesheet è la scheda personale mese per mese: ore a quarti, massimo 24 al giorno, su un'attività di progetto oppure su una descrizione libera. /admin/rates tiene le tariffe sulla persona con storia per data di validità: un aumento è una riga nuova, i mesi già pagati non cambiano; oraria o giornaliera con ore/giornata per il prorata. /admin/timesheet-report valorizza il mese per persona e destinazione applicando la tariffa vigente nel giorno della riga, con arrotondamento per riga così il totale è la somma di ciò che si legge. Senza tariffa il costo resta vuoto con un avviso, mai zero silenzioso.

Scheda ore personale
/timesheet.
Tariffe per persona
/admin/rates.
Report ore mensile
/admin/timesheet-report.

Come si innesta sulle cuciture della shell

  • ITaskListSource - le richieste prese in carico compaiono in /tasks.
  • ITaskCompletionObserver - prendere in carico una richiesta con progetto crea l'attività che la esegue; completare quell'attività evade la richiesta. Unidirezionale di proposito: riaprire l'attività non riapre la richiesta.
  • IShellJob - promemoria di scadenza delle richieste, visibili in /admin/jobs, riusando le regole pure della shell.
  • NotificationSourceDescriptor - quattro sorgenti (richieste ricevute, avanzamento, commenti, scadenze), silenziabili per utente.
  • IProjectCatalog - il modulo sostituisce (services.Replace) il catalogo vuoto della shell: senza modulo il campo progetto non esiste, con modulo disattivato il catalogo risponde vuoto.
  • IOrgDirectory, IUserDirectory - indirizzamento a unità e selettore di persone.
  • IMessagePublisher - un commento a una richiesta arriva nella posta di chi è coinvolto.
Regola imparata, utile a chi scrive un modulo Far crescere una cucitura implementata dai moduli (aggiungere un membro a un'interfaccia come IProjectCatalog) rompe i moduli già impacchettati con un errore di caricamento tipi. La shell è un NuGet: i membri nuovi vanno aggiunti come default interface member.

9. L'agente AI per le pagine CRUD

Le pagine di gestione dati non si scrivono a mano: le genera un agente AI (Claude Code) guidato da una skill che viaggia dentro il progetto, in .claude/skills/crud-scaffold/. Il template la porta già, quindi un'applicazione appena creata ce l'ha.

Come si avvia

Nella sessione dell'agente, dentro la cartella del progetto, si scrive la parola chiave - facoltativamente seguita dal nome dell'entità o della vista:

Automazione dxGrid Fatture

L'agente apre la checklist e la annuncia; da lì il flusso ha quattro fasi e non genera codice finché la specifica non è confermata.

Fase 1 - Le domande che decidono tutto

La prima domanda è la fonte dati, e sceglie il repository che la pagina inietterà:

  1. Sola lettura - IReadOnlyRepository: griglia senza editing su una vista o tabella.
  2. Lettura e modifica (singola) - IRepository: griglia e form sulla stessa tabella.
  3. Lettura e modifica (doppia) - IRepositoryDual: griglia su una vista denormalizzata, form sulla tabella. È il modello canonico (la pagina Prodotti).

Poi l'agente scandaglia la soluzione per i progetti dati (*.db/Models/*Context.cs) e chiede, con le opzioni scoperte nel codice: quale DbContext, quali sorgenti (vista di lettura e/o tabella di scrittura), Queryable o InMemory (tabelle di lookup sotto ~2000 righe), e l'ampiezza dell'editing (CRUD completo o solo aggiornamento).

Convenzione da verificare subito Nel modello doppio la vista di lettura deve esporre la chiave della tabella di scrittura. Se non la espone, l'agente si ferma e lo dice: il pattern si regge su quella.

Fase 2 - Introspezione e specifica

L'agente legge le classi del modello e l'OnModelCreating per quell'entità, poi scrive una bozza di specifica in docs/crud/<entità>.md già precompilata: colonne (spuntate quelle che portano identità, larghezze e formati suggeriti per id, date e importi), campi del form (obbligatori dai NOT NULL, lunghezze massime dagli HasMaxLength, un sottoinsieme rappresentativo se la tabella ha duecento colonne), combo per le chiavi esterne, totali candidati, filtri, concorrenza (rowversion presente o "ultimo che scrive vince") e le avvertenze di dominio. La bozza si legge, si corregge e si approva - le specifiche di docs/crud/products.md e customers.md sono gli esempi compilati.

Fase 3 - Generazione

Le convenzioni non si ridiscutono, l'agente le applica:

  • cartella per funzionalità: Components/Pages/<Entità>/ con <Entità>List.razor e <Entità>EditPopup.razor;
  • griglia su un contesto posseduto dal componente (IDbContextFactory) dentro un GridDevExtremeDataSource<TRead>;
  • barra comandi con Nuovo / Apri / Aggiorna / Esporta (xlsx e csv) / Colonne (il selettore nativo della griglia), verbi condivisi da CommonStrings;
  • modifica in popup con EditForm e DataAnnotationsValidator; le validazioni stanno in una classe parziale IValidatableObject nel progetto dati - il solo file scritto a mano lì, così sopravvive a un nuovo scaffolding EF;
  • eliminazione con popup di conferma della pagina (mai ShowDataItemDeleteConfirmation, che su un'istanza stantia della riga focalizzata non fa nulla in silenzio);
  • nessuna stringa scritta a mano nel markup: inglese neutro nel resx della pagina, tutto attraverso i localizzatori, marker aggiunto al catalogo così le chiavi appaiono in /admin/localisation;
  • voce nel rail attraverso INavigationProvider, come chiave di NavigationStrings;
  • errori a toast, compreso il conflitto di concorrenza; successo a toast più ricarica della griglia.

Fase 4 - Verifica

dotnet build, poi la pagina viene esercitata davvero nel browser: caricamento, filtri (i totali devono ricalcolare), apertura, salvataggio con errori di validazione e salvataggio pulito. Se emerge un comportamento nuovo di un repository, nasce un test accanto a RepositoryDualTests.cs.

La pagina Prodotti generata secondo il modello canonico
Il risultato: la pagina Prodotti è insieme il modello di riferimento della skill e il collaudo del generatore.
Se compare un progetto dati nuovo Tre cose da fare a mano: riferimento al progetto nel .csproj, AddDbContextFactory<TContext> nel Program.cs con la stringa di connessione <Database>Connection, versioni dei pacchetti EF allineate tra i progetti (altrimenti NU1605).

10. Localizzazione

L'inglese neutro vive nei resx (in Shell/Resources per la shell, in Resources per l'applicazione); ogni altra lingua vive nel database e si modifica da /admin/localisation. Le lingue spedite arrivano come JSON di seed (Data/Localization/it.seed.json, chiave di sezione = nome completo del tipo di risorsa) applicato a ogni avvio chiave per chiave: una chiave aggiunta a un seed atterra anche su installazioni che hanno già quella lingua, e un valore modificato da un amministratore non viene mai sovrascritto.

Il meccanismo è la sostituzione della IStringLocalizerFactory: lo store si stratifica sopra il resx senza che i componenti se ne accorgano. Anche le stringhe interne dei componenti DevExpress (pager, menu filtro, selettore colonne) passano da lì, sotto la pseudo-risorsa DevExpress.

Aggiungere una stringa di interfaccia significa toccare due file: il resx e il seed.

Pagina di localizzazione con elenco delle traduzioni
/admin/localisation: lingue abilitate, filtro per risorsa e sezione, "solo mancanti", export del seed di una cultura.
Da fare
  • La traduzione automatica è una cucitura non riempita: IMachineTranslator ha una sola implementazione che risponde "non configurato", e il pulsante nella pagina resta spento.
  • Le pagine /Account/* non sono localizzate: sono ancora i testi inglesi dello scaffolding di Identity.
  • Alcune stringhe che arrivano dai descrittori - nomi e descrizioni delle sorgenti di notifica, nomi dei job in /admin/jobs, nomi delle sorgenti di attività nella colonna "Origine" - sono in inglese: non passano dai resx, e i descrittori non hanno un marker di localizzazione come i moduli.

11. Chiavi di configurazione

ChiaveA che serve
ConnectionStrings:DefaultConnectionIl database Identity dell'applicazione.
ConnectionStrings:DashboardConnectionIl database della shell (e dei moduli). Obbligatoria: senza, l'avvio si ferma.
Bootstrap:AdminEmailL'account che all'avvio entra nel ruolo Admin.
Bootstrap:AdminPasswordSolo in sviluppo: crea quell'account se manca. Mai in produzione.
Users:AllowSelfRegistrationApre o chiude la registrazione pubblica (false come spedito).
Email:SendGrid:ApiKeyChiave SendGrid. Assente = nessun invio, link di invito da consegnare a mano.
Email:From / Email:FromNameMittente (indirizzo verificato in SendGrid) e nome visualizzato.
Tasks:ReminderTimeL'ora in cui il job spedisce i promemoria (08:00 locali del server per default).
Messages:RetentionDaysDopo quanti giorni si cancellano i messaggi letti (90; zero disattiva).
Messages:HardRetentionDaysDopo quanti giorni si cancella qualunque messaggio (365; zero disattiva).
Docs:UrlDove punta il collegamento "Documentazione" del rail. Vuoto = questa guida, servita dal pacchetto.
Demo:NotificationFeedSolo nell'host dimostrativo: il flusso di notifiche di esempio (attivo in Development).

12. Parti da completare

Onestà sullo stato: questa è la 2.0.0-preview. Quello che segue non è rotto - è dichiaratamente non finito.

Invio email Implementato su SendGrid, non configurato né collaudato in nessuna installazione. Di conseguenza l'invito a registrarsi non parte per email: il link si consegna a mano dal popup di /admin/users. Mancano anche l'alternativa SMTP, i template modificabili, la coda con ripetizione dei tentativi e il recapito delle email di Identity (conferma indirizzo, "password dimenticata"). Vedi §7.
Licenze dei moduli DemoLicenseValidator accetta qualunque chiave non vuota. Nessuna firma, nessuna scadenza, nessun vincolo all'installazione.
Localizzazione residua Pagine /Account in inglese; nomi e descrizioni di sorgenti di notifica, job e sorgenti di attività in inglese; traduzione automatica non configurata.
Fusi orari I promemoria e le scadenze usano l'orologio locale del server: i fusi per utente sono un affinamento futuro. Per il personale di una singola installazione il fuso del server è quello dell'ufficio.
Distribuzione Il feed è ospitato (GitHub Packages, organizzazione Promisoft-rc), ma la release resta manuale: build/release.ps1 packa e pubblica, il tag si mette a mano, non c'è una pipeline che lo faccia al posto tuo. Il feed è privato e richiede un token anche in lettura: per i clienti finali la base gratuita andrà su nuget.org, con i moduli a pagamento su un feed autenticato.
Guida Questa guida copre la shell e i due moduli disponibili. Non copre ancora: ricette di deployment (IIS, container, reverse proxy), backup e manutenzione dei due database, integrazione con un identity provider esterno, e non ha una versione inglese.

Vedi anche la guida dell'utente per il punto di vista di chi la usa ogni giorno, e CHANGELOG.md nel repository per la storia delle versioni.