/* global React, DictoIcons */ // backoffice.jsx — Dicto BO · Settings/Backoffice prototype // Roles: Super Admin (dev / DictoAI team), Admin (cliente owner), Configurator (editor), Viewer (viewer) // Re-runner is excluded as a simulated viewing role — it's a postazione that admins can assign. const { IconBack, IconBuilding, IconUser, IconUsers, IconSettings, IconBook, IconLayers, IconCheck, IconChart, IconChevDown, IconChevRight, IconSparkles, IconPlus, IconX, IconSearch, IconBolt, IconLock, IconDots, IconTrash, IconUpload, IconTag, IconInfo, } = window.DictoIcons; // ─── Mock data: customer organizations (the orgs Dicto sells to) ─ const INITIAL_ORGANIZATIONS = [ { id:'isp', name:'Intesa Sanpaolo', domain:'intesasanpaolo.com', color:'#0B6B3A', plan:'Enterprise', totalSeats:15, availableSeats:7, totalMembers:8, unassignedMembers:1, projects:4 }, { id:'edi', name:'Edison', domain:'edison.it', color:'#1D4ED8', plan:'Growth', totalSeats:10, availableSeats:4, totalMembers:6, unassignedMembers:0, projects:3 }, { id:'ferr',name:'Ferrero S.p.A.', domain:'ferrero.com', color:'#E94E1B', plan:'Enterprise', totalSeats:20, availableSeats:8, totalMembers:12, unassignedMembers:2, projects:6 }, { id:'gen', name:'Generali Italia', domain:'generali.it', color:'#9B1B30', plan:'Growth', totalSeats:8, availableSeats:3, totalMembers:5, unassignedMembers:0, projects:2 }, ]; const TEAMS_BY_ORG = { isp: ['Comms & Reputation', 'Investor Relations', 'Public Affairs', 'Risk & Compliance', 'Non assegnato'], edi: ['Brand & Sustainability', 'Public Affairs', 'Marketing', 'Non assegnato'], ferr: ['Brand Intelligence', 'Comms & Reputation', 'Public Affairs', 'Non assegnato'], gen: ['Brand & Reputation', 'Investor Relations', 'Non assegnato'], }; const MEMBERS_BY_ORG = { isp: [ { id:'i1', name:'Carlo Messina', email:'carlo.messina@intesasanpaolo.com', seat:'admin', team:'Comms & Reputation', status:'active' }, { id:'i2', name:'Marina Natale', email:'marina.natale@intesasanpaolo.com', seat:'configurator', team:'Investor Relations', status:'active', isYou:true }, { id:'i3', name:'Stefano Del Punta', email:'stefano.delpunta@intesasanpaolo.com', seat:'configurator', team:'Risk & Compliance', status:'active' }, { id:'i4', name:'Paola Angeletti', email:'paola.angeletti@intesasanpaolo.com', seat:'runner', team:'Comms & Reputation', status:'active' }, { id:'i5', name:'Andrea Munari', email:'andrea.munari@intesasanpaolo.com', seat:'runner', team:'Public Affairs', status:'active' }, { id:'i6', name:'Elena Goitini', email:'elena.goitini@intesasanpaolo.com', seat:'viewer', team:'Comms & Reputation', status:'active' }, { id:'i7', name:'Marco Siracusano', email:'marco.siracusano@intesasanpaolo.com', seat:'viewer', team:'Investor Relations', status:'invited' }, { id:'i8', name:'Giulia Tonelli', email:'giulia.tonelli@intesasanpaolo.com', seat:'unassigned', team:'Non assegnato', status:'invited' }, ], edi: [ { id:'e1', name:'Nicola Monti', email:'nicola.monti@edison.it', seat:'admin', team:'Brand & Sustainability', status:'active' }, { id:'e2', name:'Cristina Parenti', email:'cristina.parenti@edison.it', seat:'configurator', team:'Brand & Sustainability', status:'active' }, { id:'e3', name:'Marco Stangalini', email:'marco.stangalini@edison.it', seat:'configurator', team:'Public Affairs', status:'active' }, { id:'e4', name:'Federica Bertolini',email:'federica.bertolini@edison.it', seat:'runner', team:'Marketing', status:'active' }, { id:'e5', name:'Paolo Quaini', email:'paolo.quaini@edison.it', seat:'viewer', team:'Public Affairs', status:'active' }, { id:'e6', name:'Sara Cervati', email:'sara.cervati@edison.it', seat:'viewer', team:'Brand & Sustainability', status:'invited' }, ], ferr: [ { id:'f1', name:'Lapo Civiletti', email:'lapo.civiletti@ferrero.com', seat:'admin', team:'Brand Intelligence', status:'active' }, { id:'f2', name:'Manuela Kron', email:'manuela.kron@ferrero.com', seat:'configurator', team:'Brand Intelligence', status:'active' }, { id:'f3', name:'Alessandro Bevolo', email:'alessandro.bevolo@ferrero.com', seat:'configurator', team:'Comms & Reputation', status:'active' }, { id:'f4', name:'Chiara Lazzari', email:'chiara.lazzari@ferrero.com', seat:'runner', team:'Public Affairs', status:'active' }, ], gen: [ { id:'g1', name:'Philippe Donnet', email:'philippe.donnet@generali.com', seat:'admin', team:'Brand & Reputation', status:'active' }, { id:'g2', name:'Cristiano Borean', email:'cristiano.borean@generali.com', seat:'configurator', team:'Investor Relations', status:'active' }, { id:'g3', name:'Lucia Silva', email:'lucia.silva@generali.com', seat:'viewer', team:'Brand & Reputation', status:'active' }, ], }; const BILLING_BY_ORG = { isp: { plan: 'Enterprise', price: 4800, seatsIncl: 15, analysesIncl: 50, rerunsIncl: 200, frequencyMax: 'Giornaliera', seatsUsed: 8, analysesUsed: 12, rerunsUsed: 145, nextCharge: '01 maggio 2026', creditsBalance: 320, creditsValue: 640, creditPrice: 2, paymentMethod: { type:'card', brand:'VISA', last4:'4242', exp:'12/27', holder:'Intesa Sanpaolo S.p.A.' }, fiscal: { ragione: 'Intesa Sanpaolo S.p.A.', address: 'Piazza San Carlo, 156', city: '10121 Torino (TO), Italia', piva: '11991500015', cf: '00799960158', pec: 'governance@pec.intesasanpaolo.com', cd: 'MMSGYUV', }, invoices: [ { date:'01/04/2026', n:'2026/0042', period:'Aprile 2026', amount:4800, status:'pending' }, { date:'01/03/2026', n:'2026/0031', period:'Marzo 2026', amount:4800, status:'paid' }, { date:'01/02/2026', n:'2026/0023', period:'Febbraio 2026 + 50 cr extra', amount:4900, status:'paid' }, { date:'01/01/2026', n:'2026/0011', period:'Gennaio 2026', amount:4800, status:'paid' }, { date:'01/12/2025', n:'2025/0240', period:'Dicembre 2025', amount:4800, status:'paid' }, { date:'01/11/2025', n:'2025/0221', period:'Novembre 2025', amount:4800, status:'paid' }, ], creditsTx: [ { date:'29/04/2026', kind:'out', desc:'Re-run progetto "Reputazione Carlo Messina"', delta:-5, balance:320 }, { date:'28/04/2026', kind:'out', desc:'Frequenza giornaliera "Sustainability narrative"', delta:-20, balance:325 }, { date:'25/04/2026', kind:'out', desc:'Re-run progetto "AI nel banking — narrativa"', delta:-5, balance:345 }, { date:'15/04/2026', kind:'in', desc:'Ricarica pacchetto crediti aggiuntivi (200)', delta:200, balance:350 }, { date:'01/04/2026', kind:'in', desc:'Crediti inclusi nel piano Enterprise', delta:50, balance:150 }, { date:'28/03/2026', kind:'out', desc:'Re-run extra "Tassazione bancaria UE"', delta:-10, balance:100 }, ], }, edi: { plan: 'Growth', price: 1800, seatsIncl: 10, analysesIncl: 20, rerunsIncl: 80, frequencyMax: 'Settimanale', seatsUsed: 6, analysesUsed: 8, rerunsUsed: 62, nextCharge: '01 maggio 2026', creditsBalance: 80, creditsValue: 160, creditPrice: 2, paymentMethod: { type:'sepa', iban:'IT60•••••••••••••••••0123', holder:'Edison S.p.A.' }, fiscal: { ragione: 'Edison S.p.A.', address: 'Foro Buonaparte, 31', city: '20121 Milano (MI), Italia', piva: '06598640960', cf: '06598640960', pec: 'edison@pec.edison.it', cd: 'A4707H7', }, invoices: [ { date:'01/04/2026', n:'2026/0086', period:'Aprile 2026', amount:1800, status:'pending' }, { date:'01/03/2026', n:'2026/0061', period:'Marzo 2026', amount:1800, status:'paid' }, { date:'01/02/2026', n:'2026/0042', period:'Febbraio 2026',amount:1800, status:'paid' }, { date:'01/01/2026', n:'2026/0019', period:'Gennaio 2026', amount:1800, status:'paid' }, ], creditsTx: [ { date:'27/04/2026', kind:'out', desc:'Re-run extra "Transizione energetica"', delta:-5, balance:80 }, { date:'01/04/2026', kind:'in', desc:'Crediti inclusi nel piano Growth', delta:20, balance:85 }, { date:'30/03/2026', kind:'out', desc:'Re-run extra "Edison brand reputation"', delta:-5, balance:65 }, ], }, ferr: { plan: 'Enterprise', price: 6500, seatsIncl: 20, analysesIncl: 80, rerunsIncl: 300, frequencyMax: 'Giornaliera', seatsUsed: 12, analysesUsed: 24, rerunsUsed: 218, nextCharge: '01 maggio 2026', creditsBalance: 540, creditsValue: 1080, creditPrice: 2, paymentMethod: { type:'card', brand:'AMEX', last4:'1003', exp:'08/28', holder:'Ferrero S.p.A.' }, fiscal: { ragione: 'Ferrero S.p.A.', address: 'Piazzale Pietro Ferrero, 1', city: '12051 Alba (CN), Italia', piva: '02914930040', cf: '02914930040', pec: 'ferrero@pec.ferrero.com', cd: 'XL13LG4', }, invoices: [ { date:'01/04/2026', n:'2026/0044', period:'Aprile 2026', amount:6500, status:'pending' }, { date:'01/03/2026', n:'2026/0033', period:'Marzo 2026', amount:6500, status:'paid' }, { date:'01/02/2026', n:'2026/0021', period:'Febbraio 2026',amount:6700, status:'paid' }, ], creditsTx: [ { date:'29/04/2026', kind:'out', desc:'Re-run extra "Monitoraggio Nutella"', delta:-15, balance:540 }, { date:'15/04/2026', kind:'in', desc:'Ricarica pacchetto crediti (500)', delta:500, balance:555 }, ], }, gen: { plan: 'Growth', price: 1800, seatsIncl: 10, analysesIncl: 20, rerunsIncl: 80, frequencyMax: 'Settimanale', seatsUsed: 5, analysesUsed: 4, rerunsUsed: 28, nextCharge: '01 maggio 2026', creditsBalance: 18, creditsValue: 36, creditPrice: 2, paymentMethod: { type:'card', brand:'MASTERCARD', last4:'8821', exp:'05/27', holder:'Generali Italia S.p.A.' }, fiscal: { ragione: 'Generali Italia S.p.A.', address: 'Via Marocchesa, 14', city: '31021 Mogliano Veneto (TV), Italia', piva: '01333550323', cf: '00079760328', pec: 'generali@pec.generaligroup.com', cd: 'M5UXCR1', }, invoices: [ { date:'01/04/2026', n:'2026/0089', period:'Aprile 2026', amount:1800, status:'pending' }, { date:'01/03/2026', n:'2026/0064', period:'Marzo 2026', amount:1800, status:'paid' }, ], creditsTx: [ { date:'01/04/2026', kind:'in', desc:'Crediti inclusi nel piano Growth', delta:20, balance:18 }, ], }, }; const PROJECTS_BY_ORG = { isp: [ { id:'p1', name:'Reputazione Carlo Messina', type:'Personal', subject:'Carlo Messina', team:'Comms & Reputation', updated:'oggi', status:'running' }, { id:'p2', name:'Sustainability narrative ISP', type:'Brand', subject:'Intesa Sanpaolo',team:'Comms & Reputation', updated:'2 giorni fa', status:'idle' }, { id:'p3', name:'Tassazione bancaria UE', type:'Topic', subject:'Bank tax UE', team:'Public Affairs', updated:'1 settimana fa', status:'idle' }, { id:'p4', name:'AI per il banking — narrativa', type:'Topic', subject:'AI nel banking', team:'Risk & Compliance', updated:'4 ore fa', status:'running' }, ], edi: [ { id:'p1', name:'Transizione energetica', type:'Topic', subject:'Transizione energetica', team:'Public Affairs', updated:'oggi', status:'running' }, { id:'p2', name:'Edison brand reputation', type:'Brand', subject:'Edison', team:'Brand & Sustainability', updated:'5 giorni fa', status:'idle' }, { id:'p3', name:'CEO reputation Nicola Monti', type:'Personal', subject:'Nicola Monti', team:'Brand & Sustainability', updated:'oggi', status:'idle' }, ], ferr: [ { id:'p1', name:'Monitoraggio Nutella', type:'Brand', subject:'Nutella', team:'Brand Intelligence', updated:'oggi', status:'running' }, { id:'p2', name:'Cibi ultra processati', type:'Topic', subject:'UPF', team:'Public Affairs', updated:'2 giorni fa', status:'idle' }, { id:'p3', name:'Ferrero Group reputation', type:'Brand', subject:'Ferrero Group', team:'Comms & Reputation', updated:'oggi', status:'running' }, { id:'p4', name:'Olio di palma narrativa', type:'Topic', subject:'Olio di palma', team:'Public Affairs', updated:'3 settimane fa', status:'idle' }, { id:'p5', name:'Kinder portfolio', type:'Brand', subject:'Kinder', team:'Brand Intelligence', updated:'1 settimana fa', status:'idle' }, { id:'p6', name:'Ferrero CEO reputation', type:'Personal', subject:'Lapo Civiletti', team:'Comms & Reputation', updated:'2 giorni fa', status:'idle' }, ], gen: [ { id:'p1', name:'Generali brand reputation', type:'Brand', subject:'Generali Italia', team:'Brand & Reputation', updated:'oggi', status:'running' }, { id:'p2', name:'Donnet — IR narrative', type:'Personal', subject:'Philippe Donnet', team:'Investor Relations', updated:'5 giorni fa', status:'idle' }, ], }; const SEAT_LABELS = { configurator: 'Configurator', runner: 'Re-runner', viewer: 'Viewer', unassigned: 'Non assegnato', }; const AVATAR_COLORS = [ ['#FDEDE6', '#C03D0F'], ['#E6F7EC', '#15803D'], ['#EFF6FF', '#1D4ED8'], ['#F1EBFF', '#6D28D9'], ['#FFF7ED', '#9A3412'], ['#FCE7F3', '#BE185D'], ]; const colorFor = (s) => AVATAR_COLORS[(s || '').charCodeAt(0) % AVATAR_COLORS.length]; const initials = (name) => name.split(' ').slice(0,2).map(s => s[0]).join('').toUpperCase(); // ─── Role pill (top center) ──────────────────────────────────── function RolePill({ role, setRole }) { const roles = [ { k:'superadmin', t:'Super Admin' }, { k:'admin', t:'Admin' }, { k:'configurator',t:'Configurator' }, { k:'viewer', t:'Viewer' }, ]; const lbl = roles.find(r => r.k === role)?.t; return (
Vista {lbl} · simulazione ruolo
{roles.map(r => ( ))}
); } // ─── Org selector dropdown (super admin only) ──────────────── function OrgSelector({ org, onChange, role, onShowAll, organizations }) { const [open, setOpen] = React.useState(false); const ref = React.useRef(null); React.useEffect(() => { const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener('mousedown', onDoc); return () => document.removeEventListener('mousedown', onDoc); }, []); const isSuper = role === 'superadmin'; if (!isSuper) { return (
Organizzazione
{org.name[0]}
{org.name}
); } return (
setOpen(!open)}> Cliente
{org.name[0]}
{org.name}
{open && (
Organizzazioni clienti
{organizations.map(o => (
{ onChange(o); setOpen(false); }} style={o.id === org.id ? {background:'#FAFAFB'} : null}>
{o.name[0]}
{o.name}
{o.domain} · {o.totalMembers} membri
{o.id === org.id && }
))}
{ onShowAll(); setOpen(false); }}>
Vedi tutte le organizzazioni →
)}
); } // ─── Page header ─────────────────────────────────────────────── function PageHead({ role, org, onChangeOrg, onShowAll, onBack, viewMode, organizations }) { return (
{viewMode === 'orgs-list' ? (

Backoffice DictoAI · Organizations

) : ( <>

{role === 'viewer' ? 'Progetti' : 'Impostazioni organizzazione'}

)} {viewMode === 'orgs-list' && ( Solo Super Admin )}
{viewMode !== 'orgs-list' && role === 'superadmin' && ( )}
{role === 'superadmin' ? 'DA' : 'GC'}
); } // ─── Top tabs (Organizzazione | Progetti) ───────────────────── function TabBar({ activeTab, setActiveTab, projectsCount, membersCount, role }) { const isViewer = role === 'viewer'; return (
{!isViewer && ( )}
); } // ─── Settings sidebar (left) ────────────────────────────────── function SettingsSidebar({ active, setActive, role }) { const isSuper = role === 'superadmin'; const isAdmin = role === 'admin' || isSuper; // Organizzazione & Progetti are tabs; this sidebar is sub-navigation INSIDE Organizzazione tab. const items = [ { k:'overview', ic:, lbl:'Panoramica', visible:true }, { k:'members', ic:, lbl:'Membri & Postazioni', visible:true }, { k:'billing', ic:, lbl:'Fatturazione & Crediti', visible:true, locked: !isAdmin }, { k:'audit', ic:, lbl:'Audit log', visible:isSuper }, { k:'danger', ic:, lbl:'Danger zone', visible:isSuper }, ]; return ( ); } // ─── Section: Team overview ──────────────────────────────────── function TeamOverview({ role, org }) { const canEdit = role === 'admin' || role === 'superadmin'; return (

Panoramica del team

{org.domain}
Dominio email consentito
{canEdit && }
{org.totalSeats} ({org.availableSeats} disponibili)
Postazioni totali · piano {org.plan}
{canEdit && }
{org.totalMembers} ({org.unassignedMembers} non assegnato)
Membri totali
); } // ─── Section: Org identity ───────────────────────────────────── function OrgIdentity({ role, org }) { const canEdit = role === 'admin' || role === 'superadmin'; const [name, setName] = React.useState(org.name); React.useEffect(() => { setName(org.name); }, [org.id]); const dirty = name !== org.name; return (

Identità organizzazione

{org.name[0]}
Nome organizzazione setName(e.target.value)} disabled={!canEdit} style={{maxWidth:340, background: canEdit ? '#fff' : '#F2F2F5'}}/>
); } // ─── Section: Members table ──────────────────────────────────── function MembersSection({ role, members, setMembers, onAdd, teams }) { const canEdit = role === 'admin' || role === 'superadmin'; const [filter, setFilter] = React.useState('all'); const [query, setQuery] = React.useState(''); const filtered = members.filter(m => { if (filter !== 'all' && m.seat !== filter) return false; if (query && !(m.name.toLowerCase().includes(query.toLowerCase()) || m.email.toLowerCase().includes(query.toLowerCase()))) return false; return true; }); const updateSeat = (id, seat) => setMembers(members.map(m => m.id === id ? {...m, seat} : m)); const updateTeam = (id, team) => setMembers(members.map(m => m.id === id ? {...m, team} : m)); const removeMember = (id) => setMembers(members.filter(m => m.id !== id)); return (

Membri

setQuery(e.target.value)}/>
{filtered.map(m => { const [bg, fg] = colorFor(m.name); const isAdminRow = m.seat === 'admin'; return ( ); })} {filtered.length === 0 && ( )}
Nome Postazione Team Stato
{initials(m.name)}
{m.name} {m.isYou && (tu)}
{m.email}
{isAdminRow ? ( Admin (Proprietario) ) : canEdit ? ( ) : ( {SEAT_LABELS[m.seat]} )} {canEdit ? ( ) : ( {m.team} )} {m.status === 'active' ? 'Attivo' : m.status === 'invited' ? 'Invitato' : 'Sospeso'} {canEdit && !isAdminRow && ( )}
Nessun membro corrisponde ai filtri.
Mostrati {filtered.length} di {members.length} membri · Configurator: {members.filter(m=>m.seat==='configurator').length} · Re-runner: {members.filter(m=>m.seat==='runner').length} · Viewer: {members.filter(m=>m.seat==='viewer').length}
); } // ─── Add organization modal (Super Admin only) ──────────────── const ORG_PLANS = [ { k:'starter', label:'Starter', price:'€ 490 / mese', meta:'3 postazioni · 5 analisi/mese · 20 ri-esecuzioni · frequenza mensile' }, { k:'growth', label:'Growth', price:'€ 1.800 / mese',meta:'10 postazioni · 20 analisi/mese · 80 ri-esecuzioni · frequenza settimanale' }, { k:'enterprise', label:'Enterprise', price:'su preventivo', meta:'15+ postazioni · 50+ analisi/mese · 200+ ri-esecuzioni · frequenza giornaliera · SSO · audit log' }, ]; const ORG_COLORS = ['#0B6B3A', '#1D4ED8', '#E94E1B', '#9B1B30', '#6D28D9', '#0E7490', '#B45309', '#0A0A0B']; const cleanRagione = (s) => s.replace(/\s+(S\.p\.A\.?|S\.r\.l\.?|S\.A\.S\.?|S\.r\.l\.s\.?|Inc\.?|LLC|Ltd\.?|N\.V\.?|GmbH)\s*$/i, '').trim(); function AddOrgModal({ onClose, onConfirm }) { const [ragione, setRagione] = React.useState(''); const [nameBreve, setNameBreve] = React.useState(''); const [touchedBreve, setTouchedBreve] = React.useState(false); const [domain, setDomain] = React.useState(''); const [plan, setPlan] = React.useState('growth'); const [color, setColor] = React.useState(ORG_COLORS[0]); const [adminEmail, setAdminEmail] = React.useState(''); const [adminName, setAdminName] = React.useState(''); const updateRagione = (v) => { setRagione(v); if (!touchedBreve) setNameBreve(cleanRagione(v)); }; const updateBreve = (v) => { setNameBreve(v); setTouchedBreve(true); }; // Auto-suggest dominio from email Admin (if user hasn't typed it) React.useEffect(() => { if (!domain && adminEmail.includes('@')) { const guess = adminEmail.split('@')[1]; if (guess && guess.includes('.')) setDomain(guess); } }, [adminEmail]); const valid = ragione.trim() && nameBreve.trim() && domain.trim() && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(adminEmail); const planObj = ORG_PLANS.find(p => p.k === plan); const submit = () => { if (!valid) return; const id = nameBreve.toLowerCase().replace(/[^a-z0-9]+/g,'').slice(0,8) + Date.now().toString().slice(-3); onConfirm({ org: { id, name: nameBreve.trim(), ragione: ragione.trim(), domain: domain.trim().toLowerCase(), color, plan: planObj.label, totalSeats: plan === 'starter' ? 3 : plan === 'growth' ? 10 : 20, availableSeats: (plan === 'starter' ? 3 : plan === 'growth' ? 10 : 20) - 1, totalMembers: 1, unassignedMembers: 0, projects: 0, }, admin: { email: adminEmail.trim().toLowerCase(), name: adminName.trim() || adminEmail.split('@')[0].replace(/[._]/g,' ').replace(/\b\w/g, c => c.toUpperCase()), }, planKey: plan, }); }; return (
e.stopPropagation()}>

Nuova organizzazione cliente

{/* ── Sezione Organizzazione ── */}
Organizzazione
updateRagione(e.target.value)} autoFocus/>
Denominazione legale completa, usata in fattura.
updateBreve(e.target.value)}/>
Mostrato nella sidebar e nei breadcrumb. Auto-compilato dalla ragione sociale.
@ setDomain(e.target.value)}/>
L'Admin potrà invitare solo email di questo dominio.
{ORG_PLANS.map(p => (
setPlan(p.k)} style={{ padding:'12px 14px', border:'1.5px solid ' + (plan === p.k ? '#0A0A0B' : '#EBEBEF'), background: plan === p.k ? '#FAFAFB' : '#fff', borderRadius:10, cursor:'pointer', boxShadow: plan === p.k ? '0 0 0 3px rgba(10,10,11,0.05)' : 'none', display:'grid', gridTemplateColumns:'20px 1fr auto', gap:12, alignItems:'center', }}>
{plan === p.k &&
}
{p.label}
{p.meta}
{p.price}
))}
{ORG_COLORS.map(c => (
Usato come accent della sidebar. Il logo immagine può essere caricato in seguito.
{/* ── Sezione Primo Admin ── */}
Primo Admin (proprietario)
Riceverà un invito email per attivare l'organizzazione e configurarla. Potrà invitare altri membri, assegnare team e completare i dati di fatturazione.
setAdminEmail(e.target.value)}/>
setAdminName(e.target.value)}/>
Cosa configura l'Admin dopo l'accept dell'invito: dati fiscali (P.IVA, sede legale, PEC, codice destinatario), metodo di pagamento, team, membri e progetti. Non sono richiesti qui.
); } // ─── Add member modal ────────────────────────────────────────── function AddMemberModal({ onClose, onConfirm, org, teams }) { const [email, setEmail] = React.useState(''); const [seat, setSeat] = React.useState('configurator'); const [team, setTeam] = React.useState(teams[0]); const submit = () => { if (!email) return; const name = email.split('@')[0].replace(/[._]/g, ' ').replace(/\b\w/g, s => s.toUpperCase()); onConfirm({ id: 'u' + Date.now(), name, email, seat, team, status:'invited', }); }; return (
e.stopPropagation()}>

Aggiungi membro a {org.name}

setEmail(e.target.value)} autoFocus/>
Riceverà un'email d'invito. Deve appartenere al dominio {org.domain}.
{seat === 'configurator' && 'Crea, modifica e lancia progetti.'} {seat === 'runner' && 'Può solo rilanciare analisi già configurate.'} {seat === 'viewer' && 'Sola lettura sui progetti a cui è assegnato.'} {seat === 'unassigned' && 'L\'utente verrà invitato senza accesso ai progetti.'}
Determina a quali progetti ha accesso di default.
); } // ─── Billing & Credits section ──────────────────────────────── function BillingSection({ role, org, billing }) { const fmt = (n) => '€ ' + Number(n).toLocaleString('it-IT', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const fmtNoDec = (n) => '€ ' + Number(n).toLocaleString('it-IT', { minimumFractionDigits: 0, maximumFractionDigits: 0 }); const pct = (used, max) => Math.min(100, Math.round((used / max) * 100)); const meterClass = (p) => p >= 85 ? 'red' : p >= 70 ? 'amber' : 'green'; const seatsP = pct(billing.seatsUsed, billing.seatsIncl); const analysesP = pct(billing.analysesUsed, billing.analysesIncl); const rerunsP = pct(billing.rerunsUsed, billing.rerunsIncl); return (
{/* ─── Plan hero ─── */}

Piano corrente

{billing.plan}

Attivo
{fmtNoDec(billing.price)} / mese · IVA esclusa · prossimo addebito {billing.nextCharge}
{billing.seatsIncl} postazioni {billing.analysesIncl} analisi/mese {billing.rerunsIncl} ri-esecuzioni/mese Frequenza fino a {billing.frequencyMax.toLowerCase()}
{/* ─── Usage of period + Credits wallet (side by side) ─── */}

Utilizzo del periodo · aprile 2026

Postazioni attive sui posti inclusi
{billing.seatsUsed} / {billing.seatsIncl}
Postazioni aggiuntive: € 240 / postazione / mese
Analisi configurate questo mese
{billing.analysesUsed} / {billing.analysesIncl}
Comprende creazione e modifica di progetti
Ri-esecuzioni / iterazioni questo mese
{billing.rerunsUsed} / {billing.rerunsIncl}
= 85 ? 'warn' : '')}> {rerunsP >= 85 ? `Superato l'85% della quota — eventuali ri-esecuzioni extra saranno scalate dal borsellino crediti.` : `Le ri-esecuzioni oltre quota sono scalate dal borsellino crediti (${fmt(billing.creditPrice)} / ri-esecuzione extra).`}
{/* Credits wallet */}
Borsellino crediti
{billing.creditsBalance}crediti
Equivalente a {fmtNoDec(billing.creditsValue)}.
1 credito = {fmt(billing.creditPrice)} = 1 ri-esecuzione extra o 1 settimana di frequenza giornaliera su un progetto.
{/* ─── Payment + Fiscal data (side by side) ─── */}

Metodo di pagamento e dati fiscali

Metodo di pagamento

{billing.paymentMethod.type === 'card' ? ( <>
Tipo
Carta {billing.paymentMethod.brand} •••• {billing.paymentMethod.last4}
Scadenza
{billing.paymentMethod.exp}
Intestatario
{billing.paymentMethod.holder}
) : ( <>
Tipo
SEPA Direct Debit
IBAN
{billing.paymentMethod.iban}
Intestatario
{billing.paymentMethod.holder}
)}
I dati di pagamento sono gestiti da Stripe. Dicto non li conserva.

Dati per fatturazione elettronica

Ragione sociale
{billing.fiscal.ragione}
Sede legale
{billing.fiscal.address}
{billing.fiscal.city}
Partita IVA
{billing.fiscal.piva}
Codice fiscale
{billing.fiscal.cf}
PEC
{billing.fiscal.pec}
Cod. destinatario
{billing.fiscal.cd}
{/* ─── Invoices history ─── */}

Storico fatture

{billing.invoices.map(inv => ( ))}
Data Numero Periodo Importo Stato Documento
{inv.date} {inv.n} {inv.period} {fmt(inv.amount)} {inv.status === 'paid' ? 'Pagata' : inv.status === 'pending' ? 'In attesa' : 'Scaduta'}
{/* ─── Credits transactions ─── */}

Movimenti borsellino crediti

{billing.creditsTx.map((t,i) => ( ))}
Data Tipo Descrizione Variazione Saldo
{t.date} {t.kind === 'in' ? 'Ricarica' : 'Consumo'} {t.desc} {t.delta > 0 ? '+' : ''}{t.delta} cr {t.balance} cr
I crediti residui non scadono. Possono essere usati per ri-esecuzioni extra, frequenze più alte (giornaliera) e set di provider AI premium oltre la quota inclusa nel piano. Tariffario crediti →
); } // ─── Projects tab ────────────────────────────────────────────── function ProjectsTab({ role, org, projects }) { const canEdit = role === 'admin' || role === 'configurator' || role === 'superadmin'; const typeColor = (t) => t === 'Brand' ? '#DC2626' : t === 'Personal' ? '#059669' : '#7C3AED'; return (

Progetti dell'organizzazione

Tutti i progetti di analisi configurati per {org.name}.

{projects.map(p => ( ))}
Progetto Tipologia Team Aggiornato Stato
{p.name}
Soggetto: {p.subject}
{p.type} {p.team} {p.updated} {p.status === 'running' ? 'In esecuzione' : 'Idle'}
{projects.length} progetti · {projects.filter(p => p.status === 'running').length} in esecuzione
); } // ─── Super admin · Organizations list ───────────────────────── function OrgsListView({ onSelect, organizations, onAddOrg }) { return (

Organizzazioni clienti

Tutte le organizzazioni gestite dal team DictoAI. Click su una card per accedere alle sue impostazioni.

{organizations.map((o,i) => (
onSelect(o)}>
{o.name[0]}
{o.name}
{o.domain}
{o.plan}
Postazioni {o.totalSeats - o.availableSeats}/{o.totalSeats}
Membri {o.totalMembers}
Progetti {o.projects}
))}
Nuova organizzazione
Crea un nuovo tenant cliente
); } // ─── Locked / placeholder section ────────────────────────────── function PlaceholderSection({ title, role, lockedFor }) { const isLocked = lockedFor && lockedFor.includes(role); return (
{isLocked ? : }

{title}

{isLocked ? <>Sezione riservata ad Admin e Super Admin DictoAI. Stai navigando come {role === 'configurator' ? 'Configurator' : 'Viewer'}. : 'Layout dettagliato — alla prossima iterazione del prototipo.'}

); } // ─── Stage scaler ────────────────────────────────────────────── function useScaledStage(w = 1440, h = 900) { const ref = React.useRef(null); React.useEffect(() => { const node = ref.current; if (!node) return; const fit = () => { const padding = 80; const sx = (window.innerWidth - padding) / w; const sy = (window.innerHeight - padding) / h; const s = Math.min(1, Math.min(sx, sy)); node.style.transform = `scale(${s})`; }; fit(); window.addEventListener('resize', fit); return () => window.removeEventListener('resize', fit); }, [w, h]); return ref; } // ─── Main App ────────────────────────────────────────────────── function App() { const [role, setRole] = React.useState('admin'); const [orgId, setOrgId] = React.useState('isp'); const [activeTab, setActiveTab] = React.useState('organization'); // organization | projects const [activeNav, setActiveNav] = React.useState('overview'); // overview | members | billing | audit | danger const [viewMode, setViewMode] = React.useState('settings'); // settings | orgs-list (super admin only) const [showAddModal, setShowAddModal] = React.useState(false); const [showAddOrgModal, setShowAddOrgModal] = React.useState(false); const [organizations, setOrganizations] = React.useState(INITIAL_ORGANIZATIONS); const [membersByOrg, setMembersByOrg] = React.useState(MEMBERS_BY_ORG); const [teamsByOrg, setTeamsByOrg] = React.useState(TEAMS_BY_ORG); const [billingByOrg, setBillingByOrg] = React.useState(BILLING_BY_ORG); const [projectsByOrg, setProjectsByOrg] = React.useState(PROJECTS_BY_ORG); const [toast, setToast] = React.useState(null); const stageRef = useScaledStage(1440, 900); const org = organizations.find(o => o.id === orgId) || organizations[0]; const teams = teamsByOrg[orgId] || ['Non assegnato']; const members = membersByOrg[orgId] || []; const projects = projectsByOrg[orgId] || []; const setMembers = (newMembers) => setMembersByOrg({...membersByOrg, [orgId]: newMembers}); // Snap nav off locked entries when role changes React.useEffect(() => { if ((role === 'viewer' || role === 'configurator') && ['billing', 'audit', 'danger'].includes(activeNav)) { setActiveNav('overview'); } if (role !== 'superadmin' && ['audit', 'danger'].includes(activeNav)) { setActiveNav('overview'); } if (role !== 'superadmin' && viewMode === 'orgs-list') { setViewMode('settings'); } // Viewer vede solo la tab Progetti if (role === 'viewer') { setActiveTab('projects'); } }, [role]); const handleAdd = (newMember) => { setMembers([...members, newMember]); setShowAddModal(false); setToast('Invito inviato a ' + newMember.email); setTimeout(() => setToast(null), 2400); }; const handleSelectOrg = (newOrg) => { setOrgId(newOrg.id); setViewMode('settings'); setActiveTab('organization'); setActiveNav('overview'); }; const handleCreateOrg = ({ org: newOrg, admin, planKey }) => { // Pricing per piano const planConfig = { starter: { price:490, seatsIncl:3, analysesIncl:5, rerunsIncl:20, frequencyMax:'Mensile', creditsIncl:10 }, growth: { price:1800, seatsIncl:10, analysesIncl:20, rerunsIncl:80, frequencyMax:'Settimanale', creditsIncl:20 }, enterprise: { price:0, seatsIncl:20, analysesIncl:80, rerunsIncl:300, frequencyMax:'Giornaliera', creditsIncl:50 }, }[planKey]; // 1. Aggiungi org setOrganizations([...organizations, newOrg]); // 2. Aggiungi primo Admin come membro setMembersByOrg({ ...membersByOrg, [newOrg.id]: [{ id: 'a' + Date.now(), name: admin.name, email: admin.email, seat: 'admin', team: 'Generale', status: 'invited', }], }); // 3. Team default setTeamsByOrg({ ...teamsByOrg, [newOrg.id]: ['Generale', 'Non assegnato'], }); // 4. Billing iniziale (vuoto, da configurare dall'admin) setBillingByOrg({ ...billingByOrg, [newOrg.id]: { plan: newOrg.plan, price: planConfig.price, seatsIncl: planConfig.seatsIncl, analysesIncl: planConfig.analysesIncl, rerunsIncl: planConfig.rerunsIncl, frequencyMax: planConfig.frequencyMax, seatsUsed: 1, analysesUsed: 0, rerunsUsed: 0, nextCharge: 'Da configurare', creditsBalance: planConfig.creditsIncl, creditsValue: planConfig.creditsIncl * 2, creditPrice: 2, paymentMethod: { type:'card', brand:'—', last4:'••••', exp:'—', holder:'Da configurare' }, fiscal: { ragione: newOrg.ragione || newOrg.name, address: '— da compilare', city: '— da compilare', piva: '—', cf: '—', pec: '—', cd: '—', }, invoices: [], creditsTx: [], }, }); // 5. Progetti vuoti setProjectsByOrg({ ...projectsByOrg, [newOrg.id]: [] }); setShowAddOrgModal(false); setToast(`Organizzazione "${newOrg.name}" creata · invito inviato a ${admin.email}`); setTimeout(() => setToast(null), 3200); }; // ─── Render ────────────────────────────────────────────────── const renderOrganizationContent = () => { if (activeNav === 'overview') { return (
setShowAddModal(true)} teams={teams}/>
); } if (activeNav === 'members') { return (
setShowAddModal(true)} teams={teams}/>
); } if (activeNav === 'billing') { const billing = BILLING_BY_ORG[orgId]; if (!billing) return
; return ; } const titles = { audit: 'Audit log', danger: 'Danger zone', }; return (
); }; return ( <>
setViewMode('orgs-list')} onBack={() => setViewMode('settings')} viewMode={viewMode} organizations={organizations}/> {viewMode === 'orgs-list' ? ( setShowAddOrgModal(true)}/> ) : ( <> {activeTab === 'organization' ? (
{renderOrganizationContent()}
) : ( )} )}
{showAddModal && ( t !== 'Non assegnato')} onClose={() => setShowAddModal(false)} onConfirm={handleAdd}/> )} {showAddOrgModal && ( setShowAddOrgModal(false)} onConfirm={handleCreateOrg}/> )}
{toast && (
{toast}
)} ); } window.DictoBackoffice = { App };