Le tabelle di un financial model si compilavano a mano, cella per cella, mentre i numeri erano già in un foglio Excel. Abbiamo reso possibile copiare una riga da Excel, Google Sheets o un CSV e incollarla direttamente in tabella: un solo parser per qualunque foglio, numeri normalizzati in ogni lingua e valuta, e un motore riusato in 31 tabelle senza codice dedicato.
Valueize è una piattaforma di financial modeling: personale, ricavi, COGS, opex, capitale circolante, struttura finanziaria. Ognuno di questi moduli è una o più tabelle a griglia — righe di voci, colonne di periodi — che l'utente deve compilare per costruire il modello. Un modello copre in genere 3-4 anni per volta, e ogni anno si può dettagliare mese per mese: le celle da riempire si moltiplicano in fretta.
Il punto è che quei numeri quasi sempre esistono già: sono in un foglio Excel, in un Google Sheet condiviso, in un CSV esportato dal gestionale. Un import da file c'era, ma dava per scontato un foglio pulito e completo — e i piani reali sono fatti a mano, parziali e tutti diversi tra loro. Così, nella pratica, restava l'input manuale: leggere un valore dal proprio foglio e ridigitarlo nella griglia, cella per cella, per ogni tabella. Il lavoro non era 'modellare': era ricopiare.
Il flusso era tecnicamente corretto e praticamente lentissimo. Ogni cella era un input validato, ma la validazione proteggeva il dato singolo, non il tempo dell'utente né la varietà dei fogli da cui i dati arrivavano.
Una griglia = decine di celle da ridigitare. Una tabella si compila di solito 3-4 anni alla volta: in vista annuale sono una decina di celle per riga, in vista mensile diventano 12 periodi per anno. Da ripetere su tutte le voci porta la complessità a centinaia di input manuali e ore a riempire tabelle.
Ogni foglio parla una lingua diversa. Un Excel italiano scrive 1.234,56, uno americano 1,234.56; un export CSV ci attacca il simbolo di valuta, un altro mette i separatori delle migliaia, un altro ancora le parentesi per i negativi. Anche volendo incollare, l'input avrebbe rifiutato quasi tutto: ogni formato è un caso diverso da gestire.
Niente via di mezzo tra 'a mano' e 'reimport totale'. Se dopo un import ti accorgi che quattro celle in mezzo a una tabella enorme sono sbagliate, le opzioni erano due: ridigitarle a mano, oppure rifare l'import di tutta la tabella. Nessuna delle due è una correzione, sono entrambe un ricominciare.
Il numero che conta non è '30 secondi', è '31 tabelle'. La feature è arrivata dappertutto perché il parser e la tabella non sanno niente l'uno dell'altra: due contratti piccoli e generici, agganciati con poche righe di configurazione per ogni tabella.
Un import da file c'era già, ma dava per scontato un foglio pulito e completo. L'utente tipico invece arriva con un piano fatto a mano: parziale, disordinato, diverso da ogni altro — un import perfetto spesso non era nemmeno possibile. Excel, Google Sheets e i CSV però mettono tutti nella clipboard lo stesso formato: righe separate da a-capo, celle separate da tab. Basta un parser per quel formato condiviso, non uno per ogni strumento, e la riga si incolla così com'è, senza riordinare niente prima. Diventa un gesto, non una procedura.
Un gesto solo: tasto destro, Paste. L'utente copia una riga dal suo foglio, fa tasto destro su una cella e sceglie Paste. Prima di scrivere niente, un'anteprima dice quante celle sta per incollare e mostra il contenuto grezzo della clipboard: nessuna sorpresa, si vede cosa sta per succedere.
I numeri li normalizza da solo. 1.234,56 € e €1,234.56 sono lo stesso valore scritto in due lingue: entrambi diventano 1234.56. Simboli di valuta, separatori delle migliaia, parentesi per i negativi, spazi: il parser li interpreta e li riduce a un numero pulito, qualunque foglio li abbia prodotti.
Guardrail espliciti, non troncamenti silenziosi. Se incolli più celle di quante ne ha la riga, l'app te lo dice e ti lascia scegliere invece di tagliare in silenzio. Se nella selezione ci sono celle vuote, ti offre tre comportamenti chiari: riempirle con zero, preservare il valore attuale, oppure saltarle.
Ogni paste è reversibile. Subito dopo aver incollato, dal menu è disponibile un Undo che ripristina soltanto le celle toccate — non l'intera riga, non tutta la tabella. È la fine del dilemma 'ridigito o reimporto': si correggono quattro celle in mezzo a una griglia enorme senza ripartire da capo.




Sotto ci sono due cose, tenute apposta separate. Un parser puro che non sa niente delle tabelle: prende del testo dalla clipboard e restituisce numeri validati. E un provider generico che non sa niente dei numeri: prende una lista di dati e una chiave, e sa incollare, validare, mutare e annullare. Si incastrano con una config minima — ed è per questo che la stessa feature è finita in 31 tabelle diverse senza una riga di codice dedicata.
Un parser, qualunque foglio. Il paste di Excel/Sheets/CSV è testo tab-separato: si spezza per a-capo e per tab, e ogni cella passa in un validatore intercambiabile. I validatori sono schemi Zod (numerico, percentuale, numerico-positivo) e la tabella sceglie quale usare passando una stringa. Aggiungerne uno nuovo non tocca il parser.
export function parseExcelData(
pastedData: string,
options: ClipboardParseOptions
): ClipboardParseResult {
const rows = pastedData.split(/\r\n|\r|\n/) // Windows, Mac, Unix
if (rows.length > 0 && rows[rows.length - 1] === ``) rows.pop()
const cells = (rows[0] ?? ``).split(`\t`) // Excel/Sheets/CSV → tab-separated
const parsedData: ClipboardParsedItem[] = []
cells.forEach((cell, index) => {
const trimmed = cell.trim()
const { success, value, error } =
typeof options.validate === `function`
? options.validate(trimmed)
: VALIDATE_FNS[options.validate](trimmed) // validatore scelto dalla tabella
parsedData.push({ id: index, originalValue: trimmed, validatedValue: value, error })
})
return { clipboardContent: pastedData, data: parsedData, /* ...errors */ }
} const VALIDATE_FNS = {
numeric: (input: string) => run(z.string().transform(parseInputValue)
.refine((v) => !Number.isNaN(Number(v)), `invalid_numeric`), input),
percentage: (input: string) => run(z.string().transform(parseInputValue)
.refine((v) => !Number.isNaN(Number(v)), `invalid_numeric`)
.refine((v) => Number(v) >= 0 && Number(v) <= 100, `invalid_percentage`), input),
positiveNumeric: (input: string) => run(z.string().transform(parseInputValue)
.refine((v) => !Number.isNaN(Number(v)), `invalid_numeric`)
.refine((v) => Number(v) >= 0, `invalid_positive`), input),
}
// la tabella sceglie con una stringa: dataValidationType="percentage" I numeri, in qualunque lingua e valuta. Il cuore è parseInputValue. Prima ripulisce: via simboli di valuta, lettere, spazi e percentuali; le parentesi diventano segno meno. Poi disambigua la parte più insidiosa — separatore delle migliaia contro separatore decimale — con una regola precisa invece di indovinare a caso.
const cleanInput = (value: string) => {
const parsed = value
.replace(currencySymbolsPattern, ``) // €, $, £, ...
.replace(/[a-zA-Z]/g, ``)
.replace(/[%]/, ``)
.replace(/[" "]/g, ``)
.trim()
// notazione contabile: (342) → -342
const hasParentheses = parsed.startsWith(`(`) && parsed.endsWith(`)`)
return hasParentheses ? `-${parsed.slice(1, -1)}` : parsed
} const processDecimalPart = (value: string) => {
let processedValue = value
if (value.includes(`,`)) {
const parts = value.split(`,`)
// 2 cifre dopo la virgola → è un decimale (1234,56 → 1234.56)
if (parts[1]?.length <= 2) processedValue = value.replace(`,`, `.`)
// altrimenti è un separatore delle migliaia (1,234 → 1234)
else processedValue = value.replace(/,/g, ``)
}
// più punti: l'ultimo è il decimale, gli altri sono migliaia
const split = processedValue.split(`.`)
if (split.length > 1) {
const decimalPart = split.pop()
return `${split.join(``)}.${decimalPart}`
}
return processedValue
} // "1.234,56" e "1,234.56" convivono: l'ultimo separatore è il decimale
const handleMixedSeparators = (value: string) => {
const parts = value.split(/[,.]/)
if (parts.length >= 2) {
const decimalPart = parts.pop()
return `${parts.join(``)}.${decimalPart}`
}
return parts.join(``)
}
export const parseInputValue = (value: string) => {
const parsed = cleanInput(value)
if (INVALID_NUMERIC_INPUTS.includes(parsed)) return `` // ".", ",", "..." → scartati
if (parsed.includes(`,`) && parsed.includes(`.`)) return handleMixedSeparators(parsed)
return processDecimalPart(parsed)
} Una funzione, decine di tabelle. Il provider è generico sul tipo di riga: TableActionableRowProvider<DataItem>. Non sa cosa sia una cella 'personale' o 'ricavi'. Riceve i dati correnti, la funzione di mutation della tabella e — il pezzo chiave — mutateDataKey, il percorso al campo da aggiornare, tipizzato sulle chiavi annidate di DataItem. È così che lo stesso provider serve strutture dati completamente diverse.
export const TableActionableRowProvider = <DataItem extends object>({
children,
currentData,
mutateDataKey, // percorso al campo, es. "value" o "config.rate"
rowUpdateMutationArgs: { mutationItemFn },
dataValidationType = `numeric`, // quale validatore Zod usa il parser
}: {
currentData: DataItem[]
mutateDataKey: NestedKeyOf<DataItem> // solo percorsi validi per DataItem
dataValidationType?: ClipboardParseValidationType
rowUpdateMutationArgs: {
mutationItemFn: (item: DataItem & { itemIndex: number }, extra: {
rowType: StandardInputType
}) => Promise<unknown>
}
}) => { /* ... */ } // "id" | "config" | "config.rate" | "config.view" ... — niente stringhe a caso
type NestedKeyOf<T> = {
[K in keyof T & (string | number)]: T[K] extends object
? `${K}` | `${K}.${NestedKeyOf<T[K]>}`
: `${K}`
}[keyof T & (string | number)] function getPatchedObjectByNestedProps<T extends object, V>(obj: T, path: string, value: V): T {
const result = { ...obj }
const keys = path.split(`.`)
let current = result as Record<string, any>
for (let i = 0; i < keys.length - 1; i++) {
current[keys[i]] = { ...current[keys[i]] } // copia il livello annidato
current = current[keys[i]]
}
current[keys[keys.length - 1]] = value
return result
} Modifiche chirurgiche, non reimport. Le tre modalità di paste, il guardrail sul limite della riga, e un undo che rimette a posto solo le celle coinvolte. Le mutation partono in parallelo ma a gruppi di dieci, con feedback per singola cella: una griglia grande non blocca l'interfaccia e un errore su una cella non fa fallire le altre.
const PASTE_ACTION_TYPES = {
PASTE_ALL: `PASTE_ALL`, // incolla tutto
PASTE_PRESERVE_EMPTY: `PASTE_PRESERVE_EMPTY`, // vuoto → tieni il valore attuale
PASTE_SKIP_EMPTY: `PASTE_SKIP_EMPTY`, // vuoto → salta la cella
} as const
const typePrepareFn = {
[PASTE_ACTION_TYPES.PASTE_ALL]: (cps) => cps.data,
[PASTE_ACTION_TYPES.PASTE_PRESERVE_EMPTY]: (cps) => cps.data,
[PASTE_ACTION_TYPES.PASTE_SKIP_EMPTY]: (cps) =>
cps.data.filter((item) => item.validatedValue !== ``),
} // incolli più celle di quante ne restano nella riga dal punto di partenza?
if (clipboardParseResult.data.length > currentData.length - triggerCellIndex) {
errorCode = PENDING_PASTE_ERROR.LIMIT_OVERFLOW // → dialog "Paste within limit?"
} // allSettled invece di Promise.all: una cella che fallisce non abbatte le altre
const promises = args.upsertData.map((item, index) =>
mutationItemFn(item, { rowType })
.then(() => ({ item, index, success: true }))
.catch(() => ({ item, index, success: false }))
)
const batchSize = 10
const batched = []
for (let i = 0; i < promises.length; i += batchSize) {
batched.push(Promise.all(promises.slice(i, i + batchSize)))
}
const results = (await Promise.all(batched)).flat()
// ogni risultato aggiorna il feedback della sua cella (verde/rosso) const restoreDataCells = fullRowDataBeforePaste.filter(
(_, i) =>
i >= contextMenuTriggerCellIndex &&
i < contextMenuTriggerCellIndex + pastedData.length
)
// rimette lo stato precedente solo su quelle celle: nessuna chiamata
// per il resto della riga, che non è mai stato toccato Un parser di numeri finanziari sbaglia in silenzio: 1,234 è milleduecentotrentaquattro per un americano e uno-virgola-due-tre-quattro per un italiano, e leggere male una cifra vuol dire corrompere un bilancio senza che nessuno se ne accorga. Per questo il parser non è coperto da qualche test scelto a mano, ma da una suite che lo fa girare su tutte le lingue supportate incrociate con tutte le valute ammesse, più i casi limite che rompono i parser ingenui: separatori misti, parentesi contabili, simboli attaccati al numero, input spazzatura.
it(`Should return number without currency`, () => {
ALLOWED_CURRENCIES.forEach((currency) => {
numbers.forEach((number) => {
const formatted = formatNumber(number.input, { currency, style: `currency` })
// qualunque valuta la scriva, il parser deve ricavarne il numero pulito
expect(parseInputValue(formatted)).toBe(Number(number.expected).toFixed(number.precision))
})
})
})
it(`Should return formatted number for all supported locales`, () => {
SUPPORTED_LOCALES.forEach((locale) => {
numbers.forEach((number) => {
const formatted = formatNumber(number.input, { language: locale })
expect(parseInputValue(formatted)).toBe(Number(number.expected).toFixed(number.precision))
})
})
}) expect(parseInputValue(`1,234.56`)).toBe(`1234.56`) // migliaia US + decimale
expect(parseInputValue(`1234,56`)).toBe(`1234.56`) // decimale IT
expect(parseInputValue(`1.234,567.89`)).toBe(`1234567.89`) // separatori misti
expect(parseInputValue(`(342,56)`)).toBe(`-342.56`) // parentesi = negativo
expect(parseInputValue(`$1234.56`)).toBe(`1234.56`) // simbolo attaccato
expect(parseInputValue(`1 234.56`)).toBe(`1234.56`) // spazio come separatore
// input che NON è un numero non deve diventare 0 di nascosto
INVALID_NUMERIC_INPUTS.forEach((input) => expect(parseInputValue(input)).toBe(``)) La virgola che corrompe un bilancio. L'ambiguità tra separatore delle migliaia e decimale non è un dettaglio estetico: interpretare 1,234 come 1234 invece di 1,234 significa gonfiare un numero di mille volte, in silenzio, dentro un modello finanziario. La difesa non è indovinare la lingua dell'utente, ma una regola deterministica — due cifre dopo il separatore sono decimali, tre o più sono migliaia — e una suite di test che la verifica su ogni lingua e ogni valuta prima che una modifica al parser arrivi in produzione.
Il paste parallelo che restituiva la cella sbagliata. La prima versione mandava tutte le mutation in un unico Promise.all. Al refetch, sulle selezioni grandi, la prima cella spesso non mostrava il valore incollato — anche se la richiesta POST era corretta. Il commento nel codice lo documenta ancora. La soluzione è stata passare a allSettled a gruppi di dieci: gli errori restano isolati alla loro cella e non si perdono aggiornamenti, tenendo reattiva anche una griglia grande.
Parliamone: 30 minuti per guardarlo insieme, capire se si può ridurre a pochi gesti e dirti onestamente se ne vale la pena.
Prenota una call di 30 min Altri casi studio