Il problema: un'azienda operativa su fogli Excel e WhatsApp
Il cliente per cui ho costruito PanelControl è un distributore ufficiale di terminali di pagamento myPOS. Ogni giorno gestisce decine di ordini, centinaia di lead commerciali, attivazioni contrattuali, spedizioni, turni del personale, ferie, buste paga e un flusso costante di lead in arrivo da un CRM esterno. Prima di PanelControl, tutto questo viveva sparso tra fogli Excel, chat WhatsApp, email e telefonate — nessuna vista unica, nessuno storico affidabile, nessun modo di sapere in tempo reale cosa stesse facendo un collega nell'ufficio accanto.
Ho progettato e costruito da zero un gestionale web-based che centralizza tutta l'operatività — commerciale, onboarding, assistenza, spedizioni, HR, amministrazione — in un'unica Progressive Web App multi-ruolo, installabile su desktop e su mobile, usata quotidianamente anche su tablet e telefono dal team. Come per gli altri progetti su roversia.it, nessun framework: solo JavaScript caricato direttamente dal browser, per zero attrito nel deploy e nel debug diretto in produzione.
The problem: a business running on spreadsheets and WhatsApp
The client I built PanelControl for is an official reseller of myPOS payment terminals. Every day they handle dozens of orders, hundreds of sales leads, contract activations, shipments, staff shifts, time off, payroll, and a steady stream of leads coming in from an external CRM. Before PanelControl, all of this lived scattered across spreadsheets, WhatsApp chats, emails, and phone calls — no single view, no reliable history, no way to know in real time what a colleague in the next room was doing.
I designed and built from scratch a web-based business app that centralizes all of it — sales, onboarding, support, shipping, HR, administration — into a single multi-role Progressive Web App, installable on desktop and mobile, used daily on tablets and phones by the team. Like the other projects on roversia.it, no framework: just JavaScript loaded straight by the browser, for zero friction in deploy and in debugging directly in production.
Stack tecnologico
Nessuna riga in comune col resto del sito, ma la stessa filosofia: zero bundler sul frontend, dipendenze scelte una per una.
| Livello | Tecnologia |
|---|---|
| Frontend | HTML/CSS/JS vanilla, nessun bundler |
| Dati realtime | Firebase Realtime Database |
| Query/analytics | Firebase Firestore |
| Autenticazione | Firebase Auth con Custom Token, coniato lato server |
| File storage | Firebase Storage |
| Notifiche push | Firebase Cloud Messaging (v1, service account OAuth2) |
| Backend serverless | Netlify Functions, ESM, zero dipendenze npm |
| Hosting/CI-CD | Netlify, deploy automatico da GitHub |
| Grafici | Chart.js |
| Export dati | SheetJS (Excel), jsPDF (PDF) |
| IA integrata | Google Gemini API |
| CRM esterno | Delera / GoHighLevel, via webhook |
| Telefonia | Voxloud, Telephony Events API |
| Gmail API, OAuth2 |
Tech stack
Not a single line shared with the rest of the site, but the same philosophy: zero bundler on the frontend, dependencies picked one by one.
| Layer | Technology |
|---|---|
| Frontend | Vanilla HTML/CSS/JS, no bundler |
| Realtime data | Firebase Realtime Database |
| Query/analytics | Firebase Firestore |
| Authentication | Firebase Auth with server-minted Custom Tokens |
| File storage | Firebase Storage |
| Push notifications | Firebase Cloud Messaging (v1, service account OAuth2) |
| Serverless backend | Netlify Functions, ESM, zero npm dependencies |
| Hosting/CI-CD | Netlify, automatic deploy from GitHub |
| Charts | Chart.js |
| Data export | SheetJS (Excel), jsPDF (PDF) |
| Built-in AI | Google Gemini API |
| External CRM | Delera / GoHighLevel, via webhook |
| Telephony | Voxloud, Telephony Events API |
| Gmail API, OAuth2 |
Un rendering engine scritto a mano, senza React né Vue
L'intera UI è renderizzata da un motore fatto in casa: uno state object globale contiene tutti i dati applicativi — ordini, attivazioni, lead, operatori, turni, ferie, permessi. Ogni modifica allo stato invoca render(), con un debounce di 80ms per evitare re-render a cascata durante scritture multiple ravvicinate, più una renderNow() per i casi che richiedono aggiornamento sincrono immediato, come il cambio mese. Il router interno è uno switch sulla vista corrente, che seleziona quale funzione render*() chiamare — ciascuna definita nel proprio file, caricato come modulo separato.
La parte a cui tengo di più è la guardia di interazione utente: prima di ogni re-render, il motore controlla se il focus è su un campo di input, e in quel caso posticipa il render di qualche secondo, per non distruggere il DOM sotto le dita di chi sta scrivendo — con eccezioni esplicite per select di cambio mese e checkbox, che devono restare reattivi comunque. Senza un framework che gestisce il diffing per me, questa è la regola che ho dovuto scrivere a mano perché un aggiornamento realtime da Firebase non cancellasse mai un modulo mentre un operatore lo sta compilando.
Non tutti i moduli sono caricati al primo avvio. I file più pesanti sono caricati on-demand solo quando l'utente naviga verso quella sezione, con un loader che aspetta e ritenta se il modulo non è ancora arrivato in rete — utile su reti mobili lente, dato che il gestionale viene usato anche fuori sede su tablet e telefono.
A hand-written rendering engine, no React or Vue
The whole UI is rendered by a home-grown engine: a global state object holds every piece of application data — orders, activations, leads, operators, shifts, time off, permissions. Every state change triggers render(), debounced at 80ms to avoid cascading re-renders during closely spaced writes, plus a renderNow() for cases that need an immediate synchronous update, like switching months. The internal router is a switch on the current view, picking which render*() function to call — each defined in its own file, loaded as a separate module.
The part I'm most proud of is the user-interaction guard: before every re-render, the engine checks whether focus is on an input field, and if so postpones the render by a few seconds, so it never wipes out the DOM under someone's fingers while they're typing — with explicit exceptions for month-switch selects and checkboxes, which have to stay reactive regardless. Without a framework handling diffing for me, this is the rule I had to write by hand so a realtime update from Firebase would never erase a form an operator is filling in.
Not every module loads on first launch. The heaviest files load on demand, only when the user navigates to that section, with a loader that waits and retries if the module hasn't arrived over the network yet — useful on slow mobile connections, since the app is also used off-site on tablets and phones.
Da on('value') a listener granulari: il vero collo di bottiglia
Ogni nodo di dati critico è sincronizzato in tempo reale tra tutti i client connessi tramite i listener nativi di Firebase. L'ottimizzazione più significativa del progetto è stata la migrazione dai listener on('value') ai listener granulari child_added / child_changed / child_removed:
- Prima: ogni singola scrittura su un nodo — es. una nuova attivazione — causava il ri-scaricamento dell'intero storico da parte di ogni client connesso.
- Dopo: solo il delta, il record nuovo o modificato, viene trasmesso a ciascun client.
- Risultato misurato: risparmio stimato di 40-60 MB al giorno di banda Firebase, con impatto diretto sui costi del piano a consumo.
Lo stesso pattern, applicato al log attività, ha ridotto il caricamento iniziale da 200 a 50 record con once('value'), seguito da un listener child_added per i soli eventi nuovi — circa il 75% di banda risparmiata sul caricamento iniziale di quella sezione. È il tipo di ottimizzazione che non si vede nell'interfaccia ma si vede eccome nella bolletta.
From on('value') to granular listeners: the real bottleneck
Every critical data node syncs in real time across all connected clients through Firebase's native listeners. The single most impactful optimization in the project was migrating from on('value') listeners to granular child_added / child_changed / child_removed listeners:
- Before: every single write to a node — say, a new activation — caused every connected client to re-download the entire history.
- After: only the delta, the new or changed record, gets transmitted to each client.
- Measured result: an estimated 40-60 MB/day saved in Firebase bandwidth, with a direct impact on pay-as-you-go plan costs.
The same pattern, applied to the activity log, cut the initial load from 200 to 50 records via once('value'), followed by a child_added listener for new events only — roughly 75% bandwidth saved on that section's initial load. It's the kind of optimization that's invisible in the UI but very visible on the bill.
Autenticazione: niente più credenziali Firebase condivise
Il sistema di login è stato riprogettato da zero per eliminare le credenziali Firebase condivise lato client. Una Netlify Function verifica username e password con hashing PBKDF2-SHA256, applica rate limiting server-side contro il brute force, e restituisce un Firebase Custom Token coniato lato server. Il client scambia il token con una sessione Firebase Auth autenticata, con permessi granulari mappati via custom claim, verificati nelle regole di sicurezza del database.
Sopra il ruolo di base, ogni operatore può avere permessi specifici abilitati singolarmente da un pannello Admin dedicato. Il pattern architetturale scelto per ogni controllo di accesso è sempre lo stesso:
accesso = lista_legacy_hardcoded.includes(operatore)
OR hasAbilitazione(operatore, feature)
Mai la sola verifica dinamica. Questo per non rompere l'accesso degli operatori storici non ancora migrati esplicitamente al nuovo sistema a permessi granulari, garantendo continuità durante la transizione. È un principio che ho applicato in generale su tutte le migrazioni di permessi del progetto: ogni nuova regola si unisce in OR alla vecchia, mai la sostituisce di netto.
Authentication: no more shared Firebase credentials
The login system was redesigned from scratch to eliminate shared Firebase credentials on the client. A Netlify Function verifies username and password with PBKDF2-SHA256 hashing, applies server-side rate limiting against brute force, and returns a server-minted Firebase Custom Token. The client exchanges the token for an authenticated Firebase Auth session, with granular permissions mapped via custom claims, verified in the database's security rules.
On top of the base role, each operator can have specific permissions individually enabled from a dedicated Admin panel. The architectural pattern chosen for every access check is always the same:
access = legacy_hardcoded_list.includes(operator)
OR hasPermission(operator, feature)
Never the dynamic check alone. This is to avoid breaking access for legacy operators not yet explicitly migrated to the new granular permission system, keeping continuity during the transition. It's a principle I applied across every permission migration in the project: every new rule ORs with the old one, never replaces it outright.
Moduli che hanno posto i problemi più interessanti
Chat interna: il pattern "portal" per il position:fixed
La messaggistica tra operatori supporta foto, messaggi vocali, documenti e GIF, con una UI a bolla flottante. Il problema classico: position:fixed smette di funzionare correttamente quando un antenato ha una transform applicata via CSS, cosa comune quando si annidano modali e pannelli. L'ho risolto rendendo la chat un sibling diretto del contenitore principale, invece che un discendente — struttura del DOM, non trucco CSS.
Posta: polling invece di push, schema denormalizzato
Il modulo email interroga la Gmail API con un polling periodico invece che push, e usa uno schema dati denormalizzato su due nodi separati per bilanciare velocità di lista e di dettaglio. Su questo modulo i listener Firebase sono sempre granulari, mai on('value'): i nodi contengono corpi email potenzialmente pesanti, e un on('value') scaricherebbe l'intera mailbox a ogni minima modifica.
Telefonia: architettura ibrida per volumi alti
Lo storico chiamate integra l'API di Voxloud con un'architettura a due velocità: il mese corrente resta su Realtime Database, con listener limitato e paginazione "carica altri"; i mesi passati vengono spostati su Firestore, con paginazione a blocchi. Le ricerche instradano sempre su Firestore, per non saturare il database realtime con query pesanti su migliaia di record.
Riconciliazione con un CRM esterno a rate limit stretti
Una funzione schedulata confronta ogni lead con l'opportunità corrispondente nel CRM esterno, una chiamata alla volta invece che con dump massivi della pipeline, per restare dentro i limiti dell'API. Strategia a due velocità: lead recenti ricontrollati ogni 24 ore, lead più vecchi esclusi permanentemente dal ricontrollo per non consumare quota su dati ormai stabilizzati.
The modules that raised the most interesting problems
Internal chat: the "portal" pattern for position:fixed
Messaging between operators supports photos, voice messages, documents, and GIFs, with a floating bubble UI. The classic problem: position:fixed stops working correctly once an ancestor has a CSS transform applied, common when nesting modals and panels. I fixed it by making the chat a direct sibling of the main container instead of a descendant — a DOM structure choice, not a CSS trick.
Mail: polling instead of push, denormalized schema
The email module queries the Gmail API with periodic polling instead of push, and uses a denormalized data schema split across two separate nodes to balance list speed against detail speed. On this module, Firebase listeners are always granular, never on('value'): the nodes hold potentially heavy email bodies, and an on('value') would download the entire mailbox on any tiny change.
Telephony: a hybrid architecture for high volume
Call history integrates the Voxloud API with a two-speed architecture: the current month stays on the Realtime Database, with a capped listener and "load more" pagination; past months move to Firestore, with block pagination. Searches always route to Firestore, to avoid saturating the realtime database with heavy queries over thousands of records.
Reconciling with an external CRM under tight rate limits
A scheduled function checks each lead against its matching opportunity in the external CRM, one call at a time rather than bulk-dumping the pipeline, to stay within API limits. A two-speed strategy: recent leads rechecked every 24 hours, older leads permanently excluded from rechecking so they don't burn quota on data that's already settled.
Backend: funzioni serverless idempotenti e a prova di errore
Le funzioni backend sono scritte in ESM puro, deliberatamente senza dipendenze npm, per restare leggere e senza supply chain da mantenere. Alcuni pattern condivisi tra tutte le integrazioni con il CRM esterno:
- Un secret condiviso valida le richieste webhook in ingresso.
- Deduplicazione tramite una chiave di business — il numero ordine — invece degli ID tecnici generati dal CRM.
- Ogni webhook logga sia su Realtime Database sia su Firestore, per avere sia visibilità realtime sia query storiche.
- Alert critici inviati via Telegram con throttle per categoria, per evitare flood di notifiche in caso di errori a raffica.
- I webhook rispondono sempre HTTP 200, anche in caso di errore gestito internamente — scelta deliberata per evitare che il CRM esterno ritenti la stessa richiesta all'infinito.
Un vincolo operativo che ho dovuto imparare a gestire: le variabili d'ambiente di Netlify hanno un limite di 4KB per funzione. Credenziali grandi come chiavi private non vengono quindi salvate come variabile d'ambiente, ma tenute direttamente nel file della funzione, con una nota esplicita di rotazione manuale — evita di superare il limite e rompere il deploy, al costo di una procedura di rotazione meno automatica.
Backend: idempotent, fail-soft serverless functions
Backend functions are written in pure ESM, deliberately with no npm dependencies, to stay lightweight with no supply chain to maintain. Some shared patterns across every integration with the external CRM:
- A shared secret validates incoming webhook requests.
- Deduplication via a business key — the order number — instead of technical IDs generated by the CRM.
- Every webhook logs to both the Realtime Database and Firestore, for both realtime visibility and historical queries.
- Critical alerts sent via Telegram with per-category throttling, to avoid a flood of notifications during a burst of errors.
- Webhooks always respond HTTP 200, even when an error is handled internally — a deliberate choice to stop the external CRM from retrying the same request forever.
One operational constraint I had to learn to work around: Netlify environment variables have a 4KB limit per function. Large credentials like private keys aren't stored as environment variables, then, but kept directly in the function file, with an explicit note for manual rotation — it avoids hitting the limit and breaking the deploy, at the cost of a less automated rotation process.
Cosa mi porto a casa da questo progetto
PanelControl è il progetto che mi ha insegnato più di ogni altro che "senza framework" non significa "senza disciplina": significa scrivere a mano le regole che un framework darebbe per scontate — un debounce sensato, una guardia sull'interazione utente, un pattern di permessi non distruttivo — e poi rispettarle con costanza mentre il codice cresce da un file a sessantacinque. Lo stesso approccio di PWA installabile e zero dipendenze l'ho usato anche per i giochi di questo sito: cambia la natura del problema, ma la filosofia di fondo resta la stessa.
Se ti interessa la parte di autenticazione, ho raccontato più in dettaglio sia la migrazione a Custom Token per operatore sia il token HMAC cross-app usato per far comunicare in sicurezza PanelControl con altri gestionali dello stesso cliente.
What I take away from this project
PanelControl is the project that taught me, more than any other, that "no framework" doesn't mean "no discipline": it means writing by hand the rules a framework would otherwise give you for free — a sensible debounce, a user-interaction guard, a non-destructive permission pattern — and then sticking to them as the code grows from one file to sixty-five. I used the same installable, zero-dependency PWA approach for the games on this site: the nature of the problem changes, but the underlying philosophy stays the same.
If the authentication side interests you, I wrote in more detail about both the migration to per-operator Custom Tokens and the cross-app HMAC token used to let PanelControl talk securely with other business apps for the same client.
Domande frequenti
Conviene costruire un gestionale aziendale senza framework come React o Vue?
Per un singolo sviluppatore che conosce a fondo la propria base di codice, vanilla JavaScript con un piccolo motore di rendering fatto in casa elimina build step e dipendenze da aggiornare. Il costo si paga in disciplina: senza un framework che impone pattern, tocca mantenerli a mano nel tempo.
Come si riduce il consumo di banda di Firebase Realtime Database su nodi che crescono molto?
Passando da listener on('value') a listener granulari child_added/child_changed/child_removed, che trasmettono solo il delta modificato invece di riscaricare l'intero nodo a ogni scrittura. Il risparmio può arrivare al 70-75% sul caricamento iniziale.
Perché usare un Custom Token Firebase invece delle credenziali dirette lato client?
Con un Custom Token una funzione serverless verifica le credenziali con hashing e rate limiting, poi conia un token firmato lato server. Nessuna credenziale condivisa resta visibile nel client, e i permessi diventano custom claim verificabili nelle regole di sicurezza.
Come si gestiscono i permessi granulari senza rompere l'accesso degli utenti esistenti?
Con un OR non distruttivo: accesso concesso se l'utente è nella vecchia lista hardcoded oppure ha l'abilitazione granulare esplicita. Si introduce un sistema più fine senza dover migrare tutti nello stesso momento.
Perché le push notification su iOS richiedono l'SDK Firebase e non l'evento push nativo?
Il raw push event del Service Worker non risveglia in modo affidabile una PWA completamente chiusa su iOS. L'SDK Firebase Messaging con onBackgroundMessage gestisce correttamente decrittazione del payload e integrazione con APNs.
Frequently asked questions
Is it worth building a business app with no framework like React or Vue?
For a single developer who knows their codebase deeply, vanilla JavaScript with a small hand-built rendering engine removes build steps and dependencies to keep updated. The cost is discipline: without a framework enforcing patterns, you have to maintain them by hand over time.
How do you cut Firebase Realtime Database bandwidth on nodes that grow a lot?
By switching from on('value') listeners to granular child_added/child_changed/child_removed listeners, which transmit only the changed delta instead of redownloading the whole node on every write. Savings can reach 70-75% on initial load.
Why use a Firebase Custom Token instead of direct client-side credentials?
With a Custom Token, a serverless function verifies credentials with hashing and rate limiting, then mints a server-signed token. No shared credential stays visible on the client, and permissions become custom claims checkable in security rules.
How do you handle granular permissions without breaking existing users' access?
With a non-destructive OR: access is granted if the user is on the old hardcoded list or has the explicit granular permission. You introduce a finer system without migrating everyone at once.
Why do iOS push notifications need the Firebase SDK instead of the native push event?
The Service Worker's raw push event doesn't reliably wake a fully closed PWA on iOS. The Firebase Messaging SDK with onBackgroundMessage correctly handles payload decryption and APNs integration.