/* 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 => (
setRole(r.k)}>
{r.t}
))}
);
}
// ─── 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' && (
Tutte le organizzazioni
)}
{role === 'superadmin' ? 'DA' : 'GC'}
);
}
// ─── Top tabs (Organizzazione | Progetti) ─────────────────────
function TabBar({ activeTab, setActiveTab, projectsCount, membersCount, role }) {
const isViewer = role === 'viewer';
return (
{!isViewer && (
setActiveTab('organization')}>
Organizzazione
{membersCount}
)}
setActiveTab('projects')}>
Progetti
{projectsCount}
);
}
// ─── 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 (
Impostazioni organizzazione
{items.filter(it => it.visible).map(it => (
!it.locked && setActive(it.k)}>
{it.ic} {it.lbl}
{it.locked && }
))}
);
}
// ─── Section: Team overview ────────────────────────────────────
function TeamOverview({ role, org }) {
const canEdit = role === 'admin' || role === 'superadmin';
return (
Panoramica del team
{org.domain}
Dominio email consentito
{canEdit &&
Aggiorna → }
{org.totalSeats} ({org.availableSeats} disponibili)
Postazioni totali · piano {org.plan}
{canEdit &&
Gestisci postazioni → }
{org.totalMembers} ({org.unassignedMembers} non assegnato)
Membri totali
Visualizza dettagli →
);
}
// ─── 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 (
);
}
// ─── 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
Esporta CSV
Aggiungi membro
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
Piano iniziale *
{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',
}}>
{p.price}
))}
Colore identificativo
{ORG_COLORS.map(c => (
setColor(c)}
style={{
width:32, height:32, borderRadius:8,
background:c, cursor:'pointer',
border: color === c ? '2px solid #0A0A0B' : '2px solid transparent',
outline: color === c ? '2px solid #fff' : 'none',
outlineOffset: color === c ? -2 : 0,
padding:0,
}}
title={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.
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.
Annulla
Crea organizzazione
);
}
// ─── 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}
Postazione
setSeat(e.target.value)}>
Configurator
Re-runner
Viewer
Non assegnato (decidi dopo)
{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.'}
Team
setTeam(e.target.value)}>
{teams.map(t => {t} )}
Determina a quali progetti ha accesso di default.
Annulla
Invia invito
);
}
// ─── 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()}
Cambia piano
Aggiungi postazioni
{/* ─── 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.
Ricarica crediti
Storico →
{/* ─── Payment + Fiscal data (side by side) ─── */}
Metodo di pagamento e dati fiscali
Metodo di pagamento Aggiorna →
{billing.paymentMethod.type === 'card' ? (
<>
Tipo
Carta {billing.paymentMethod.brand} •••• {billing.paymentMethod.last4}
Scadenza
{billing.paymentMethod.exp}
Intestatario
{billing.paymentMethod.holder}
>
) : (
<>
IBAN
{billing.paymentMethod.iban}
Intestatario
{billing.paymentMethod.holder}
>
)}
I dati di pagamento sono gestiti da Stripe. Dicto non li conserva.
Dati per fatturazione elettronica Modifica →
Ragione sociale
{billing.fiscal.ragione}
Sede legale
{billing.fiscal.address} {billing.fiscal.city}
Partita IVA
{billing.fiscal.piva}
Codice fiscale
{billing.fiscal.cf}
Cod. destinatario
{billing.fiscal.cd}
{/* ─── Invoices history ─── */}
Storico fatture
2026
2025
Data
Numero
Periodo
Importo
Stato
Documento
{billing.invoices.map(inv => (
{inv.date}
{inv.n}
{inv.period}
{fmt(inv.amount)}
{inv.status === 'paid' ? 'Pagata' : inv.status === 'pending' ? 'In attesa' : 'Scaduta'}
Scarica PDF
))}
{/* ─── Credits transactions ─── */}
Movimenti borsellino crediti
Data
Tipo
Descrizione
Variazione
Saldo
{billing.creditsTx.map((t,i) => (
{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} .
Progetto
Tipologia
Team
Aggiornato
Stato
{projects.map(p => (
{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)}>
Postazioni {o.totalSeats - o.availableSeats}/{o.totalSeats}
Membri {o.totalMembers}
Progetti {o.projects}
{ e.stopPropagation(); onSelect(o); }}>Apri
e.stopPropagation()}>
))}
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 };