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, come si estende un'installazione già consegnata con un pacchetto di componenti 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.Components I contratti contro cui si compila un pacchetto di componenti (§9): gli attributi che rendono un componente collocabile, il contesto di pagina, i servizi che un componente può chiedere. Referenzia solo il framework: niente shell, niente DevExpress, e quindi nessuna licenza per chi lo compila. Linea di versione propria (1.x).
Promisoft.Dashboard.Templates Due template: dotnet new promisoft-dashboard (la sola zona applicativa, con shell e moduli presi dal feed) e dotnet new promisoft-components (un pacchetto di componenti con la sua applicazione di anteprima e la skill per l'agente).
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 / Promisoft1! 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.

    // L'autenticazione: account locali, che sono un pacchetto. Toglila, insieme al
    // riferimento, per autenticarsi altrove e non spedire nessuna pagina /Account.
    builder.Services.AddIdentityAccounts(builder.Configuration)
        .AddEntityFrameworkStores<ApplicationDbContext>();
    
    // 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.

E un CSS dell'installazione, in AppearanceSettings.CustomCss, emesso da <InstallationStyles /> come ultimo <style> della pagina - dopo i fogli della shell, quelli dei moduli e gli override delle superfici. Essere ultimo significa che basta pari specificità per vincere.

In linea e non come file servito, ed è tutto il senso della cosa: gli asset statici sono fingerprintati al build (MapStaticAssets), quindi un .css modificato in una cartella pubblicata non corrisponde più all'hash che il manifesto dichiara. Questo vive nel database, così cambia su un'applicazione che nessuno può ricompilare - il caso di un'installazione consegnata senza i sorgenti o senza una licenza DevExpress. Non costa nulla per richiesta: le impostazioni sono in cache in memoria e un documento Blazor Server si scarica una volta per sessione.

Due protezioni, perché il valore raggiunge la pagina attraverso un MarkupString: </ è rifiutato senza appello (chiuderebbe l'elemento in anticipo e il resto diventerebbe markup) e la lunghezza è limitata a 64 KB. Entrambe verificate al salvataggio e prima di emettere, perché lo store è un database che un'installazione può raggiungere anche con un query tool.

Nota tecnica In devtools quel blocco non risulterà dentro <head>. DevExpress vi rende <dxbl-resource-script>, e un elemento sconosciuto nell'head fa chiudere l'head al parser HTML: tutto ciò che segue - site.css e i fogli dei moduli compresi, da sempre - finisce nel body. La risposta del server è corretta; il DOM non è dove controllare.
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

L'autenticazione è un pacchetto, e la shell non ne nomina nulla. La shell contiene IUserDirectory e nessuna implementazione: non menziona ASP.NET Core Identity in nessun file. Legge righe DirectoryUser e chiede modifiche attraverso l'interfaccia.

Gli account locali sono Promisoft.Dashboard.Accounts.Identity: DashboardUser, IdentityUserDirectory, e l'intera famiglia /Account - login, registrazione, recupero password, gestione profilo, 2FA, codici di recupero, dati personali - come pagine compilate e già instradate. Non sono più sorgente dell'applicazione: sono due righe.

builder.Services.AddIdentityAccounts(builder.Configuration)
    .AddEntityFrameworkStores<ApplicationDbContext>();

È il default, non l'unica scelta. Un'applicazione che si autentica altrove (LDAP, un identity provider esterno, un archivio utenti che esiste già) toglie il riferimento al pacchetto e queste due righe, registra la propria implementazione di IUserDirectory e non spedisce nessuna pagina /Account. Tiene intatte tutte le pagine di amministrazione della shell.

Due cose restano all'applicazione di proposito. Il DbContext di Identity e le sue migrazioni - è uno schema standard che l'applicazione possiede, ed è anche il motivo per cui AddIdentityAccounts restituisce l'IdentityBuilder: un'installazione che esiste già conserva la sua storia delle migrazioni. E il tipo utente non si estende: quella riga è un login, la persona è UserProfile nella rubrica della shell. Un campo su una persona va lì, o in una tabella tua con chiave UserId.

Deliberatamente non è un IShellModule: i moduli si accendono e si spengono da /admin/moduli, e un sistema di login non è qualcosa che un'installazione commuta mentre gira. È un pacchetto fratello scelto in fase di build, come il provider PostgreSQL.

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.

Invio dell'email di invito

L'invio esiste (IShellMailer su SendGrid via HttpClient) e si configura in due modi: dall'amministratore in /admin/email - chiave API, mittente verificato in SendGrid, nome mittente, salvati nel database della shell con la chiave cifrata, più un pulsante che spedisce un messaggio di prova - oppure nel file di configurazione (Email:SendGrid:ApiKey, Email:From, Email:FromName). Quello che è salvato nella pagina prevale, campo per campo. 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. Non è ancora stato collaudato con un account SendGrid reale.

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

Il tipo utente non è il posto giusto: quella riga è un login, e con gli account locali la possiede il pacchetto. La persona è UserProfile, nella rubrica della shell, che raggiunge il login logicamente per UserId.

Quindi, in ordine di preferenza: un campo che descrive una persona e serve a tutti va su UserProfile; un campo che serve a una sola installazione va in una tabella tua con chiave UserId, che è il pattern usato in tutta la shell (nessuna chiave esterna fra database, riferimento logico per stringa) e non richiede di toccare né la shell né il pacchetto di autenticazione.

Se invece il valore deve viaggiare nel cookie di autenticazione ed essere leggibile senza una query, la via è un UserClaimsPrincipalFactory personalizzato nell'applicazione: la shell legge solo i claim dell'utente autenticato, e il claim può venire da qualunque tabella, compresa quella qui sopra.

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. Pacchetti di componenti e pagine composte

Un'applicazione consegnata come binario chiuso - il cliente la esegue, non la ricompila - cresce lo stesso: con un pacchetto di componenti (pack), una Razor Class Library che chiunque può compilare contro il solo pacchetto dei contratti, copiata nella cartella component-packs/ dell'installazione e letta all'avvio. Un amministratore compone poi pagine con quei componenti da /admin/pages, senza che nessuno compili nulla: la shell le serve a /p/<slug> e le mette nel rail. La forma lunga, con le trappole incontrate, è docs/component-packs.md nel repository.

9.1 Perché esistono

La shell referenzia DevExpress Blazor, e DevExpress compila un analizzatore dentro DevExpress.Data che gira in ogni progetto che ha quel pacchetto nel grafo - anche transitivamente attraverso la shell, anche senza nominare un solo tipo DevExpress - e scrive un attributo di licenza nell'assembly compilato. A runtime viene letto l'attributo dell'assembly d'ingresso: una shell licenziata non copre l'applicazione che la ospita. Quindi chi compila un progetto che referenzia la shell ha bisogno di una licenza DevExpress propria. Cancellare le pagine demo non cambia nulla.

Il pack gira intorno al muro: referenzia Promisoft.Dashboard.Components, che referenzia il framework condiviso e nient'altro. Il suo build non ha vendor dentro - verificato: nessun DevExpress nel deps.json, nessun DXLicenseInformationAttribute nella DLL. Chi scrive un pack non vede nemmeno quale libreria di componenti sta dentro la shell, e sostituirla domani non lo tocca.

Non è un modulo Un modulo (§8) è un pacchetto NuGet che l'applicazione referenzia e ricompila; porta pagine proprie, tabelle, licenza, un interruttore in /admin/modules. Un pack arriva dopo la compilazione, da una cartella, non ha attivazione né DbContext, e i suoi componenti non sono pagine ma pezzi che un amministratore mette su una pagina. Se stai scrivendo qualcosa che Promisoft rilascia a tutte le installazioni, è un modulo; se stai aggiungendo un riquadro a questa installazione senza ricompilarla, è un pack.

9.2 Il contratto

TipoA che serve
[assembly: ComponentPack(typeof(...))], IComponentPack, ComponentPackDescriptor Che cosa rende l'assembly un pack: chiave, nome, versione, i fogli di stile del suo wwwroot. ConfigureServices è facoltativo - quasi nessun pack registra qualcosa.
[DashboardComponent(key, displayName)] Sul componente Razor: lo rende collocabile. Chiave stabile in minuscolo con trattini, unica nel pack; le pagine salvano la chiave, quindi rinominare la classe è gratis e rinominare la chiave orfana le pagine. Category raggruppa nel selettore.
[ComponentParameter(Label, Help, Hidden)] Sui [Parameter]: che cosa legge l'amministratore nel form e la frase che evita la domanda.
DashboardPageContext Cascading parameter, nullo fuori da una pagina composta: slug e titolo della pagina, posizione della regione.
ICurrentUser La persona autenticata: id, nome visualizzato, ruoli. Il primo dei servizi della shell che un componente può iniettare; gli altri entrano nel pacchetto quando un componente reale li chiede.

Ogni tipo del contratto cresce per proprietà, mai per parametri di costruttore: un pack compilato contro una versione precedente deve continuare a caricarsi.

9.3 Scrivere un pack

dotnet new install Promisoft.Dashboard.Templates
dotnet new promisoft-components -n Acme.Widgets

Nascono due progetti: Acme.Widgets, il pack con due componenti d'esempio e un wwwroot/sample.css, e Acme.Widgets.Preview (§9.6). Un componente è così:

@attribute [DashboardComponent("kpi-tile", "KPI tile",
    Description = "Una cifra con didascalia.", Category = "Cifre")]
@inject ICurrentUser User

<section class="acme-kpi">
    <span class="acme-kpi-caption">@Caption</span>
    <strong>@Value.ToString(Format)</strong>
</section>

@code {
    [Parameter, ComponentParameter(Label = "Didascalia")]
    public string Caption { get; set; } = "Ordini aperti";

    [Parameter, ComponentParameter(Label = "Valore")]
    public decimal Value { get; set; }

    [Parameter, ComponentParameter(Label = "Formato", Help = "Formato .NET: n0, n2, c.")]
    public string Format { get; set; } = "n0";

    [CascadingParameter] public DashboardPageContext? Page { get; set; }
}

Le regole, ciascuna un difetto già visto o certo:

  • Solo HTML, CSS e C#, più i contratti. DxGrid, DxButton, Card, PageHeader qui non esistono: il pack non referenzia la shell, ed è questo il punto.
  • Colori dai token della shell - var(--shell-surface), var(--shell-content), var(--shell-border), var(--shell-accent), sempre con un fallback - così il componente segue il tema dell'installazione e il modo scuro per costruzione. Un colore letterale rompe il tema scuro. CSS in wwwroot/, dichiarato in Stylesheets, classi con il prefisso del pack.
  • Ogni parametro ha un default che rende qualcosa di sensato: galleria e form partono dai default. E ogni parametro è di un tipo che fa il giro del JSON: stringhe, numeri, booleani, enum, DateOnly, classi semplici di questi. Niente delegati né RenderFragment: un amministratore non li può digitare.
  • Null è un valore su ogni parametro di riferimento: il form può svuotare un campo.
  • Niente inizializzatori JS (<Assembly>.lib.module.js non gira per un pack caricato a runtime): il JavaScript si importa con IJSRuntime.
  • Lavoro costoso in OnInitializedAsync, protetto: un componente che lancia è una regione rotta con un avviso, un componente che si blocca blocca la pagina.

La skill dashboard-component, che il template porta in .claude/skills/, guida l'agente attraverso queste regole: parola chiave "Nuovo componente", domande prima, checklist alla fine.

9.4 Come viene caricato

La cartella è Components:Folder nella configurazione, per default component-packs/ sotto la radice dei contenuti - accanto ad appsettings.json in sviluppo, accanto al binario in produzione. È un percorso di deploy dell'amministratore di sistema: una DLL lì dentro gira con i privilegi dell'applicazione, e niente nell'interfaccia vi scrive. Un pack è una sotto-cartella con l'output di build (<Nome>.dll, <Nome>.deps.json, cartelle satellite) più il suo wwwroot/; il target PackDeployFolder del csproj generato fa la copia a ogni build.

  • Un AssemblyLoadContext per pack, con regola "prima l'host": tutto ciò che il contesto predefinito sa risolvere - framework, Blazor, i contratti, la shell - viene da lì, e la copia del pack non viene mai aperta. È ciò che fa coincidere i tipi: i contratti esistono una volta sola nel processo. Solo ciò che l'host non ha arriva dalla cartella del pack, via deps.json.
  • Non scaricabile: un pack si aggiorna riavviando l'applicazione. I pack si leggono all'avvio, prima che il container esista.
  • Un pack rotto non ferma l'avvio: DLL mancante, attributo assente, costruttore che lancia, due pack con la stessa chiave - ogni caso è una riga in /admin/diagnostics (controllo "Component packs"), che è il primo posto dove guardare quando "il mio componente non c'è". Un pack che porta una copia della shell o di DevExpress viene nominato lì: vuol dire che è stato compilato contro la shell.
  • Asset statici: il manifest dell'host è stato scritto alla compilazione e non conosce il pack, quindi la shell serve da sé il wwwroot del pack sotto _content/<Assembly>/ e <ModuleStyles /> collega i fogli dichiarati.
Mai chiamare la cartella components Su Windows è la cartella Components/ dell'applicazione Blazor, e il primo pack di prova si è copiato fra App.razor e Routes.razor. Da qui component-packs/.

9.5 Le pagine composte

Una pagina è una riga della tabella DynamicPage della shell: slug, titolo, posizione nel rail (la chiave di una sezione, oppure null per stare in cima accanto alla Home con un'icona e una posizione), ruoli, e le regioni come JSON ([{ "component": "acme/kpi-tile", "parameters": { ... } }]). La sola pagina compilata è /p/{slug}: trova la definizione, verifica i ruoli (nessun ruolo = ogni utente autenticato) e rende una regione dopo l'altra con DynamicComponent, convertendo il JSON salvato nei tipi dei parametri. Due tolleranze che un form richiede: un parametro stringa accetta qualunque scalare, un booleano scritto come testo si legge come booleano. Ciò che non combacia viene saltato e segnalato - solo agli amministratori, sotto la regione. Un componente il cui pack è sparito è un avviso; un componente che lancia è un ErrorBoundary, non una pagina rotta.

Il rail: una pagina nomina una sezione. Una chiave esistente (le sezioni della shell, quelle dell'applicazione) accoglie la voce; una chiave nuova crea la sezione con il titolo e l'icona della pagina, dopo Utilità e prima di Amministrazione. La rail, l'elenco delle pagine e una copia aperta della pagina si ridisegnano al salvataggio, senza riavvio.

/admin/components è la galleria: ogni componente del catalogo reso con i suoi default, ciascuno dentro il suo ErrorBoundary. È la sandbox dentro la shell vera: un pack appena copiato si vede lì prima di comporre una pagina. /admin/pages è l'editor, descritto nella guida dell'utente.

9.6 Anteprima e template

Il progetto <Nome>.Preview generato dal template è una piccola applicazione Blazor Server che ospita il pack dentro un'imitazione della shell: barra superiore, rail, area contenuto, interruttore chiaro/scuro sui token veri della shell (wwwroot/shell-tokens.css, generato dal foglio di stile della shell e da non modificare a mano), un utente finto configurabile in appsettings.json. La galleria rende ogni componente con i default; /c/<pack>/<chiave> lascia digitare il JSON dei parametri e applicarlo, segnalando gli errori come farebbe la shell. F5, hot reload, debugger: il pack è un riferimento a progetto.

È un'imitazione dichiarata: la shell vera non è referenziabile senza portarsi DevExpress. Il controllo finale resta il dashboard: pack copiato nella sua cartella, riavvio, /admin/diagnostics, /admin/components, una pagina da /admin/pages.

9.7 Componenti nativi: griglia, form, grafico

Un pack è HTML e CSS; la pagina che ogni installazione chiede per prima - una griglia, un form, un grafico sui propri dati - ha bisogno del vendor, e il vendor resta dentro la shell. Per questo la shell porta tre componenti nativi, nel catalogo sotto la sorgente shell, il cui comportamento è dato che l'amministratore digita nell'editor:

ComponenteChe cosa fa
Data grid shell/data-grid Le righe di una vista o tabella: ordinamento, filtri, raggruppamento, paginazione, ricerca, totali, export xlsx/csv, selettore colonne. Colonne, intestazioni, larghezze, formati e totali scelti nell'editor; vuoto = tutte le colonne. In memoria fino al limite di righe, oppure in modalità server (ServerMode): la griglia è legata a una GridCustomDataSource e il database ordina, filtra, pagina, totalizza e raggruppa (un livello). I criteri DevExpress sono tradotti una volta in un modello di interrogazione neutro (DataQuery, DataFilter) e da lì in SQL parametrizzato; ciò che non si traduce viene rifiutato con un messaggio, mai approssimato. Niente export in modalità server.
Data form shell/data-form Griglia su una vista o tabella e finestra di modifica generata dalle colonne della tabella che scrive (testo, memo, numero, casella, data; obbligatorio dalla nullabilità; chiave bloccata). Nuovo, modifica, elimina con conferma. Il pattern duale di RepositoryDual come configurazione: la griglia legge la vista denormalizzata, la finestra legge e scrive la tabella (WriteObject), agganciate dalla chiave che la vista espone (WriteKeyField se i nomi differiscono); stessa sorgente per entrambe. Le chiavi esterne della tabella verso tabelle esposte diventano tendine (Lookups: campo, tabella, colonna chiave, colonna testo), proposte dall'editor leggendo le relazioni dal catalogo del database. Concorrenza: ultimo che scrive vince, detto chiaramente.
Data chart shell/data-chart Barre, linee, aree o torta su una vista: una colonna argomento, una o più colonne valore piegate da un'aggregazione (somma, conteggio, media, minimo, massimo) in una GROUP BY.
Data filter shell/data-filter I valori distinti di una colonna di una vista o tabella, pubblicati alla pagina con un nome (PublishAs). I componenti di una pagina comunicano attraverso IPageSignals, un'interfaccia dei contratti (1.1) cascata dalla pagina composta, una per pagina aperta: griglia e form pubblicano una colonna della riga selezionata (PublishField, PublishAs), griglia, form e grafico ascoltano (Listen: nome pubblicato, colonna) e il valore diventa un'uguaglianza nel filtro neutro, sommata al WHERE in modalità server. Anche un componente di un pack può ascoltare o pubblicare.

Le sorgenti dati sono chiuse. Un amministratore non digita mai il nome di una tabella su una connessione a sua scelta. Components:DataSources nella configurazione elenca le sorgenti - un nome, una connessione (o il nome di una sotto ConnectionStrings) e le viste e tabelle di essa che si possono collocare; "*" apre ogni vista e nessuna tabella:

"Components": {
  "DataSources": [
    {
      "Name": "demo",
      "DisplayName": "Demo (Northwind)",
      "ConnectionStringName": "DemoConnection",
      "Objects": [ "dbo.ProductsList", "dbo.Products", "dbo.Customers" ]
    }
  ]
}
  • Chi amministra il server decide che cosa è esponibile; chi amministra l'applicazione compone dentro quel perimetro. Ogni nome di oggetto e di colonna che arriva da una pagina viene confrontato con lo schema letto dal database prima di entrare in una query, i valori viaggiano come parametri, e nessun punto del codice accetta SQL da una pagina.
  • Le connessioni si aprono con il provider dell'installazione: un'installazione SQL Server legge sorgenti SQL Server, una PostgreSQL legge PostgreSQL, senza driver aggiunti alla shell. Una sorgente su un motore diverso non è supportata.
  • Le letture tornano in memoria, con un tetto di righe (5000 per default) e un avviso quando il tetto ha tagliato; la griglia ordina, filtra, pagina ed esporta entro quelle.
  • Il controllo "Data sources" in /admin/diagnostics dice se ogni sorgente risponde e se la sua lista nomina cose che esistono.

Ogni componente nativo ha un editor proprio nella finestra della pagina: scelta della sorgente e dell'oggetto dagli elenchi letti dal database, colonne con spunta e intestazione, chiave, serie del grafico. Lo dichiara con l'attributo [ComponentEditor], che è della shell e non dei contratti: un pack non può portare un editor che la shell sappia rendere, e il contratto resta piccolo.

10. 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).

11. 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.

12. 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. Quello che l'amministratore salva in /admin/email prevale.
Email:From / Email:FromNameMittente (indirizzo verificato in SendGrid) e nome visualizzato. Anche questi si impostano in /admin/email.
Accounts:PasswordMinimumLengthLunghezza minima della password (10). Restano attive le regole di Identity su cifre, maiuscole e simboli.
Accounts:LockoutMaxFailedAttempts / Accounts:LockoutMinutesTentativi falliti prima del blocco dell'account (5) e durata del blocco (15 minuti).
Accounts:SessionDaysDurata della sessione senza attività, scorrevole (7 giorni).
Accounts:ThrottleMaxAttempts / Accounts:ThrottleWindowMinutesTentativi che un chiamante può fare contro login, reset e invito nella finestra (20 in 10 minuti).
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).
Components:FolderLa cartella dei pacchetti di componenti (§9). Relativa alla radice dei contenuti; component-packs per default. Letta all'avvio.
Components:DataSourcesLe sorgenti dati dei componenti nativi (§9.7): nome, connessione, viste e tabelle esponibili. Assente = i componenti nativi non hanno nulla da leggere.
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).

13. 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 e configurabile dall'amministratore in /admin/email (chiave, mittente, nome mittente, con un pulsante di prova), ma non ancora collaudato in un'installazione reale. Finché non è configurato, il link di invito si consegna a mano dal popup di /admin/users. Mancano l'alternativa SMTP, i template modificabili e la coda con ripetizione dei tentativi. Vedi §7.
Licenze dei moduli Le chiavi sono firmate da Promisoft e verificate dalla shell: perpetue, con la fine della manutenzione confrontata con la data di rilascio del pacchetto, e una variante demo di trenta giorni. Le installazioni vengono contate dal servizio licensing.promisoft.it all'accensione del modulo e ricontrollate ogni giorno; un servizio irraggiungibile non ferma nulla, un rifiuto esplicito (limite raggiunto, licenza revocata) spegne il modulo con la frase del servizio sulla card. Il servizio è scritto e testato ma non ancora pubblicato: finché non lo è, ogni installazione risulta "servizio non raggiungibile" e continua a funzionare.
Localizzazione residua Pagine /Account in inglese; nomi e descrizioni di sorgenti di notifica, job e sorgenti di attività in inglese; traduzione automatica non configurata.
Pacchetti di componenti Il contratto espone due servizi, ICurrentUser e IPageSignals: impostazioni, notifiche e il resto entrano quando un componente reale li chiede. Un pack non può ancora dichiarare una famiglia di stringhe per /admin/localisation. I collegamenti fra regioni sono uguaglianze su una colonna (niente intervalli né "contiene"); una regione occupa una frazione della riga e fino a tre righe; nessuna sorgente su un motore diverso da quello dell'installazione; un pack nuovo o aggiornato si vede dopo un riavvio.
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.