diff --git a/AGENTS.md b/AGENTS.md index 157638d..6d01122 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -263,7 +263,7 @@ La nota *"se i numeri sono questi cosa devo fare per crescere?"* suggerisce un l | **1** | Auth JWT + ruoli Consulente/Azienda + anagrafica Clienti/Aziende (§10.1) | Login + CRUD Clienti/Aziende | ✅ **Fatta** (sessione 1) | | **2** | Schema DB completo del motore finanziario (piano dei conti, tag, saldi, periodi) | Schema applicato, migrazioni versionate | ✅ **Fatta** (sessione 1) | | **3** | Motore di riclassificazione + indici (da `docs/MODELLO_FINANZIARIO.md`), **import Excel §11.1** | Import di un vero file cliente → Conto Economico riclassificato corretto | 🟡 **Quasi**: motore, indici e import fatti e verificati. Manca il file di un cliente **con i saldi**: quello consegnato è un modello vuoto (vedi `docs/MODELLO_FINANZIARIO.md` §8-bis) | -| **4** | UI Business: Panoramica + Conto Economico + Stato Patrimoniale (§10.2-10.4) | Le 3 schermate con dati reali importati | ✅ **Fatta** (sessione 1) — mancano i 4 grafici a 12 mesi di §10.2, che richiedono più periodi caricati | +| **4** | UI Business: Panoramica + Conto Economico + Stato Patrimoniale (§10.2-10.4) | Le 3 schermate con dati reali importati | ✅ **Fatta** (sessione 1) — menu laterale e grafici compresi | | **5** | UI Capitale Circolante + Tesoreria/Cash Flow + Scadenziario (§10.5-10.6) | Previsione di cassa funzionante su dati reali | ⬜ Prossima | | **6** | UI Banche e Finanziamenti (§10.7) + collegamento rate→Cash Flow | Fidi/finanziamenti con impatto visibile in Tesoreria | ⬜ | | **7** | Analisi & Simulazioni (§10.8) | Scenario what-if salvabile e confrontabile | ⬜ | diff --git a/README.md b/README.md index e38bb9c..08f25a6 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,7 @@ Da qui in giù serve solo a chi mette le mani nel codice. | Database | SQLite **cifrato** (`better-sqlite3-multiple-ciphers`, SQLCipher) | | Autenticazione | JWT, password con scrypt | | Build | electron-vite + TypeScript, electron-builder per gli installatori | +| Grafici | Recharts | | Test | Vitest | Convenzione di progetto: **identificatori in inglese, interfaccia in italiano**. diff --git a/package-lock.json b/package-lock.json index 630f5d3..ad101c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "daprodfinanza", - "version": "0.0.1", + "version": "0.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "daprodfinanza", - "version": "0.0.1", + "version": "0.0.2", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -15,7 +15,8 @@ "cors": "^2.8.5", "exceljs": "^4.4.0", "express": "^5.2.1", - "jsonwebtoken": "^9.0.2" + "jsonwebtoken": "^9.0.2", + "recharts": "^3.10.1" }, "devDependencies": { "@electron-toolkit/tsconfig": "^2.0.0", @@ -1358,6 +1359,32 @@ "node": ">=14.18.0" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -1767,6 +1794,18 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@szmarczak/http-timer": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", @@ -2164,6 +2203,69 @@ "@types/node": "*" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -2293,7 +2395,7 @@ "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2340,6 +2442,12 @@ "@types/node": "*" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", @@ -3296,6 +3404,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3544,9 +3661,130 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/dayjs": { "version": "1.11.23", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", @@ -3570,6 +3808,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -4120,6 +4364,18 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, "node_modules/es6-error": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", @@ -4219,6 +4475,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/exceljs": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", @@ -5024,6 +5286,16 @@ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "license": "MIT" }, + "node_modules/immer": { + "version": "11.1.18", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz", + "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -5041,6 +5313,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -6584,7 +6865,6 @@ "version": "19.3.0", "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6594,7 +6874,6 @@ "version": "19.3.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", - "dev": true, "license": "MIT", "dependencies": { "scheduler": "^0.28.0" @@ -6603,6 +6882,36 @@ "react": "^19.3.0" } }, + "node_modules/react-is": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz", + "integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -6683,6 +6992,51 @@ "node": ">=10" } }, + "node_modules/recharts": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -6721,6 +7075,12 @@ "url": "https://github.com/sponsors/jet2jet" } }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/resolve-alpn": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", @@ -6907,7 +7267,6 @@ "version": "0.28.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", - "dev": true, "license": "MIT" }, "node_modules/semver": { @@ -7401,6 +7760,12 @@ "semver": "bin/semver" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "6.1.4", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", @@ -7647,6 +8012,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/utf8-byte-length": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", @@ -7679,6 +8053,28 @@ "node": ">= 0.8" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", diff --git a/package.json b/package.json index 8c6c2f7..d96b5ea 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "cors": "^2.8.5", "exceljs": "^4.4.0", "express": "^5.2.1", - "jsonwebtoken": "^9.0.2" + "jsonwebtoken": "^9.0.2", + "recharts": "^3.10.1" }, "devDependencies": { "@electron-toolkit/tsconfig": "^2.0.0", diff --git a/src/main/server/routes/analysis.routes.ts b/src/main/server/routes/analysis.routes.ts index b01f416..f4afa72 100644 --- a/src/main/server/routes/analysis.routes.ts +++ b/src/main/server/routes/analysis.routes.ts @@ -1,7 +1,7 @@ import { Router, type Request } from 'express' import type { Scheme } from '@shared/engine' import type { Scenario } from '@shared/types' -import { analyse, listPeriods } from '../services/analysis.service' +import { analyse, listPeriods, series } from '../services/analysis.service' import { applyChartOfAccounts, previewChartOfAccounts } from '../services/import.service' import { requireAuth, requireRole } from '../middleware/auth' import { HttpError } from '../http-error' @@ -34,6 +34,10 @@ analysisRouter.get('/periods', (req, res) => { res.json(listPeriods(assertCanRead(req))) }) +analysisRouter.get('/series', (req, res) => { + res.json(series(assertCanRead(req), { scenario: (req.query.scenario as Scenario) ?? undefined })) +}) + analysisRouter.get('/periods/:periodUuid/analysis', (req, res) => { const companyUuid = assertCanRead(req) res.json( diff --git a/src/main/server/services/analysis.service.ts b/src/main/server/services/analysis.service.ts index 72e007d..76aae20 100644 --- a/src/main/server/services/analysis.service.ts +++ b/src/main/server/services/analysis.service.ts @@ -8,7 +8,7 @@ import { type EngineAccount, type Scheme } from '@shared/engine' -import type { Analysis } from '@shared/analysis' +import type { Analysis, SeriesPoint } from '@shared/analysis' import type { FiscalPeriod, Scenario } from '@shared/types' import { getDatabase } from '../../db' import { HttpError } from '../http-error' @@ -57,6 +57,47 @@ export function engineAccounts( .all(companyUuid, periodUuid, scenario) as EngineAccount[] } +/** + * Serie storica per i grafici a 12 mesi di §10.2 e §10.3. + * + * "Costi totali" qui sono i costi operativi prima degli ammortamenti: così + * `ricavi − costi = EBITDA` esattamente, e le tre linee del grafico si leggono + * senza doverci credere sulla parola. + */ +export function series( + companyUuid: string, + options: { scenario?: Scenario; limit?: number } = {} +): SeriesPoint[] { + getCompany(companyUuid) + const scenario = options.scenario ?? 'actual' + const limit = options.limit ?? 24 + + const periods = listPeriods(companyUuid) + .filter((p) => p.period_type === 'month') + .concat(listPeriods(companyUuid).filter((p) => p.period_type === 'year')) + .slice(0, limit) + // Dal più vecchio al più recente: è l'ordine in cui si legge un grafico. + .sort((a, b) => a.year - b.year || (a.month ?? 0) - (b.month ?? 0)) + + return periods + .map((period) => { + const accounts = engineAccounts(companyUuid, period.uuid, scenario) + if (accounts.length === 0) return null + const income = incomeStatement(accounts, DEFAULT_SCHEME).aggregates + const balance = balanceSheet(accounts) + return { + period_uuid: period.uuid, + label: period.label, + ricavi: income.ricaviNetti, + costiTotali: income.costiVariabili + income.costiFissi, + ebitda: income.ebitda, + utile: income.utile, + liquidita: balance.liquiditaImmediate + } + }) + .filter((point): point is SeriesPoint => point !== null) +} + export function analyse( companyUuid: string, periodUuid: string, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index ebd140d..4dca7bf 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -3,7 +3,7 @@ import { ROLE_LABELS, type Role } from '@shared/enums' import type { Company } from '@shared/types' import { api } from './lib/api' import { AuthProvider, useAuth } from './lib/auth' -import { Logo } from './components/Logo' +import { Sidebar, type Vista } from './components/Sidebar' import { StatusBar } from './components/StatusBar' import { Alert, Button } from './components/ui' import { LoginScreen, SetupScreen } from './pages/AuthScreen' @@ -11,82 +11,83 @@ import { CompanyPage } from './pages/CompanyPage' import { RegistryPage } from './pages/RegistryPage' import { RoleGate } from './pages/RoleGate' -/** Vista del Consulente: anagrafica → azienda selezionata. */ -function ConsultantShell(): React.JSX.Element { - const [company, setCompany] = useState(null) - - return company ? ( - setCompany(null)} canImport /> - ) : ( - - ) -} - /** - * Vista dell'operatore Azienda: entra direttamente nella propria azienda, - * senza anagrafica né accesso alle altre — AGENTS.md §4. + * Guscio dell'applicazione: menu laterale, contenuto, status bar. + * + * La navigazione vive qui e non dentro le pagine, così il menu resta fermo + * mentre il contenuto cambia — è il comportamento dei mockup analizzati. */ -function CompanyShell({ companyUuid }: { companyUuid: string }): React.JSX.Element { +function Workspace({ onLogout }: { onLogout: () => void }): React.JSX.Element { + const { user, version } = useAuth() + const consulente = user?.role === 'consultant' + const [company, setCompany] = useState(null) + const [vista, setVista] = useState(consulente ? 'anagrafica' : 'panoramica') const [error, setError] = useState(null) + // L'operatore Azienda entra direttamente nella propria azienda (§4). useEffect(() => { + if (consulente || !user?.company_uuid) return api - .get(`/api/companies/${companyUuid}`) + .get(`/api/companies/${user.company_uuid}`) .then(setCompany) - .catch((err) => - setError(err instanceof Error ? err.message : 'Azienda non disponibile.') - ) - }, [companyUuid]) + .catch((err) => setError(err instanceof Error ? err.message : 'Azienda non disponibile.')) + }, [consulente, user?.company_uuid]) - if (error) { - return ( -
- {error} -
- ) + const apriAzienda = (scelta: Company): void => { + setCompany(scelta) + setVista('panoramica') } - if (!company) return
Caricamento…
- - // L'import del piano dei conti resta al Consulente (§4). - return -} - -function AppShell({ onLogout }: { onLogout: () => void }): React.JSX.Element { - const { user, version } = useAuth() - if (!user) return
Sessione terminata.
- return (
-
- - - {/* Il wordmark sta in un solo elemento: altrimenti il `gap` del flex - si infilerebbe anche fra "DaProd" e "Finanza". */} - - DaProdFinanza - - - - {ROLE_LABELS[user.role]} - - -
- {user.full_name} - +
+ { + setCompany(null) + setVista('anagrafica') + }} + mostraAnagrafica={consulente} + mostraImport={consulente} + version={version} + /> + +
+
+ + {user ? ROLE_LABELS[user.role] : ''} + +
+ {user?.full_name} + +
+
+ +
+ {error ? ( +
+ {error} +
+ ) : company ? ( + + ) : consulente ? ( + + ) : ( +

Caricamento…

+ )} +
-
- -
- {user.role === 'consultant' ? ( - - ) : ( - - )} -
+
@@ -119,7 +120,8 @@ function Root(): React.JSX.Element { // Uscendo si torna alla scelta iniziale, non al login dell'ultimo ruolo usato. return ( - { logout() setRole(null) diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx new file mode 100644 index 0000000..6c89e6b --- /dev/null +++ b/src/renderer/src/components/Sidebar.tsx @@ -0,0 +1,172 @@ +import type { Company } from '@shared/types' +import { Logo } from './Logo' + +/** + * Menu laterale — il pattern di navigazione dei mockup analizzati (AGENTS.md §0). + * + * In alto l'anagrafica, sotto le viste dell'azienda aperta. Le viste non ancora + * costruite restano elencate e spente, con la fase in cui arriveranno: un menu + * che si allunga a sorpresa disorienta più di uno che dichiara cosa manca. + */ + +export type Vista = + | 'anagrafica' + | 'panoramica' + | 'conto-economico' + | 'stato-patrimoniale' + | 'import' + +interface Voce { + id: Vista + label: string + icona: React.JSX.Element +} + +const icona = (d: string): React.JSX.Element => ( + +) + +const ANAGRAFICA: Voce = { + id: 'anagrafica', + label: 'Clienti e Aziende', + icona: icona('M4 20v-1a4 4 0 0 1 4-4h3a4 4 0 0 1 4 4v1M9.5 11a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7M17 20v-1a4 4 0 0 0-3-3.9M15.5 4.2a3.5 3.5 0 0 1 0 6.6') +} + +const VISTE: Voce[] = [ + { + id: 'panoramica', + label: 'Panoramica', + icona: icona('M4 13h6V4H4zM14 20h6v-9h-6zM4 20h6v-4H4zM14 8h6V4h-6z') + }, + { + id: 'conto-economico', + label: 'Conto Economico', + icona: icona('M4 19h16M7 16V9M12 16V5M17 16v-4') + }, + { + id: 'stato-patrimoniale', + label: 'Stato Patrimoniale', + icona: icona('M3 9.5 12 4l9 5.5M5 10v9M19 10v9M9 10v9M15 10v9M3 20h18') + }, + { + id: 'import', + label: 'Import dati', + icona: icona('M12 4v10m0 0 3.5-3.5M12 14l-3.5-3.5M5 17v2a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2') + } +] + +const IN_ARRIVO: { label: string; fase: string }[] = [ + { label: 'Capitale Circolante', fase: 'Fase 5' }, + { label: 'Tesoreria / Cash Flow', fase: 'Fase 5' }, + { label: 'Banche e Finanziamenti', fase: 'Fase 6' }, + { label: 'Analisi & Simulazioni', fase: 'Fase 7' } +] + +function Bottone({ + voce, + attiva, + onClick +}: { + voce: Voce + attiva: boolean + onClick: () => void +}): React.JSX.Element { + return ( + + ) +} + +export function Sidebar({ + company, + vista, + onVista, + onAnagrafica, + mostraAnagrafica, + mostraImport, + version +}: { + company: Company | null + vista: Vista + onVista: (vista: Vista) => void + onAnagrafica: () => void + /** Il ruolo Azienda non ha un'anagrafica da sfogliare (§4). */ + mostraAnagrafica: boolean + mostraImport: boolean + version: string +}): React.JSX.Element { + return ( + + ) +} diff --git a/src/renderer/src/components/charts.tsx b/src/renderer/src/components/charts.tsx new file mode 100644 index 0000000..6e4408a --- /dev/null +++ b/src/renderer/src/components/charts.tsx @@ -0,0 +1,311 @@ +import { + Bar, + BarChart, + CartesianGrid, + Cell, + Legend, + Line, + LineChart, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis +} from 'recharts' +import { euro, percent } from '../lib/format' + +/** + * I grafici delle schermate di analisi — AGENTS.md §10.2 e §10.3. + * + * Tutti ricevono importi in **centesimi** e convertono qui: il resto + * dell'applicazione non deve pensare all'unità. Gli assi mostrano migliaia di + * euro, come nei mockup, perché su un grafico le cifre intere non si leggono. + */ + +const ASSE = { stroke: '#64748b', fontSize: 11 } +const GRIGLIA = '#1d2636' + +export const COLORI = { + ricavi: '#34d399', + costi: '#f87171', + ebitda: '#a78bfa', + utile: '#60a5fa', + liquidita: '#34d399', + previsione: '#fbbf24' +} as const + +/** Palette della composizione costi: tinte distinte, leggibili sul fondo scuro. */ +const TORTA = ['#60a5fa', '#34d399', '#fbbf24', '#a78bfa', '#38bdf8', '#f472b6', '#94a3b8'] + +/** + * Etichette compatte per l'asse: "Gennaio 2026" non ci sta, "Gen" sì. L'anno + * compare solo quando la serie ne attraversa più di uno, altrimenti è rumore. + */ +function etichettaAsse(dati: { label: string }[]): (label: string) => string { + const anni = new Set(dati.map((d) => d.label.split(' ').at(-1))) + const mostraAnno = anni.size > 1 + return (label) => { + const parti = String(label).split(' ') + if (parti.length < 2) return String(label) + const [mese, anno] = parti + return mostraAnno ? `${mese.slice(0, 3)} '${anno.slice(-2)}` : mese.slice(0, 3) + } +} + +const migliaia = (cents: number): string => { + const k = cents / 100_000 + return Math.abs(k) >= 1 ? `${Math.round(k)}k` : String(Math.round(cents / 100)) +} + +function Riquadro({ children }: { children: React.ReactNode }): React.JSX.Element { + return ( +
+ {children} +
+ ) +} + +interface TooltipProps { + active?: boolean + label?: string | number + payload?: { name?: string; value?: number; color?: string; dataKey?: string }[] +} + +function TooltipEuro({ active, payload, label }: TooltipProps): React.JSX.Element | null { + if (!active || !payload?.length) return null + return ( + +

{label}

+ {payload.map((voce) => ( +

+ + {voce.name} + {euro(voce.value ?? 0)} +

+ ))} +
+ ) +} + +export interface PuntoSerie { + label: string + ricavi: number + costiTotali: number + ebitda: number + utile: number + liquidita: number +} + +/** Ricavi, costi ed EBITDA a confronto nel tempo — §10.3. */ +export function SerieEconomica({ dati }: { dati: PuntoSerie[] }): React.JSX.Element { + return ( + + + + + + } /> + + + + + + + ) +} + +/** Una singola grandezza a barre nel tempo — §10.2. */ +export function SerieABarre({ + dati, + chiave, + nome, + colore +}: { + dati: PuntoSerie[] + chiave: keyof PuntoSerie + nome: string + colore: string +}): React.JSX.Element { + return ( + + + + + + } cursor={{ fill: '#1d2636', opacity: 0.4 }} /> + + {dati.map((punto, i) => ( + // Un valore negativo si colora di rosso: su un utile è l'informazione + // più importante del grafico. + + ))} + + + + ) +} + +/** Andamento della liquidità — §10.2. La previsione arriva con la Fase 5. */ +export function SerieLiquidita({ dati }: { dati: PuntoSerie[] }): React.JSX.Element { + return ( + + + + + + } /> + + + + ) +} + +export interface FettaCosto { + nome: string + valore: number +} + +/** Composizione dei costi — §10.3. Basta un solo periodo. */ +export function CompositionePie({ fette }: { fette: FettaCosto[] }): React.JSX.Element { + const totale = fette.reduce((somma, f) => somma + f.valore, 0) + + return ( +
+
+ + + + {fette.map((_, i) => ( + + ))} + + } /> + + +
+ Totale costi + {euro(totale)} +
+
+ +
    + {fette.map((fetta, i) => ( +
  • + + {fetta.nome} + + {totale ? percent((fetta.valore / totale) * 100, 0) : '—'} + +
  • + ))} +
+
+ ) +} + +/** + * Break-even e margine di sicurezza — §10.3. + * Non è un grafico da libreria: è una barra che confronta due numeri, e + * disegnarla a mano costa meno che configurarne una. + */ +export function BarraBreakEven({ + bepCents, + ricaviCents, + margineSicurezza +}: { + bepCents: number | null + ricaviCents: number + margineSicurezza: number | null +}): React.JSX.Element { + if (bepCents === null || ricaviCents <= 0) { + return ( +

+ Il punto di pareggio non è calcolabile: serve un margine di contribuzione positivo. +

+ ) + } + + const massimo = Math.max(bepCents, ricaviCents) + const quotaBep = (bepCents / massimo) * 100 + const quotaRicavi = (ricaviCents / massimo) * 100 + const sotto = ricaviCents < bepCents + + return ( +
+
+ + {euro(bepCents)} + Break-even + + + + {euro(ricaviCents)} + + Ricavi del periodo + +
+ +
+
+
+
+
+ +

+ Margine di sicurezza + + {euro(ricaviCents - bepCents)} · {percent(margineSicurezza)} + +

+
+ ) +} diff --git a/src/renderer/src/pages/CompanyPage.tsx b/src/renderer/src/pages/CompanyPage.tsx index 898220a..7c4bed8 100644 --- a/src/renderer/src/pages/CompanyPage.tsx +++ b/src/renderer/src/pages/CompanyPage.tsx @@ -1,8 +1,9 @@ import { useCallback, useEffect, useState } from 'react' -import type { Analysis } from '@shared/analysis' +import type { Analysis, SeriesPoint } from '@shared/analysis' import { DEFAULT_SCHEME, type Scheme } from '@shared/engine' import type { Company, FiscalPeriod, Scenario } from '@shared/types' import { api } from '../lib/api' +import type { Vista } from '../components/Sidebar' import { Alert, Button, Card, EmptyState, Select } from '../components/ui' import { BalanceSheetView } from './business/BalanceSheetView' import { ImportPanel } from './business/ImportPanel' @@ -10,30 +11,13 @@ import { IncomeStatementView } from './business/IncomeStatementView' import { OverviewView } from './business/OverviewView' /** - * Suite Business di una singola azienda — AGENTS.md §10.2-§10.9. + * Area di lavoro di una singola azienda — AGENTS.md §10.2-§10.9. * - * Le tre viste già costruite leggono tutte lo stesso payload di analisi: un - * solo calcolo per periodo, tre modi di guardarlo. Le tab non ancora - * disponibili restano visibili e spente, con la fase in cui arriveranno: dire - * "non c'è ancora" è più onesto che nasconderle. + * La navigazione sta nel menu laterale; qui restano i selettori che valgono per + * tutte le viste: periodo e scenario. Le viste leggono tutte lo stesso payload + * di analisi — un solo calcolo per periodo, tre modi di guardarlo. */ -type Tab = 'panoramica' | 'conto-economico' | 'stato-patrimoniale' | 'import' - -const TABS: { id: Tab; label: string }[] = [ - { id: 'panoramica', label: 'Panoramica' }, - { id: 'conto-economico', label: 'Conto Economico' }, - { id: 'stato-patrimoniale', label: 'Stato Patrimoniale' }, - { id: 'import', label: 'Import' } -] - -const IN_ARRIVO: { label: string; fase: string }[] = [ - { label: 'Capitale Circolante', fase: 'Fase 5' }, - { label: 'Tesoreria', fase: 'Fase 5' }, - { label: 'Banche', fase: 'Fase 6' }, - { label: 'Simulazioni', fase: 'Fase 7' } -] - const SCENARI: { id: Scenario; label: string }[] = [ { id: 'actual', label: 'Consuntivo' }, { id: 'budget', label: 'Budget' }, @@ -42,19 +26,21 @@ const SCENARI: { id: Scenario; label: string }[] = [ export function CompanyPage({ company, - onBack, + vista, + onVista, canImport }: { company: Company - onBack: (() => void) | null + vista: Vista + onVista: (vista: Vista) => void canImport: boolean }): React.JSX.Element { - const [tab, setTab] = useState('panoramica') const [periods, setPeriods] = useState([]) const [periodUuid, setPeriodUuid] = useState('') const [scenario, setScenario] = useState('actual') const [scheme, setScheme] = useState(DEFAULT_SCHEME) const [analysis, setAnalysis] = useState(null) + const [serie, setSerie] = useState([]) const [error, setError] = useState(null) const [loading, setLoading] = useState(true) @@ -76,6 +62,13 @@ export function CompanyPage({ caricaPeriodi() }, [caricaPeriodi]) + useEffect(() => { + api + .get(`/api/companies/${company.uuid}/series?scenario=${scenario}`) + .then(setSerie) + .catch(() => setSerie([])) + }, [company.uuid, scenario, periods.length]) + useEffect(() => { if (!periodUuid) { setAnalysis(null) @@ -104,77 +97,43 @@ export function CompanyPage({ return (
-
-
-
- {onBack && ( - - )} -
-

{company.name}

-

- {company.code} - {company.vat_number ? ` · P.IVA ${company.vat_number}` : ''} - {company.business_type ? ` · ${company.business_type}` : ''} -

-
+
+
+

{company.name}

+

+ {company.code} + {company.vat_number ? ` · P.IVA ${company.vat_number}` : ''} + {company.business_type ? ` · ${company.business_type}` : ''} +

+
- {periods.length > 0 && ( -
- - -
- )} + {periods.length > 0 && vista !== 'import' && ( +
+ Periodo + +
- - -
+ )}
@@ -184,21 +143,23 @@ export function CompanyPage({
)} - {tab === 'import' && canImport && ( + {vista === 'import' && canImport && ( )} - {tab !== 'import' && + {vista !== 'import' && (loading ? (

Caricamento…

) : !conDati ? ( s.id === scenario)?.label @@ -207,17 +168,22 @@ export function CompanyPage({ action={ periods.length === 0 && canImport && ( - ) } /> - ) : tab === 'panoramica' ? ( - - ) : tab === 'conto-economico' ? ( - + ) : vista === 'panoramica' ? ( + + ) : vista === 'conto-economico' ? ( + ) : ( ))} diff --git a/src/renderer/src/pages/business/IncomeStatementView.tsx b/src/renderer/src/pages/business/IncomeStatementView.tsx index f3b422f..8a0a83a 100644 --- a/src/renderer/src/pages/business/IncomeStatementView.tsx +++ b/src/renderer/src/pages/business/IncomeStatementView.tsx @@ -1,7 +1,8 @@ -import type { Analysis } from '@shared/analysis' +import type { Analysis, SeriesPoint } from '@shared/analysis' import { SCHEME_LABELS, SCHEMES, type Scheme } from '@shared/engine' import { euro, percent, share, tone } from '../../lib/format' import { Card, Select } from '../../components/ui' +import { BarraBreakEven, CompositionePie, SerieEconomica } from '../../components/charts' /** * Conto Economico riclassificato — AGENTS.md §10.3. @@ -34,16 +35,30 @@ function Kpi({ export function IncomeStatementView({ analysis, + serie, scheme, onScheme }: { analysis: Analysis + serie: SeriesPoint[] scheme: Scheme onScheme: (scheme: Scheme) => void }): React.JSX.Element { const a = analysis.incomeStatement.aggregates const r = analysis.ratios + // La composizione dei costi si legge da un solo periodo: le voci a zero + // sparirebbero come spicchi invisibili, quindi si tolgono. + const fetteCosti = [ + { nome: 'Materie prime', valore: a.costiMateriePrime }, + { nome: 'Produzione', valore: a.costiProduzione }, + { nome: 'Personale', valore: a.costiPersonale }, + { nome: 'Commerciali', valore: a.costiCommerciali }, + { nome: 'Generali e amministrativi', valore: a.costiGenerali }, + { nome: 'Ammortamenti', valore: a.ammortamenti }, + { nome: 'Oneri finanziari', valore: a.oneriFinanziari } + ].filter((fetta) => fetta.valore > 0) + return (
@@ -144,6 +159,41 @@ export function IncomeStatementView({ +
+ +
+ {serie.length > 1 ? ( + + ) : ( +

+ Serve più di un periodo per disegnare un andamento. Importa altri mesi e il + grafico compare qui. +

+ )} +
+
+ + +
+ {fetteCosti.length > 0 ? ( + + ) : ( +

Nessun costo nel periodo.

+ )} +
+
+
+ + +
+ +
+
+
{analysis.alternativeSchemes.map((alt) => ( diff --git a/src/renderer/src/pages/business/OverviewView.tsx b/src/renderer/src/pages/business/OverviewView.tsx index f3214ae..82fc780 100644 --- a/src/renderer/src/pages/business/OverviewView.tsx +++ b/src/renderer/src/pages/business/OverviewView.tsx @@ -1,7 +1,8 @@ -import type { Analysis } from '@shared/analysis' +import type { Analysis, SeriesPoint } from '@shared/analysis' import { THRESHOLDS } from '@shared/engine' import { days, euro, percent, times, tone } from '../../lib/format' import { Card } from '../../components/ui' +import { COLORI, SerieABarre, SerieEconomica, SerieLiquidita } from '../../components/charts' /** * Panoramica — AGENTS.md §10.2. @@ -10,8 +11,9 @@ import { Card } from '../../components/ui' * (docs/MODELLO_FINANZIARIO.md §5): non c'è nessun modello che indovina, sono * confronti espliciti, e ognuno dice da quale numero arriva. * - * I quattro grafici a 12 mesi previsti da §10.2 arriveranno quando ci saranno - * più periodi caricati: con un solo periodo non c'è una serie da disegnare. + * I quattro grafici di §10.2 si disegnano sui periodi caricati: con uno solo + * non c'è un andamento da mostrare, e il riquadro lo dice invece di disegnare + * una linea piatta che sembrerebbe un dato. */ type Livello = 'rosso' | 'giallo' @@ -114,7 +116,13 @@ function Kpi({ ) } -export function OverviewView({ analysis }: { analysis: Analysis }): React.JSX.Element { +export function OverviewView({ + analysis, + serie +}: { + analysis: Analysis + serie: SeriesPoint[] +}): React.JSX.Element { const a = analysis.incomeStatement.aggregates const r = analysis.ratios const b = analysis.balanceSheet @@ -208,13 +216,41 @@ export function OverviewView({ analysis }: { analysis: Analysis }): React.JSX.El
- -

- I grafici di §10.2 si disegnano quando ci sono più periodi caricati: con un solo periodo - non c'è una serie storica da mostrare. Importa altri mesi dalla scheda{' '} - Import e compariranno qui. -

-
+ {serie.length > 1 ? ( +
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +

+ La previsione a 30/60/90 giorni arriva con la Tesoreria (Fase 5). +

+
+
+
+ ) : ( + +

+ I grafici si disegnano da due periodi in su: con uno solo non c'è un andamento da + mostrare, e una linea piatta sembrerebbe un dato. Importa altri mesi dalla voce{' '} + Import dati e compariranno qui. +

+
+ )}
) } diff --git a/src/shared/analysis.ts b/src/shared/analysis.ts index 0af05a5..7cb9b0a 100644 --- a/src/shared/analysis.ts +++ b/src/shared/analysis.ts @@ -20,3 +20,15 @@ export interface Analysis { balanceSheet: BalanceSheet ratios: Ratios } + +/** Un punto della serie storica: tutti gli importi in centesimi. */ +export interface SeriesPoint { + period_uuid: string + label: string + ricavi: number + /** Costi operativi prima degli ammortamenti: ricavi − costi = EBITDA. */ + costiTotali: number + ebitda: number + utile: number + liquidita: number +}