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.
| Pezzo | Che 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 dalnuget.configche 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.
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.
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.
-
Riferimenti nel
.csproj(più ilnuget.configcon il feed Promisoft):<PackageReference Include="Promisoft.Dashboard.Shell" Version="2.0.0-preview.25" /> <PackageReference Include="DevExpress.Blazor" Version="26.1.*" /> -
Stringhe di connessione:
DashboardConnection(la shell la pretende all'avvio) accanto a quella di Identity. -
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)); -
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>(); -
Router (
Routes.razor): le pagine della shell (/admin/*,/settings/*,/search,/tasks,/messages) vivono nel suo assembly, quelle dei moduli nei loro;ShellPageAssembliesle 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> -
Endpoint (
Program.cs, dopoUseRequestLocalization()):app.MapRazorComponents<App>() .AddInteractiveServerRenderMode() .AddShellPageAssemblies(app.Services) // shell + moduli installati .AllowAnonymous(); app.MapPromisoftDashboardShell(); // cookie della cultura, logo, export traduzioni -
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>ActiveThemeviene daAppThemes.Resolve(cookie, Appearance.Current.Accent): il modo chiaro o scuro dal cookie del browser, l'accento dalle impostazioni dell'installazione. -
_Imports.razor: aggiungiPromisoft.Dashboard.Shell.Components.Layout,...Shell.Resources,...Shell.ServiceseDevExpress.Blazor.
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
- 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 cookieshell.drawer, cheApp.razorrilegge 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.themepiù 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. PageHeadereCommandBar- titolo, sottotitolo, breadcrumb e barra comandi che le pagine dichiarano dove vogliono e che atterrano in cima all'area di contenuto tramiteSectionOutlet.- Icone - SVG usati come
mask-image(20x20, tratto 1.5) registrati inicons.csse usati comeIconCssClass="icon icon-<nome>": seguono il colore del tema per costruzione.
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.
/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"));
/admin/notifications: il banco di prova manda una notifica
vera attraverso la pipeline vera.
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).
/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.
/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.
/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.
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);
/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à:
| Interfaccia | Quando |
|---|---|
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.
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.
/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.
/admin/roles: ruoli e conteggio membri.
/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:
| Stato | Come si ottiene |
|---|---|
| In attesa | Creata (magari invitata) ma mai entrata: la login può ancora non esistere. |
| Attivo | ActivatedUtc valorizzato: la persona ha impostato la password seguendo il link di invito. |
| Disabilitato | DisabledUtc valorizzato: resta in anagrafica e nell'organigramma, la login è bloccata e le sessioni aperte muoiono. |
Il flusso di invito
- Nuovo utente crea la persona: nessuna login, stato "In attesa". Assegnabile subito a un'unità organizzativa.
- 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.
- 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 timbraActivatedUtc.
Nessuna password viaggia per email, mai: viaggia solo un token a scadenza.
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.
/admin/organization: gerarchia libera, azioni per riga
(rinomina, membri, sotto-unità, elimina).
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 parametroRenderFragmentchiamatoBody). Al massimo un modulo attivo può dichiararlo.SettingsEditorComponent- viene reso dentro la card del modulo in/admin/modules; legge e salva le proprie impostazioni conIModuleService.Localization- la famiglia di stringhe del modulo: registra lo scope, i file di seed, e risolveName/Descriptiondella 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.
DemoLicenseValidator: accetta qualunque chiave
non vuota. La verifica vera (firma, scadenza, vincolo all'installazione) va
innestata lì, senza toccare il resto della shell.
/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.
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.
<PageTitle> della
pagina.- Le schede non attive restano vive nel DOM: un
querySelectorglobale 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.Uria 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.
/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.
/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.
/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: 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.
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.
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à:
- Sola lettura -
IReadOnlyRepository: griglia senza editing su una vista o tabella. - Lettura e modifica (singola) -
IRepository: griglia e form sulla stessa tabella. - 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).
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.razore<Entità>EditPopup.razor; - griglia su un contesto posseduto dal componente
(
IDbContextFactory) dentro unGridDevExtremeDataSource<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
EditFormeDataAnnotationsValidator; le validazioni stanno in una classe parzialeIValidatableObjectnel 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 diNavigationStrings; - 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.
.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.
/admin/localisation: lingue abilitate, filtro per risorsa e
sezione, "solo mancanti", export del seed di una cultura.- La traduzione automatica è una cucitura non riempita:
IMachineTranslatorha 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
| Chiave | A che serve |
|---|---|
ConnectionStrings:DefaultConnection | Il database Identity dell'applicazione. |
ConnectionStrings:DashboardConnection | Il database della shell (e dei moduli). Obbligatoria: senza, l'avvio si ferma. |
Bootstrap:AdminEmail | L'account che all'avvio entra nel ruolo Admin. |
Bootstrap:AdminPassword | Solo in sviluppo: crea quell'account se manca. Mai in produzione. |
Users:AllowSelfRegistration | Apre o chiude la registrazione pubblica (false come spedito). |
Email:SendGrid:ApiKey | Chiave SendGrid. Assente = nessun invio, link di invito da consegnare a mano. |
Email:From / Email:FromName | Mittente (indirizzo verificato in SendGrid) e nome visualizzato. |
Tasks:ReminderTime | L'ora in cui il job spedisce i promemoria (08:00 locali del server per default). |
Messages:RetentionDays | Dopo quanti giorni si cancellano i messaggi letti (90; zero disattiva). |
Messages:HardRetentionDays | Dopo quanti giorni si cancella qualunque messaggio (365; zero disattiva). |
Docs:Url | Dove punta il collegamento "Documentazione" del rail. Vuoto = questa guida, servita dal pacchetto. |
Demo:NotificationFeed | Solo 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.
/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.
DemoLicenseValidator accetta qualunque chiave non vuota. Nessuna firma,
nessuna scadenza, nessun vincolo all'installazione.
/Account in inglese; nomi e descrizioni di sorgenti di notifica, job e
sorgenti di attività in inglese; traduzione automatica non configurata.
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.
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.



