first commit

This commit is contained in:
fabio
2026-02-18 21:04:57 +01:00
parent 3f675be1c3
commit ce1fb7b23f
141 changed files with 24621 additions and 2 deletions

BIN
.DS_Store vendored

Binary file not shown.

4520
CH.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
# taxorganizer
test

BIN
app/.DS_Store vendored Normal file

Binary file not shown.

7
app/.editorconfig Normal file
View File

@@ -0,0 +1,7 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}]
charset = utf-8
indent_size = 2
indent_style = space
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

3
app/.eslintignore Normal file
View File

@@ -0,0 +1,3 @@
node_modules
dist
.quasar

21
app/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,21 @@
module.exports = {
root: true,
env: {
browser: true,
node: true
},
parser: 'vue-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser',
extraFileExtensions: ['.vue']
},
plugins: ['@typescript-eslint'],
extends: [
'plugin:vue/vue3-recommended',
'plugin:@typescript-eslint/recommended'
],
rules: {
// Allow usage of `any` when necessary
'@typescript-eslint/no-explicit-any': 'off'
}
}

5
app/.npmrc Normal file
View File

@@ -0,0 +1,5 @@
# pnpm-related options
shamefully-hoist=true
strict-peer-dependencies=false
# to get the latest compatible packages when creating the project https://github.com/pnpm/pnpm/issues/6463
resolution-mode=highest

5
app/.prettierrc.json Normal file
View File

@@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"singleQuote": true,
"printWidth": 100
}

View File

@@ -0,0 +1,77 @@
/* eslint-disable */
/**
* THIS FILE IS GENERATED AUTOMATICALLY.
* DO NOT EDIT.
*
* You are probably looking on adding startup/initialization code.
* Use "quasar new boot <name>" and add it there.
* One boot file per concern. Then reference the file(s) in quasar.config file > boot:
* boot: ['file', ...] // do not add ".js" extension to it.
*
* Boot files are your "main.js"
**/
import { Quasar } from 'quasar'
import { markRaw } from 'vue'
import RootComponent from 'app/src/App.vue'
import createStore from 'app/src/stores/index'
import createRouter from 'app/src/router/index'
export default async function (createAppFn, quasarUserOptions) {
// Create the app instance.
// Here we inject into it the Quasar UI, the router & possibly the store.
const app = createAppFn(RootComponent)
app.config.performance = true
app.use(Quasar, quasarUserOptions)
const store = typeof createStore === 'function'
? await createStore({})
: createStore
app.use(store)
const router = markRaw(
typeof createRouter === 'function'
? await createRouter({store})
: createRouter
)
// make router instance available in store
store.use(({ store }) => { store.router = router })
// Expose the app, the router and the store.
// Note that we are not mounting the app here, since bootstrapping will be
// different depending on whether we are in a browser or on the server.
return {
app,
store,
router
}
}

View File

@@ -0,0 +1,154 @@
/* eslint-disable */
/**
* THIS FILE IS GENERATED AUTOMATICALLY.
* DO NOT EDIT.
*
* You are probably looking on adding startup/initialization code.
* Use "quasar new boot <name>" and add it there.
* One boot file per concern. Then reference the file(s) in quasar.config file > boot:
* boot: ['file', ...] // do not add ".js" extension to it.
*
* Boot files are your "main.js"
**/
import { createApp } from 'vue'
import '@quasar/extras/roboto-font/roboto-font.css'
import '@quasar/extras/material-icons/material-icons.css'
// We load Quasar stylesheet file
import 'quasar/dist/quasar.sass'
import 'src/css/app.scss'
import createQuasarApp from './app.js'
import quasarUserOptions from './quasar-user-options.js'
console.info('[Quasar] Running SPA.')
const publicPath = `/`
async function start ({
app,
router
, store
}, bootFiles) {
let hasRedirected = false
const getRedirectUrl = url => {
try { return router.resolve(url).href }
catch (err) {}
return Object(url) === url
? null
: url
}
const redirect = url => {
hasRedirected = true
if (typeof url === 'string' && /^https?:\/\//.test(url)) {
window.location.href = url
return
}
const href = getRedirectUrl(url)
// continue if we didn't fail to resolve the url
if (href !== null) {
window.location.href = href
window.location.reload()
}
}
const urlPath = window.location.href.replace(window.location.origin, '')
for (let i = 0; hasRedirected === false && i < bootFiles.length; i++) {
try {
await bootFiles[i]({
app,
router,
store,
ssrContext: null,
redirect,
urlPath,
publicPath
})
}
catch (err) {
if (err && err.url) {
redirect(err.url)
return
}
console.error('[Quasar] boot error:', err)
return
}
}
if (hasRedirected === true) return
app.use(router)
app.mount('#q-app')
}
createQuasarApp(createApp, quasarUserOptions)
.then(app => {
// eventually remove this when Cordova/Capacitor/Electron support becomes old
const [ method, mapFn ] = Promise.allSettled !== void 0
? [
'allSettled',
bootFiles => bootFiles.map(result => {
if (result.status === 'rejected') {
console.error('[Quasar] boot error:', result.reason)
return
}
return result.value.default
})
]
: [
'all',
bootFiles => bootFiles.map(entry => entry.default)
]
return Promise[ method ]([
import('boot/i18n')
]).then(bootFiles => {
const boot = mapFn(bootFiles).filter(entry => typeof entry === 'function')
start(app, boot)
})
})

View File

@@ -0,0 +1,116 @@
/* eslint-disable */
/**
* THIS FILE IS GENERATED AUTOMATICALLY.
* DO NOT EDIT.
*
* You are probably looking on adding startup/initialization code.
* Use "quasar new boot <name>" and add it there.
* One boot file per concern. Then reference the file(s) in quasar.config file > boot:
* boot: ['file', ...] // do not add ".js" extension to it.
*
* Boot files are your "main.js"
**/
import App from 'app/src/App.vue'
let appPrefetch = typeof App.preFetch === 'function'
? App.preFetch
: (
// Class components return the component options (and the preFetch hook) inside __c property
App.__c !== void 0 && typeof App.__c.preFetch === 'function'
? App.__c.preFetch
: false
)
function getMatchedComponents (to, router) {
const route = to
? (to.matched ? to : router.resolve(to).route)
: router.currentRoute.value
if (!route) { return [] }
const matched = route.matched.filter(m => m.components !== void 0)
if (matched.length === 0) { return [] }
return Array.prototype.concat.apply([], matched.map(m => {
return Object.keys(m.components).map(key => {
const comp = m.components[key]
return {
path: m.path,
c: comp
}
})
}))
}
export function addPreFetchHooks ({ router, store, publicPath }) {
// Add router hook for handling preFetch.
// Doing it after initial route is resolved so that we don't double-fetch
// the data that we already have. Using router.beforeResolve() so that all
// async components are resolved.
router.beforeResolve((to, from, next) => {
const
urlPath = window.location.href.replace(window.location.origin, ''),
matched = getMatchedComponents(to, router),
prevMatched = getMatchedComponents(from, router)
let diffed = false
const preFetchList = matched
.filter((m, i) => {
return diffed || (diffed = (
!prevMatched[i] ||
prevMatched[i].c !== m.c ||
m.path.indexOf('/:') > -1 // does it has params?
))
})
.filter(m => m.c !== void 0 && (
typeof m.c.preFetch === 'function'
// Class components return the component options (and the preFetch hook) inside __c property
|| (m.c.__c !== void 0 && typeof m.c.__c.preFetch === 'function')
))
.map(m => m.c.__c !== void 0 ? m.c.__c.preFetch : m.c.preFetch)
if (appPrefetch !== false) {
preFetchList.unshift(appPrefetch)
appPrefetch = false
}
if (preFetchList.length === 0) {
return next()
}
let hasRedirected = false
const redirect = url => {
hasRedirected = true
next(url)
}
const proceed = () => {
if (hasRedirected === false) { next() }
}
preFetchList.reduce(
(promise, preFetch) => promise.then(() => hasRedirected === false && preFetch({
store,
currentRoute: to,
previousRoute: from,
redirect,
urlPath,
publicPath
})),
Promise.resolve()
)
.then(proceed)
.catch(e => {
console.error(e)
proceed()
})
})
}

View File

@@ -0,0 +1,21 @@
/* eslint-disable */
/**
* THIS FILE IS GENERATED AUTOMATICALLY.
* DO NOT EDIT.
*
* You are probably looking on adding startup/initialization code.
* Use "quasar new boot <name>" and add it there.
* One boot file per concern. Then reference the file(s) in quasar.config file > boot:
* boot: ['file', ...] // do not add ".js" extension to it.
*
* Boot files are your "main.js"
**/
import {Dialog,Notify} from 'quasar'
export default { config: {"notify":{"position":"top-right"}},plugins: {Dialog,Notify} }

8
app/.quasar/feature-flags.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
/* eslint-disable */
import "quasar/dist/types/feature-flag.d.ts";
declare module "quasar/dist/types/feature-flag.d.ts" {
interface QuasarFeatureFlags {
store: true;
}
}

8
app/.quasar/pinia.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
/* eslint-disable */
import { Router } from 'vue-router';
declare module 'pinia' {
export interface PiniaCustomProperties {
readonly router: Router;
}
}

View File

@@ -0,0 +1,75 @@
/* eslint-disable */
/**
* THIS FILE IS GENERATED AUTOMATICALLY.
* DO NOT EDIT.
*
* You are probably looking on adding startup/initialization code.
* Use "quasar new boot <name>" and add it there.
* One boot file per concern. Then reference the file(s) in quasar.config file > boot:
* boot: ['file', ...] // do not add ".js" extension to it.
*
* Boot files are your "main.js"
**/
import { Quasar } from 'quasar'
import { markRaw } from 'vue'
import RootComponent from 'app/src/App.vue'
import createStore from 'app/src/stores/index'
import createRouter from 'app/src/router/index'
export default async function (createAppFn, quasarUserOptions) {
// Create the app instance.
// Here we inject into it the Quasar UI, the router & possibly the store.
const app = createAppFn(RootComponent)
app.use(Quasar, quasarUserOptions)
const store = typeof createStore === 'function'
? await createStore({})
: createStore
app.use(store)
const router = markRaw(
typeof createRouter === 'function'
? await createRouter({store})
: createRouter
)
// make router instance available in store
store.use(({ store }) => { store.router = router })
// Expose the app, the router and the store.
// Note that we are not mounting the app here, since bootstrapping will be
// different depending on whether we are in a browser or on the server.
return {
app,
store,
router
}
}

View File

@@ -0,0 +1,152 @@
/* eslint-disable */
/**
* THIS FILE IS GENERATED AUTOMATICALLY.
* DO NOT EDIT.
*
* You are probably looking on adding startup/initialization code.
* Use "quasar new boot <name>" and add it there.
* One boot file per concern. Then reference the file(s) in quasar.config file > boot:
* boot: ['file', ...] // do not add ".js" extension to it.
*
* Boot files are your "main.js"
**/
import { createApp } from 'vue'
import '@quasar/extras/roboto-font/roboto-font.css'
import '@quasar/extras/material-icons/material-icons.css'
// We load Quasar stylesheet file
import 'quasar/dist/quasar.sass'
import 'src/css/app.scss'
import createQuasarApp from './app.js'
import quasarUserOptions from './quasar-user-options.js'
const publicPath = `/`
async function start ({
app,
router
, store
}, bootFiles) {
let hasRedirected = false
const getRedirectUrl = url => {
try { return router.resolve(url).href }
catch (err) {}
return Object(url) === url
? null
: url
}
const redirect = url => {
hasRedirected = true
if (typeof url === 'string' && /^https?:\/\//.test(url)) {
window.location.href = url
return
}
const href = getRedirectUrl(url)
// continue if we didn't fail to resolve the url
if (href !== null) {
window.location.href = href
window.location.reload()
}
}
const urlPath = window.location.href.replace(window.location.origin, '')
for (let i = 0; hasRedirected === false && i < bootFiles.length; i++) {
try {
await bootFiles[i]({
app,
router,
store,
ssrContext: null,
redirect,
urlPath,
publicPath
})
}
catch (err) {
if (err && err.url) {
redirect(err.url)
return
}
console.error('[Quasar] boot error:', err)
return
}
}
if (hasRedirected === true) return
app.use(router)
app.mount('#q-app')
}
createQuasarApp(createApp, quasarUserOptions)
.then(app => {
// eventually remove this when Cordova/Capacitor/Electron support becomes old
const [ method, mapFn ] = Promise.allSettled !== void 0
? [
'allSettled',
bootFiles => bootFiles.map(result => {
if (result.status === 'rejected') {
console.error('[Quasar] boot error:', result.reason)
return
}
return result.value.default
})
]
: [
'all',
bootFiles => bootFiles.map(entry => entry.default)
]
return Promise[ method ]([
import('boot/i18n')
]).then(bootFiles => {
const boot = mapFn(bootFiles).filter(entry => typeof entry === 'function')
start(app, boot)
})
})

View File

@@ -0,0 +1,116 @@
/* eslint-disable */
/**
* THIS FILE IS GENERATED AUTOMATICALLY.
* DO NOT EDIT.
*
* You are probably looking on adding startup/initialization code.
* Use "quasar new boot <name>" and add it there.
* One boot file per concern. Then reference the file(s) in quasar.config file > boot:
* boot: ['file', ...] // do not add ".js" extension to it.
*
* Boot files are your "main.js"
**/
import App from 'app/src/App.vue'
let appPrefetch = typeof App.preFetch === 'function'
? App.preFetch
: (
// Class components return the component options (and the preFetch hook) inside __c property
App.__c !== void 0 && typeof App.__c.preFetch === 'function'
? App.__c.preFetch
: false
)
function getMatchedComponents (to, router) {
const route = to
? (to.matched ? to : router.resolve(to).route)
: router.currentRoute.value
if (!route) { return [] }
const matched = route.matched.filter(m => m.components !== void 0)
if (matched.length === 0) { return [] }
return Array.prototype.concat.apply([], matched.map(m => {
return Object.keys(m.components).map(key => {
const comp = m.components[key]
return {
path: m.path,
c: comp
}
})
}))
}
export function addPreFetchHooks ({ router, store, publicPath }) {
// Add router hook for handling preFetch.
// Doing it after initial route is resolved so that we don't double-fetch
// the data that we already have. Using router.beforeResolve() so that all
// async components are resolved.
router.beforeResolve((to, from, next) => {
const
urlPath = window.location.href.replace(window.location.origin, ''),
matched = getMatchedComponents(to, router),
prevMatched = getMatchedComponents(from, router)
let diffed = false
const preFetchList = matched
.filter((m, i) => {
return diffed || (diffed = (
!prevMatched[i] ||
prevMatched[i].c !== m.c ||
m.path.indexOf('/:') > -1 // does it has params?
))
})
.filter(m => m.c !== void 0 && (
typeof m.c.preFetch === 'function'
// Class components return the component options (and the preFetch hook) inside __c property
|| (m.c.__c !== void 0 && typeof m.c.__c.preFetch === 'function')
))
.map(m => m.c.__c !== void 0 ? m.c.__c.preFetch : m.c.preFetch)
if (appPrefetch !== false) {
preFetchList.unshift(appPrefetch)
appPrefetch = false
}
if (preFetchList.length === 0) {
return next()
}
let hasRedirected = false
const redirect = url => {
hasRedirected = true
next(url)
}
const proceed = () => {
if (hasRedirected === false) { next() }
}
preFetchList.reduce(
(promise, preFetch) => promise.then(() => hasRedirected === false && preFetch({
store,
currentRoute: to,
previousRoute: from,
redirect,
urlPath,
publicPath
})),
Promise.resolve()
)
.then(proceed)
.catch(e => {
console.error(e)
proceed()
})
})
}

View File

@@ -0,0 +1,21 @@
/* eslint-disable */
/**
* THIS FILE IS GENERATED AUTOMATICALLY.
* DO NOT EDIT.
*
* You are probably looking on adding startup/initialization code.
* Use "quasar new boot <name>" and add it there.
* One boot file per concern. Then reference the file(s) in quasar.config file > boot:
* boot: ['file', ...] // do not add ".js" extension to it.
*
* Boot files are your "main.js"
**/
import {Dialog,Notify} from 'quasar'
export default { config: {"notify":{"position":"top-right"}},plugins: {Dialog,Notify} }

4
app/.quasar/quasar.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
/* eslint-disable */
/// <reference types="@quasar/app-vite" />
/// <reference types="vite/client" />

6
app/.quasar/shims-vue.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/* eslint-disable */
declare module '*.vue' {
import { DefineComponent } from 'vue';
const component: DefineComponent;
export default component;
}

100
app/.quasar/tsconfig.json Normal file
View File

@@ -0,0 +1,100 @@
{
"compilerOptions": {
"esModuleInterop": true,
"skipLibCheck": true,
"target": "esnext",
"allowJs": true,
"resolveJsonModule": true,
"moduleDetection": "force",
"isolatedModules": true,
"module": "preserve",
"noEmit": true,
"lib": [
"esnext",
"dom",
"dom.iterable"
],
"strict": true,
"allowUnreachableCode": false,
"allowUnusedLabels": false,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"paths": {
"src": [
"./../src"
],
"src/*": [
"./../src/*"
],
"app": [
"./.."
],
"app/*": [
"./../*"
],
"components": [
"./../src/components"
],
"components/*": [
"./../src/components/*"
],
"layouts": [
"./../src/layouts"
],
"layouts/*": [
"./../src/layouts/*"
],
"pages": [
"./../src/pages"
],
"pages/*": [
"./../src/pages/*"
],
"assets": [
"./../src/assets"
],
"assets/*": [
"./../src/assets/*"
],
"boot": [
"./../src/boot"
],
"boot/*": [
"./../src/boot/*"
],
"stores": [
"./../src/stores"
],
"stores/*": [
"./../src/stores/*"
],
"#q-app": [
"./../node_modules/.pnpm/@quasar+app-vite@2.4.0_@types+node@20.19.27_eslint@9.39.2_pinia@3.0.4_typescript@5.9.3__8e27baa73f7298cb84f0516a9a74f12e/node_modules/@quasar/app-vite/types/index.d.ts"
],
"#q-app/wrappers": [
"./../node_modules/.pnpm/@quasar+app-vite@2.4.0_@types+node@20.19.27_eslint@9.39.2_pinia@3.0.4_typescript@5.9.3__8e27baa73f7298cb84f0516a9a74f12e/node_modules/@quasar/app-vite/types/app-wrappers.d.ts"
],
"#q-app/bex/background": [
"./../node_modules/.pnpm/@quasar+app-vite@2.4.0_@types+node@20.19.27_eslint@9.39.2_pinia@3.0.4_typescript@5.9.3__8e27baa73f7298cb84f0516a9a74f12e/node_modules/@quasar/app-vite/types/bex/entrypoints/background.d.ts"
],
"#q-app/bex/content": [
"./../node_modules/.pnpm/@quasar+app-vite@2.4.0_@types+node@20.19.27_eslint@9.39.2_pinia@3.0.4_typescript@5.9.3__8e27baa73f7298cb84f0516a9a74f12e/node_modules/@quasar/app-vite/types/bex/entrypoints/content.d.ts"
],
"#q-app/bex/private/bex-bridge": [
"./../node_modules/.pnpm/@quasar+app-vite@2.4.0_@types+node@20.19.27_eslint@9.39.2_pinia@3.0.4_typescript@5.9.3__8e27baa73f7298cb84f0516a9a74f12e/node_modules/@quasar/app-vite/types/bex/bex-bridge.d.ts"
]
}
},
"include": [
"./**/*.d.ts",
"./../**/*"
],
"exclude": [
"./../dist",
"./../node_modules",
"./../src-capacitor",
"./../src-cordova",
"./../quasar.config.*.temporary.compiled*"
]
}

15
app/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,15 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"editorconfig.editorconfig",
"vue.volar",
"wayou.vscode-todo-highlight"
],
"unwantedRecommendations": [
"octref.vetur",
"hookyqr.beautify",
"dbaeumer.jshint",
"ms-vscode.vscode-typescript-tslint-plugin"
]
}

9
app/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,9 @@
{
"editor.bracketPairColorization.enabled": true,
"editor.guides.bracketPairs": true,
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": ["source.fixAll.eslint"],
"eslint.validate": ["javascript", "javascriptreact", "typescript", "vue"],
"typescript.tsdk": "node_modules/typescript/lib"
}

43
app/README.md Normal file
View File

@@ -0,0 +1,43 @@
# dichiarazione fiscale (app)
Dichiarazione fiscale automatizzata
## Install the dependencies
```bash
yarn
# or
npm install
```
### Start the app in development mode (hot-code reloading, error reporting, etc.)
```bash
quasar dev
```
### Lint the files
```bash
yarn lint
# or
npm run lint
```
### Format the files
```bash
yarn format
# or
npm run format
```
### Build the app for production
```bash
quasar build
```
### Customize the configuration
See [Configuring quasar.config.js](https://v2.quasar.dev/quasar-cli-vite/quasar-config-js).

48
app/Reports/2026-01-07.md Normal file
View File

@@ -0,0 +1,48 @@
# Report Giornaliero — 2026-01-07
## Sommario rapido
- Aggiornamenti allo schema, tipi e componenti per il flusso stepper.
- Aggiunta integrazione Pinia per i dati `taxpayer` e UI per editing/visualizzazione.
- Configurazione linter/TypeScript aggiornata per ridurre errori.
## Dettagli attività
- Schema
- Aggiunta del campo `description` a ogni step in `schema.json`.
- Tipi TypeScript
- Creato/aggiornato `app/src/types/schema.ts` con i tipi per lo schema (StepDescriptor, FieldDescriptor, TaxpayerForm, ecc.).
- Sostituiti alcuni `any` con `unknown` per soddisfare le regole ESLint.
- Componenti
- `app/src/components/steps/WelcomeStep.vue`: ora riceve `step` come prop; `q-card` flat e full-width.
- `app/src/components/StepsStepper.vue`: stepper verticale con lista di navigazione a sinistra; separazione navigazione/contenuto; caricamento dinamico del componente `WelcomeStep`.
- `app/src/components/steps/TaxpayerStep.vue`: form statico per lo step `taxpayer` (usa `q-input`, `q-toggle`, `q-file` con `multiple` e `use-chips`), inizializzazione dei valori e binding a store.
- Store
- Creato `app/src/stores/taxpayer.ts` (Pinia) con `data: TaxpayerForm` e azioni `set`, `replace`, `reset`.
- `TaxpayerStep.vue` carica i dati dallo store al mount e salva nello store prima di navigare avanti.
- Layout
- `app/src/layouts/MainLayout.vue`: drawer che mostra il JSON completo dello store `taxpayer.data` per debugging/visualizzazione.
- Configurazione
- `app/tsconfig.json` aggiornato per sovrascrivere `noImplicitAny` (override locale).
- Creata `app/.eslintrc.cjs` per disabilitare `@typescript-eslint/no-explicit-any` globalmente nel progetto `app` (opzione scelta temporanea).
- Aggiunta `.eslintignore` (menzione: ESLint avvisa che `.eslintignore` è deprecato per la configurazione nuova).
## Stato attuale
- Codice modificato e componenti aggiunti nel workspace.
- Linter ha segnalato errori `no-explicit-any` inizialmente; ho adattato i tipi e rimosso gli errori noti.
- Il dev server locale non è stato eseguito qui (ultimo tentativo: `pnpm run dev` exit code 130). Non ho avviato il server dopo tutte le modifiche.
## Prossimi passi suggeriti
- Avviare `pnpm run lint` e `pnpm run dev` nella cartella `app` per verificare runtime e UI.
- Collegare altri step allo store in modo simile a `taxpayer` (caricamento/salvataggio automatico).
- Implementare validazione campi e persistenza file (upload a backend), preview e rimozione file.
- Aggiungere test e/o snapshot per i componenti chiave.
---
Se vuoi, posso:
- Avviare il dev server ora (devo eseguire comandi in `/Users/fabio/CODE/BRUNO/frontend/app`).
- Salvare anche eventuali altri report o cambiare il nome/file.

View File

@@ -0,0 +1,41 @@
# Report di Lavoro — 10 gennaio 2026
## Sommario
Breve resoconto delle modifiche fatte oggi sul progetto frontend (Quasar + Vue 3 + Pinia + TypeScript).
## Modifiche principali
- `MaritalStep.vue`:
- Rimosso `await` non necessario su `resetValidation()` per risolvere l'errore `@typescript-eslint/await-thenable`.
- Il pulsante `Prev` ora salva i dati (`store.setMarital`) prima di emettere l'evento `prev`.
- `buildPayload()` espone tramite `defineExpose` e la logica pulisce i campi spouse quando `maritalStatus` è `SINGLE`.
- `TaxpayerStep.vue`:
- Aggiunta struttura `QForm` con `formRef` e regole `rules` condizionali (simili a `MaritalStep`).
- `goNext()` ora esegue la validazione prima di salvare e navigare; `goPrev()` salva prima di emettere `prev`.
- Risolti errori ESLint/TypeScript: `no-floating-promises` (await su validate), rimozione di variabili non usate nel `catch`.
## File modificati oggi
- `app/src/components/steps/MaritalStep.vue`
- `app/src/components/steps/TaxpayerStep.vue`
- (varie patch correlate a `app/src/stores/schema.ts`, `app/src/i18n/*` durante il work-in-progress)
## Stato attuale controlli
- `pnpm run lint`: exit code 0 (ultimo eseguito)
- `pnpm run tsc`: exit code 1 (ci sono ancora errori TypeScript da risolvere)
- `pnpm run dev`: exit code 130 (dev server non avviato in questo ambiente)
## Comandi utili
Esegui questi comandi nella cartella `app` per verificare lo stato:
```bash
pnpm run lint
pnpm run tsc
pnpm run dev
```
## Prossimi passi suggeriti
- Eseguire `pnpm run tsc` e correggere gli errori TypeScript rimanenti.
- Applicare lo stesso pattern di `QForm`/`rules` ad altri step se si desidera coerenza UX.
- Decidere se centralizzare il salvataggio nel `StepsStepper` (parent) o mantenerlo per-step; posso implementarne una delle due.
---
File salvato: `app/Reports/report-2026-01-10.md`

BIN
app/dist/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
import{b as q}from"./userstore-DBCughIu.js";import{$ as F,r as v,a as i,a9 as d,a3 as u,a5 as s,a4 as m,a1 as _,a7 as S,a8 as c,ac as p,a6 as j}from"./index-0B2sgEyA.js";import{A as I}from"./CommentAttachment-BuVtM3GK.js";import{u as N}from"./vue-i18n.runtime-DPKkE7zN.js";import{_ as z}from"./IndexPage-CwM_myNb.js";const T={class:"row items-center q-gutter-sm q-mb-sm q-mt-md"},E={class:"col-auto"},H={class:"col"},M={class:"text-caption"},O={class:"q-pa-sm bg-grey-2 q-mb-sm"},Q={key:0,class:"text-negative"},D={key:1},U=F({__name:"AddressInput",props:{modelValue:{},label:{},allowForeign:{type:Boolean},hint:{}},emits:["update:modelValue","save","cancel"],setup(g,{emit:h}){const o=g,r=h,{t:l}=N(),n=v(!1),y=v(null),V=o.label||l("address"),b=o.allowForeign??!0,x=i(()=>{const e=o.modelValue;if(console.log("address input formatted",e),!e)return"";const t=e.country?.name||"";return e.country.code==="CH"?[e.street,String(e.cap||""),e.city,e.canton].filter(Boolean).join(", "):[e.street,String(e.cap||""),e.city,t].filter(Boolean).join(", ")}),A=i(()=>{const e=o.modelValue;return e?!(e.street||e.city||e.cap||e.country&&e.country.code):!0}),w=i(()=>o.hint||l("validation.insertAddress"));function B(){const e=o.modelValue;y.value=e?{street:e.street||"",zip:String(e.cap||""),city:e.city||"",country:e.country&&e.country.code||"",canton:e.canton||"",foreign:!!(e.country&&e.country.code&&e.country.code!=="CH")}:{street:"",zip:"",city:"",country:"",canton:"",foreign:!0},n.value=!0}function C(e){let t={code:"",name:""};if(!e.country)t={code:"",name:""};else if(typeof e.country=="string")t={code:e.country,name:e.country};else if(typeof e.country=="object"&&e.country!==null){const f=e.country;t={code:f.code||"",name:f.name||""}}const a={street:e.street||"",cap:e.zip||"",city:e.city||"",country:t,canton:e.canton||""};r("update:modelValue",a),r("save",a),n.value=!1}function k(){r("cancel"),n.value=!1}return(e,t)=>(u(),d("div",null,[s("div",T,[s("div",E,[m(j,{dense:"",flat:"",round:"",icon:"edit",onClick:B},{default:_(()=>[m(q,{class:"bg-primary text-white"},{default:_(()=>[S(c(p(l)("children.editAddress")),1)]),_:1})]),_:1})]),s("div",H,[s("div",M,c(p(V)),1)])]),s("div",O,[A.value?(u(),d("div",Q,c(w.value),1)):(u(),d("div",D,c(x.value),1))]),m(I,{modelValue:n.value,"onUpdate:modelValue":t[0]||(t[0]=a=>n.value=a),modelAddress:y.value,allowForeign:p(b),onSave:C,onCancel:k},null,8,["modelValue","modelAddress","allowForeign"])]))}}),P=z(U,[["__scopeId","data-v-b68f19ad"]]);export{P as A};

View File

@@ -0,0 +1 @@
.q-card[data-v-b68f19ad]{width:100%;margin:0}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.full-width[data-v-aa36bd22]{width:100%}.contained-card[data-v-aa36bd22]{min-width:480px;max-width:720px}.contained-card .q-card-section[data-v-aa36bd22]{padding:16px}.contained-card .q-card-actions[data-v-aa36bd22]{padding:12px 16px}.child-modal[data-v-aa36bd22]{min-width:480px}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.contained-card[data-v-00430335]{min-width:420px;max-width:720px}.comment-attachment[data-v-a0ff9b6f]{font-size:14px}.comment-attachment__file-name[data-v-a0ff9b6f]{font-size:14px!important;width:100%;background-color:#0000001a}.text-grey[data-v-a0ff9b6f]{color:#00000073}

View File

@@ -0,0 +1 @@
import{$ as o,a9 as s,a5 as t,a4 as a,a3 as l,a6 as n}from"./index-0B2sgEyA.js";const r={class:"fullscreen bg-blue text-white text-center q-pa-md flex flex-center"},p=o({__name:"ErrorNotFound",setup(c){return(i,e)=>(l(),s("div",r,[t("div",null,[e[0]||(e[0]=t("div",{style:{"font-size":"30vh"}},"404",-1)),e[1]||(e[1]=t("div",{class:"text-h2",style:{opacity:"0.4"}},"Oops. Nothing here...",-1)),a(n,{class:"q-mt-xl",color:"white","text-color":"blue",unelevated:"",to:"/",label:"Go Home","no-caps":""})])]))}});export{p as default};

View File

@@ -0,0 +1 @@
.comment-attachment[data-v-a11712e8]{font-size:14px}.comment-attachment__file-chip[data-v-a11712e8]{max-width:320px}.text-grey[data-v-a11712e8]{color:#00000073}.q-card[data-v-f4f9035e]{width:100%;margin:0}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,2 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/WelcomeStep-Crh-xhDH.js","assets/index-0B2sgEyA.js","assets/index-BQveqNql.css","assets/vue-i18n.runtime-DPKkE7zN.js","assets/QList-CHwmSUjA.js","assets/WelcomeStep-BTfvnfIz.css","assets/TaxpayerStep-BfPZ-rxP.js","assets/userstore-DBCughIu.js","assets/LocalStorage-7Uw3xG9P.js","assets/taxstore-oR45_mFg.js","assets/AddressInput-Brku8Gup.js","assets/CommentAttachment-BuVtM3GK.js","assets/CommentAttachment-M56Dxc2A.css","assets/AddressInput-DTjVpzGz.css","assets/TaxpayerStep-CWAfROv3.css","assets/MaritalStep-npjGE3Gl.js","assets/MaritalStep-DL6z_e8a.css","assets/ChildrenStep-BaJwTfaC.js","assets/children-ChLzVyp9.js","assets/ChildrenStep-CyFMlGKq.css","assets/IncomeStep-RKJ4eG2Y.js","assets/IncomeStep-Cw8mizyx.css"])))=>i.map(i=>d[i]);
import{c as Q,g as A,j as P,k as c,m as w,X as R,a as g,h as $,b as D,$ as k,r as O,ad as p,a9 as m,a3 as s,a5 as C,a4 as v,a1 as u,aa as L,ab as V,a0 as I,a8 as b,ac as S,ae as q,af as B,ag as F,ah as z,ai as f}from"./index-0B2sgEyA.js";import{a as T,b as j,Q as N}from"./QList-CHwmSUjA.js";const H=Q({name:"QPage",props:{padding:Boolean,styleFn:Function},setup(r,{slots:o}){const{proxy:{$q:i}}=A(),e=P(w,c);if(e===c)return console.error("QPage needs to be a deep child of QLayout"),c;if(P(R,c)===c)return console.error("QPage needs to be child of QPageContainer"),c;const _=g(()=>{const n=(e.header.space===!0?e.header.size:0)+(e.footer.space===!0?e.footer.size:0);if(typeof r.styleFn=="function"){const y=e.isContainer.value===!0?e.containerHeight.value:i.screen.height;return r.styleFn(n,y)}return{minHeight:e.isContainer.value===!0?e.containerHeight.value-n+"px":i.screen.height===0?n!==0?`calc(100vh - ${n}px)`:"100vh":i.screen.height-n+"px"}}),h=g(()=>`q-page${r.padding===!0?" q-layout-padding":""}`);return()=>$("main",{class:h.value,style:_.value},D(o.default))}}),K={class:"row full-height"},M={class:"col-3 q-pa-sm bg-grey-1"},X={class:"text-body1"},G={class:"col q-pa-md full-height"},J={key:0,class:"text-h6"},U={key:2,class:"q-mt-md"},W=k({__name:"StepsStepper",setup(r){const o=[{id:"welcome",title:"Benvenuto",order:0},{id:"taxpayer",title:"Dati contribuente e dichiarazione precedente",order:1},{id:"marital",title:"Stato civile",order:2},{id:"children",title:"Figli",order:3},{id:"income",title:"Redditi",order:4},{id:"professionalExpenses",title:"Spese professionali",order:5},{id:"sideIncome",title:"Reddito accessorio",order:6},{id:"annuities",title:"Rendite",order:7},{id:"insurance",title:"Spese assicurative e mediche",order:8},{id:"pillar3",title:"Polizze 3A / 3B",order:9},{id:"bankAccounts",title:"Conti bancari",order:10},{id:"otherAssets",title:"Altri beni / averi",order:11},{id:"debts",title:"Debiti / ipoteche",order:12},{id:"properties",title:"Immobili",order:13},{id:"foreign",title:"Redditi o averi allestero",order:14}],i=Math.max(...o.map(t=>t.order)),e=new Array(i+1).fill(void 0).map(()=>({}));o.forEach(t=>{e[t.order]={id:t.id,title:t.title,order:t.order}});const a=O(0),_=g(()=>{const t=e[a.value]?.id;return t==="welcome"?p(()=>f(()=>import("./WelcomeStep-Crh-xhDH.js"),__vite__mapDeps([0,1,2,3,4,5]))):t==="taxpayer"?p(()=>f(()=>import("./TaxpayerStep-BfPZ-rxP.js"),__vite__mapDeps([6,1,2,7,8,4,9,10,11,3,12,13,14]))):t==="marital"?p(()=>f(()=>import("./MaritalStep-npjGE3Gl.js"),__vite__mapDeps([15,1,2,7,8,4,9,10,11,3,12,13,16]))):t==="children"?p(()=>f(()=>import("./ChildrenStep-BaJwTfaC.js"),__vite__mapDeps([17,1,2,7,8,4,11,3,12,18,9,19]))):t==="income"?p(()=>f(()=>import("./IncomeStep-RKJ4eG2Y.js"),__vite__mapDeps([20,1,2,7,8,4,3,21]))):null}),h=g(()=>e[a.value]);function n(t){if(typeof t=="string"){const l=e.findIndex(d=>d.id===t);if(l!==-1){a.value=l;return}}a.value<e.length-1&&a.value++}function y(t){if(typeof t=="string"){const l=e.findIndex(d=>d.id===t);if(l!==-1){a.value=l;return}}a.value>0&&a.value--}return(t,l)=>(s(),m("div",K,[C("div",M,[v(N,{dense:"",bordered:"",class:"vertical-nav"},{default:u(()=>[(s(!0),m(L,null,V(S(e),(d,x)=>(s(),I(T,{key:d.id,clickable:"",onClick:te=>a.value=x,active:a.value===x},{default:u(()=>[v(j,null,{default:u(()=>[C("div",X,b(x+1)+". "+b(d.title),1)]),_:2},1024)]),_:2},1032,["onClick","active"]))),128))]),_:1})]),C("div",G,[v(z,{flat:"",class:"q-pa-md full-height"},{default:u(()=>[v(q,null,{default:u(()=>[_.value?B("",!0):(s(),m("div",J,b(S(e)[a.value]?.title),1)),_.value&&h.value?(s(),I(F(_.value),{key:1,step:h.value,onNext:n,onPrev:y},null,40,["step"])):(s(),m("div",U))]),_:1})]),_:1})])]))}}),E=(r,o)=>{const i=r.__vccOpts||r;for(const[e,a]of o)i[e]=a;return i},Y=E(W,[["__scopeId","data-v-eaa5d621"]]),Z=k({__name:"IndexPage",setup(r){return(o,i)=>(s(),I(H,{class:"row full-width justify-center full-height"},{default:u(()=>[v(Y,{class:"no-shadow full-height full-width"})]),_:1}))}}),ee=E(Z,[["__scopeId","data-v-1cafec6b"]]),re=Object.freeze(Object.defineProperty({__proto__:null,default:ee},Symbol.toStringTag,{value:"Module"}));export{re as I,E as _};

View File

@@ -0,0 +1 @@
.q-stepper[data-v-eaa5d621]{max-width:900px;margin:0 auto}.full-height[data-v-eaa5d621]{height:100%}.vertical-nav .q-item[data-v-eaa5d621]{border-radius:4px;margin-bottom:4px;max-width:350px}.vertical-nav .q-item--active[data-v-eaa5d621]{background-color:var(--q-color-primary)!important}.vertical-nav .q-item--active .text-body1[data-v-eaa5d621],.vertical-nav .q-item--active .q-item__label[data-v-eaa5d621]{font-weight:700!important}[data-v-1cafec6b] .no-shadow .q-stepper{box-shadow:none!important;height:100%!important;width:100%!important}[data-v-1cafec6b] .no-shadow .q-stepper .q-step__content,[data-v-1cafec6b] .no-shadow .q-stepper .q-card{height:100%!important}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.q-card[data-v-d74d6ed8]{width:100%;margin:0}

File diff suppressed because one or more lines are too long

1
app/dist/spa/assets/QList-CHwmSUjA.js vendored Normal file
View File

@@ -0,0 +1 @@
import{c as v,a,h as c,b as q,aj as I,H as k,g,I as y,ak as A,r as b,al as K,G as R,p as $}from"./index-0B2sgEyA.js";const D=v({name:"QItemSection",props:{avatar:Boolean,thumbnail:Boolean,side:Boolean,top:Boolean,noWrap:Boolean},setup(e,{slots:n}){const l=a(()=>`q-item__section column q-item__section--${e.avatar===!0||e.side===!0||e.thumbnail===!0?"side":"main"}`+(e.top===!0?" q-item__section--top justify-start":" justify-center")+(e.avatar===!0?" q-item__section--avatar":"")+(e.thumbnail===!0?" q-item__section--thumbnail":"")+(e.noWrap===!0?" q-item__section--nowrap":""));return()=>c("div",{class:l.value},q(n.default))}}),F=v({name:"QItem",props:{...k,...I,tag:{type:String,default:"div"},active:{type:Boolean,default:null},clickable:Boolean,dense:Boolean,insetLevel:Number,tabindex:[String,Number],focused:Boolean,manualFocus:Boolean},emits:["click","keyup"],setup(e,{slots:n,emit:l}){const{proxy:{$q:i}}=g(),d=y(e,i),{hasLink:s,linkAttrs:h,linkClass:B,linkTag:_,navigateOnClick:C}=A(),o=b(null),r=b(null),m=a(()=>e.clickable===!0||s.value===!0||e.tag==="label"),u=a(()=>e.disable!==!0&&m.value===!0),x=a(()=>"q-item q-item-type row no-wrap"+(e.dense===!0?" q-item--dense":"")+(d.value===!0?" q-item--dark":"")+(s.value===!0&&e.active===null?B.value:e.active===!0?` q-item--active${e.activeClass!==void 0?` ${e.activeClass}`:""}`:"")+(e.disable===!0?" disabled":"")+(u.value===!0?" q-item--clickable q-link cursor-pointer "+(e.manualFocus===!0?"q-manual-focusable":"q-focusable q-hoverable")+(e.focused===!0?" q-manual-focusable--focused":""):"")),L=a(()=>e.insetLevel===void 0?null:{["padding"+(i.lang.rtl===!0?"Right":"Left")]:16+e.insetLevel*56+"px"});function E(t){u.value===!0&&(r.value!==null&&t.qAvoidFocus!==!0&&(t.qKeyEvent!==!0&&document.activeElement===o.value?r.value.focus():document.activeElement===r.value&&o.value.focus()),C(t))}function Q(t){if(u.value===!0&&K(t,[13,32])===!0){R(t),t.qKeyEvent=!0;const f=new MouseEvent("click",t);f.qKeyEvent=!0,o.value.dispatchEvent(f)}l("keyup",t)}function S(){const t=$(n.default,[]);return u.value===!0&&t.unshift(c("div",{class:"q-focus-helper",tabindex:-1,ref:r})),t}return()=>{const t={ref:o,class:x.value,style:L.value,role:"listitem",onClick:E,onKeyup:Q};return u.value===!0?(t.tabindex=e.tabindex||"0",Object.assign(t,h.value)):m.value===!0&&(t["aria-disabled"]="true"),c(_.value,t,S())}}}),j=["ul","ol"],P=v({name:"QList",props:{...k,bordered:Boolean,dense:Boolean,separator:Boolean,padding:Boolean,tag:{type:String,default:"div"}},setup(e,{slots:n}){const l=g(),i=y(e,l.proxy.$q),d=a(()=>j.includes(e.tag)?null:"list"),s=a(()=>"q-list"+(e.bordered===!0?" q-list--bordered":"")+(e.dense===!0?" q-list--dense":"")+(e.separator===!0?" q-list--separator":"")+(i.value===!0?" q-list--dark":"")+(e.padding===!0?" q-list--padding":""));return()=>c(e.tag,{class:s.value,role:d.value},q(n.default))}});export{P as Q,F as a,D as b};

View File

@@ -0,0 +1 @@
import{$ as T,Z as k,r as w,a as d,o as A,f as Q,a0 as R,a3 as b,a1 as c,a4 as r,ae as S,a5 as n,a8 as I,ac as l,a6 as x,am as F,a9 as M,af as j,an as E,ao as f,ah as L}from"./index-0B2sgEyA.js";import{Q as O}from"./userstore-DBCughIu.js";import{u as X}from"./taxstore-oR45_mFg.js";import{A as Z}from"./AddressInput-Brku8Gup.js";import{C as $}from"./CommentAttachment-BuVtM3GK.js";import{u as z}from"./vue-i18n.runtime-DPKkE7zN.js";import{_ as G}from"./IndexPage-CwM_myNb.js";import"./LocalStorage-7Uw3xG9P.js";import"./QList-CHwmSUjA.js";const H={class:"row items-center"},J={class:"col"},K={class:"text-h5"},W={class:"col-auto"},Y={class:"row items-center q-gutter-md q-mb-sm q-ml-none"},ee={class:"col q-ml-none"},ae={key:0,class:"q-mt-sm"},te=T({__name:"TaxpayerStep",props:{step:{}},emits:["next","prev"],setup(V,{emit:N}){const _=V,v=N,y=_.step,i=X(),e=k({}),{t:s}=z(),u=w(null),D=(o="Required")=>{const a=s("validation.required");return a&&a!=="validation.required"?a:o},m=o=>a=>{const t=o||D();return a==null?t:typeof a=="string"?a.trim()!==""||t:Array.isArray(a)?a.length>0||t:!0},p=d(()=>!!e.prevPreparedByUs),g=d(()=>p.value?[]:[m()]),q=d(()=>p.value?[]:[m()]),B=d(()=>p.value?[]:[m()]);A(async()=>{const o=i.getTaxpayer()||{};Object.assign(e,o),await Q(),u.value?.resetValidation?.()});async function U(){try{if(await(u.value?.validate?.()??!0)===!1)return}catch{return}i.setTaxpayer({prevPreparedByUs:e.prevPreparedByUs,prevDeclaration:e.prevDeclaration,firstName:e.firstName,lastName:e.lastName,birthDate:e.birthDate,address:e.address}),v("next",y.next)}function h(){i.setTaxpayer({prevPreparedByUs:e.prevPreparedByUs,prevDeclaration:e.prevDeclaration,firstName:e.firstName,lastName:e.lastName,birthDate:e.birthDate,address:e.address}),v("prev",y.prev)}function C(){}function P(){}return(o,a)=>(b(),R(L,{flat:"",class:"full-width q-pa-none"},{default:c(()=>[r(S,{class:"full-width"},{default:c(()=>[n("div",H,[n("div",J,[n("div",K,I(l(s)("TAX")),1)]),n("div",W,[r(x,{flat:"",color:"secondary",label:l(s)("button.prev"),onClick:h,class:"q-mr-sm"},null,8,["label"]),r(x,{color:"primary",label:l(s)("button.next"),onClick:U},null,8,["label"])])]),r(F,{class:"q-my-sm"}),r(O,{ref_key:"formRef",ref:u,class:"q-gutter-md q-mt-md"},{default:c(()=>[n("div",Y,[n("div",ee,[r(E,{modelValue:e.prevPreparedByUs,"onUpdate:modelValue":a[0]||(a[0]=t=>e.prevPreparedByUs=t),label:l(s)("taxpayer.prevPreparedByUs")},null,8,["modelValue","label"])])]),e.prevPreparedByUs?(b(),M("div",ae,[r($,{modelValue:e.prevDeclaration,"onUpdate:modelValue":a[1]||(a[1]=t=>e.prevDeclaration=t),label:l(s)("taxpayer.prevDeclaration"),id:"taxpayer"},null,8,["modelValue","label"])])):j("",!0),n("div",null,[r(f,{modelValue:e.firstName,"onUpdate:modelValue":a[2]||(a[2]=t=>e.firstName=t),label:l(s)("taxpayer.firstName"),rules:g.value},null,8,["modelValue","label","rules"]),r(f,{modelValue:e.lastName,"onUpdate:modelValue":a[3]||(a[3]=t=>e.lastName=t),label:l(s)("taxpayer.lastName"),rules:q.value},null,8,["modelValue","label","rules"]),r(f,{modelValue:e.birthDate,"onUpdate:modelValue":a[4]||(a[4]=t=>e.birthDate=t),type:"date",label:l(s)("taxpayer.birthDate"),rules:B.value},null,8,["modelValue","label","rules"]),r(Z,{modelValue:e.address,"onUpdate:modelValue":a[5]||(a[5]=t=>e.address=t),label:l(s)("taxpayer.address"),allowForeign:!0,onSave:P,onCancel:C},null,8,["modelValue","label"])])]),_:1},512)]),_:1})]),_:1}))}}),pe=G(te,[["__scopeId","data-v-e1b08f4f"]]);export{pe as default};

View File

@@ -0,0 +1 @@
.q-card[data-v-e1b08f4f]{width:100%;margin:0}

View File

@@ -0,0 +1 @@
.q-card[data-v-f4c03057]{width:100%;margin:0}

View File

@@ -0,0 +1 @@
import{$ as d,a0 as m,a3 as f,a1 as e,a4 as s,ae as u,a5 as t,a8 as o,ac as c,a6 as h,am as x,ah as v}from"./index-0B2sgEyA.js";import{u as S}from"./vue-i18n.runtime-DPKkE7zN.js";import{_ as C}from"./IndexPage-CwM_myNb.js";import"./QList-CHwmSUjA.js";const w={class:"row items-center"},y={class:"col"},B={class:"text-h5"},N={class:"col-auto"},Q=d({__name:"WelcomeStep",props:{step:{}},emits:["next"],setup(n,{emit:r}){const i=n,l=r,a=i.step,{t:p}=S();function _(){l("next",a?.next)}return(g,k)=>(f(),m(v,{flat:"",class:"full-width q-pa-none"},{default:e(()=>[s(u,{class:"full-width"},{default:e(()=>[t("div",w,[t("div",y,[t("div",B,o(c(p)("WEL")),1)]),t("div",N,[s(h,{color:"primary",label:"Avanti",onClick:_})])]),s(x,{class:"q-my-sm"}),t("pre",null,o(JSON.stringify(c(a),null,2)),1)]),_:1})]),_:1}))}}),b=C(Q,[["__scopeId","data-v-f4c03057"]]);export{b as default};

View File

@@ -0,0 +1 @@
import{as as i}from"./index-0B2sgEyA.js";import{P as r}from"./LocalStorage-7Uw3xG9P.js";const s="children:v1",a={hasChildren:!1,children:[],moreThanFiveChildrenNote:""},o=i("childrenstore",{state:()=>{try{let e=r.getItem(s);if(typeof e=="string")try{e=JSON.parse(e)}catch{e=null}if(e&&typeof e=="object")return{data:{...e}}}catch{}return{data:{...a}}},actions:{persist(){try{const e={hasChildren:!!this.data.hasChildren,children:Array.isArray(this.data.children)?this.data.children.map(t=>({firstName:t.firstName,lastName:t.lastName,birthDate:t.birthDate,sameHousehold:t.sameHousehold,alimentiVersati:t.alimentiVersati??!1,school:t.school,hasCareCost:t.hasCareCost??!1,careCosts:t.careCosts,address:t.address??null})):[],moreThanFiveChildrenNote:this.data.moreThanFiveChildrenNote||""};r.set(s,e)}catch(e){console.error("children.store: persist error",e)}},getChildren(){return this.data},setChildren(e){this.data={...this.data,...e},this.persist()},replaceChildren(e){this.data=e,this.persist()},resetChildren(){this.data={...a},this.persist()}}});export{o as u};

Binary file not shown.

1
app/dist/spa/assets/i18n-iY85aRww.js vendored Normal file

File diff suppressed because one or more lines are too long

2
app/dist/spa/assets/index-0B2sgEyA.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{as as n}from"./index-0B2sgEyA.js";import{P as e}from"./LocalStorage-7Uw3xG9P.js";const s="taxstore:v1",i={prevPreparedByUs:!1,prevDeclaration:{comments:"",attachments:[]},firstName:"",lastName:"",birthDate:"",address:{street:"",cap:"",city:"",country:{code:"",name:""},canton:""}},r={alimentiVersati:!1,alimentiCommenti:{comments:"",attachments:[]},maritalStatus:"",spouseFirstName:"",spouseLastName:"",spouseBirthDate:"",spouseDeadDate:"",spouseTaxNumber:"",spouseAddress:{street:"",cap:"",city:"",country:{code:"",name:""},canton:""},marriageDate:"",separated:!1,spouseAlimentiVersati:!1},c=n("taxstore",{state:()=>{try{const t=e.getItem(s);if(t&&typeof t=="object"){const a=t;if(Array.isArray(a.items))return{items:a.items}}}catch{}return{items:[{key:"taxpayer",data:{...i}},{key:"marital",data:{...r}}]}},actions:{persist(){try{e.set(s,{items:this.items})}catch{}},_findTaxpayer(){return this.items.find(t=>t.key==="taxpayer")},_findMarital(){return this.items.find(t=>t.key==="marital")},getTaxpayer(){return this._findTaxpayer()?.data},setTaxpayer(t){const a=this._findTaxpayer();a&&(a.data={...a.data,...t}),this.persist()},replaceTaxpayer(t){const a=this._findTaxpayer();a&&(a.data=t),this.persist()},resetTaxpayer(){const t=this._findTaxpayer();t&&(t.data={...i}),this.persist()},getMarital(){return this._findMarital()?.data},setMarital(t){const a=this._findMarital();a&&(a.data={...a.data,...t}),this.persist()},replaceMarital(t){const a=this._findMarital();a&&(a.data=t),this.persist()},resetMarital(){const t=this._findMarital();t&&(t.data={...r}),this.persist()}}});export{c as u};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

BIN
app/dist/spa/favicon.ico vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

BIN
app/dist/spa/icons/favicon-128x128.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
app/dist/spa/icons/favicon-16x16.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 B

BIN
app/dist/spa/icons/favicon-32x32.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
app/dist/spa/icons/favicon-96x96.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

3
app/dist/spa/index.html vendored Normal file
View File

@@ -0,0 +1,3 @@
<!doctype html><html><head><title>dichiarazione fiscale</title><meta charset=utf-8><meta name=description content="Dichiarazione fiscale automatizzata"><meta name=format-detection content="telephone=no"><meta name=msapplication-tap-highlight content=no><meta name=viewport content="user-scalable=no,initial-scale=1,maximum-scale=1,minimum-scale=1,width=device-width"><link rel=icon type=image/png sizes=128x128 href=/icons/favicon-128x128.png><link rel=icon type=image/png sizes=96x96 href=/icons/favicon-96x96.png><link rel=icon type=image/png sizes=32x32 href=/icons/favicon-32x32.png><link rel=icon type=image/png sizes=16x16 href=/icons/favicon-16x16.png><link rel=icon type=image/ico href=/favicon.ico> <script type="module" crossorigin src="/assets/index-0B2sgEyA.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BQveqNql.css">
</head><body><div id=q-app></div></body></html>

BIN
app/docs/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,72 @@
# Manuale utente (BRUNO Frontend)
## Panoramica
Questa applicazione guida lutente nella compilazione di un questionario a step (passi). Ogni step raccoglie dati e, dove previsto, permette di allegare documenti.
## Navigazione
- Nel menu laterale sinistro trovi la lista degli step.
- In alto a destra (negli step che lo prevedono) trovi i pulsanti **Indietro** / **Avanti** per muoverti nel flusso.
- Alcuni step non hanno ancora una schermata dedicata: in quel caso viene mostrato un contenuto “placeholder”.
## Validazione dei campi
- I campi obbligatori vengono validati tramite il pulsante **Avanti**.
- Se un campo è obbligatorio e mancante, viene mostrato un messaggio di errore.
## Allegati (componenti “CommentAttachment”)
In alcune sezioni puoi inserire:
- un campo **Commenti**
- una lista di **Allegati**
Funzionalità disponibili:
- Selezione file tramite pulsante (picker).
- Upload con **barra di progresso**.
- Possibilità di **annullare** lupload in corso.
- Lista degli allegati già caricati.
- Eliminazione di un allegato con **conferma**.
Formati file ammessi:
- `.pdf`, `.docx`, `.txt`, `.md`
Nota: gli allegati vengono associati a una “sessione” (lo step/contesto in cui stai caricando) e a un identificativo utente interno.
## Step disponibili
### 1) Benvenuto
- Mostra unintroduzione e consente di proseguire allo step successivo.
### 2) Dati contribuente e dichiarazione precedente
- Toggle: **Preparata da noi?**
- Sezione **Dichiarazione precedente** (allegati + commenti) quando disponibile.
- Dati anagrafici del contribuente:
- Nome, Cognome, Data di nascita
- Indirizzo (tramite editor indirizzo)
### 3) Stato civile
- Selezione stato civile.
- Campi relativi al coniuge/partner quando applicabili.
- Gestione indirizzo del coniuge/partner tramite editor indirizzo.
### 4) Figli
- Toggle: **Hai figli?**
- Gestione lista figli (massimo 5):
- Aggiunta/modifica tramite finestra (dialog)
- Eliminazione dalla lista
- Dati figlio:
- Nome, Cognome, Data di nascita
- Toggle **Stesso nucleo familiare**
- Se NON nello stesso nucleo: possibilità di indicare alimenti e inserire un indirizzo dedicato
- Campo **Scuola**
- Toggle **Spese di cura**: se attivo, si sblocca la sezione allegati/commenti per le spese di cura
- Pulsante rapido (icona) per **copiare il cognome** dal contribuente nel dialog del figlio.
- Se raggiungi 5 figli, compare una nota testuale per indicare “più di cinque figli”.
## Salvataggio dati
I dati inseriti vengono mantenuti localmente (persistenza) per evitare perdite durante la compilazione.
## Risoluzione problemi
- **Non riesco a caricare allegati**: verifica che il server di upload sia attivo e raggiungibile (es. ambiente locale). Se lupload resta bloccato, annulla e riprova.
- **Messaggi di validazione**: completa i campi richiesti e riprova con **Avanti**.
---
Se vuoi, posso anche generare una versione “stampabile” (più sintetica, 12 pagine) oppure una versione “operatore” con esempi di documenti da allegare per ogni step.

BIN
app/docs/Manuale_Utente.pdf Normal file

Binary file not shown.

View File

@@ -0,0 +1,43 @@
# Report — 2026-01-11
Breve riepilogo delle modifiche svolte ieri:
- Dati e lookup
- Importato dataset CAP Svizzera in `src/data/countryCodes.ts` e esposti helper: `findPostalCodes`, `findPostalCodeDetails`, `ALL_CH_POSTAL_CODES`.
- Componenti e UX indirizzi
- Ripristinato e rifattorizzato `src/components/AddressModal.vue` (SFC pulita).
- Aggiunta prop `allowForeign?: boolean` (default true); quando `allowForeign === false` il toggle `foreign` è nascosto e forzato a `false`.
- ZIP input sanitizzato (solo cifre, maxlength 4); con 4 cifre per CAP CH la città viene autocompletata.
- `ChildrenStep` ora salva oggetti `address?: Address | null` (non più la stringa composta); `formatAddressForString` usato solo per rendering.
- `TaxpayerStep` e `MaritalStep` integrati con `AddressModal` (in `TaxpayerStep` `:allowForeign="false"`).
- Store e tipi
- `src/types/schema.ts`: estesi `MaritalData` con `spousePreviousDivorces` e `spouseAlimentiVersati`.
- `src/stores/taxstore.ts`: aggiornato `defaultMarital` per includere i nuovi campi a `false`.
- `src/stores/children.ts`: persistenza aggiornata per `alimentiVersati` (se applicabile).
- UI e layout
- Spostati i pulsanti `Prev`/`Next` sulla stessa riga del titolo in tutti gli step (`src/components/steps/*`): titolo in `div.col`, bottoni in `div.col-auto` dentro `div.row`.
- Aggiunti `q-separator` dopo i titoli.
- In `ChildrenStep` spostato anche il layout del modal (titolo + bottoni coerenti).
- Campi e regole specifiche
- `ChildrenStep` modal: aggiunta toggle `alimentiVersati` (visibile solo se `sameHousehold === false`), e messaggio di validazione `validation.insertAddress` se manca l'indirizzo.
- `MaritalStep`: sostituiti campi `spouse.address/zip/city` con `AddressModal`; aggiunti toggle `spouse.previousDivorces` e `spouse.alimentiVersati` e persistenza nel payload.
- i18n
- Aggiunte traduzioni: `enum.maritalStatus.SEPARATED`, `marital.previousDivorces`, `marital.spouse.previousDivorces`, e `validation.insertAddress` in `it-IT`, `en-US`, `de-DE`, `fr-FR`.
- Pulizia e correzioni
- Risolti errori TypeScript/ESLint derivanti da SFC corrotta; rimossi helper inutilizzati e sistemati cast e tipi.
- Linter e `vue-tsc` eseguiti più volte; stato attuale: lint e type-check OK (solo avviso deprecazione `.eslintignore`).
Note e prossimi passi suggeriti
- Valutare se persistere l'`Address` strutturato anche per il `Taxpayer` (ora usa campi piatti `address/zip/city`).
- Commit e review dei cambi (ci sono molte modifiche locali non ancora committate?).
- QA manuale in browser per verificare: modal indirizzo, autocompletamento CAP CH, visualizzazione dei messaggi di validazione e comportamento dei toggle `allowForeign`.
---
Generato automaticamente dal worklog in repo. Se vuoi, aggiorno anche il `CHANGELOG.md` o aggiungo il report in un formato diverso.

83
app/eslint.config.js Normal file
View File

@@ -0,0 +1,83 @@
import js from '@eslint/js';
import globals from 'globals';
import pluginVue from 'eslint-plugin-vue';
import pluginQuasar from '@quasar/app-vite/eslint';
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript';
import prettierSkipFormatting from '@vue/eslint-config-prettier/skip-formatting';
export default defineConfigWithVueTs(
{
/**
* Ignore the following files.
* Please note that pluginQuasar.configs.recommended() already ignores
* the "node_modules" folder for you (and all other Quasar project
* relevant folders and files).
*
* ESLint requires "ignores" key to be the only one in this object
*/
// ignores: []
},
pluginQuasar.configs.recommended(),
js.configs.recommended,
/**
* https://eslint.vuejs.org
*
* pluginVue.configs.base
* -> Settings and rules to enable correct ESLint parsing.
* pluginVue.configs[ 'flat/essential']
* -> base, plus rules to prevent errors or unintended behavior.
* pluginVue.configs["flat/strongly-recommended"]
* -> Above, plus rules to considerably improve code readability and/or dev experience.
* pluginVue.configs["flat/recommended"]
* -> Above, plus rules to enforce subjective community defaults to ensure consistency.
*/
pluginVue.configs['flat/essential'],
{
files: ['**/*.ts', '**/*.vue'],
rules: {
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
},
},
// https://github.com/vuejs/eslint-config-typescript
vueTsConfigs.recommendedTypeChecked,
{
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
...globals.browser,
...globals.node, // SSR, Electron, config files
process: 'readonly', // process.env.*
ga: 'readonly', // Google Analytics
cordova: 'readonly',
Capacitor: 'readonly',
chrome: 'readonly', // BEX related
browser: 'readonly', // BEX related
},
},
// add your custom rules here
rules: {
'prefer-promise-reject-errors': 'off',
// allow debugger during development only
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
},
},
{
files: ['src-pwa/custom-service-worker.ts'],
languageOptions: {
globals: {
...globals.serviceworker,
},
},
},
prettierSkipFormatting,
);

24
app/index.html Normal file
View File

@@ -0,0 +1,24 @@
<!doctype html>
<html>
<head>
<title><%= productName %></title>
<meta charset="utf-8" />
<meta name="description" content="<%= productDescription %>" />
<meta name="format-detection" content="telephone=no" />
<meta name="msapplication-tap-highlight" content="no" />
<meta
name="viewport"
content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width<% if (ctx.mode.cordova || ctx.mode.capacitor) { %>, viewport-fit=cover<% } %>"
/>
<link rel="icon" type="image/png" sizes="128x128" href="icons/favicon-128x128.png" />
<link rel="icon" type="image/png" sizes="96x96" href="icons/favicon-96x96.png" />
<link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="icons/favicon-16x16.png" />
<link rel="icon" type="image/ico" href="favicon.ico" />
</head>
<body>
<!-- quasar:entry-point -->
</body>
</html>

49
app/package.json Normal file
View File

@@ -0,0 +1,49 @@
{
"name": "app",
"version": "0.0.1",
"description": "Dichiarazione fiscale automatizzata",
"productName": "dichiarazione fiscale",
"author": "Fabio Prada <fabio.prada@omnimed.ch>",
"type": "module",
"private": true,
"scripts": {
"lint": "eslint -c ./eslint.config.js \"./src*/**/*.{ts,js,cjs,mjs,vue}\"",
"format": "prettier --write \"**/*.{js,ts,vue,scss,html,md,json}\" --ignore-path .gitignore",
"test": "echo \"No test specified\" && exit 0",
"dev": "quasar dev",
"build": "quasar build",
"tsc": "vue-tsc --noEmit",
"docs:pdf": "pnpm -s dlx md-to-pdf ./docs/Manuale_Utente.md --launch-options '{\"executablePath\":\"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\"}'",
"postinstall": "quasar prepare"
},
"dependencies": {
"vue-i18n": "^11.0.0",
"pinia": "^3.0.1",
"@quasar/extras": "^1.16.4",
"quasar": "^2.16.0",
"vue": "^3.5.22",
"vue-router": "^4.0.12"
},
"devDependencies": {
"@eslint/js": "^9.14.0",
"eslint": "^9.14.0",
"eslint-plugin-vue": "^10.4.0",
"globals": "^16.4.0",
"vue-tsc": "^3.0.7",
"@vue/eslint-config-typescript": "^14.4.0",
"vite-plugin-checker": "^0.11.0",
"vue-eslint-parser": "^10.2.0",
"@vue/eslint-config-prettier": "^10.1.0",
"prettier": "^3.3.3",
"@types/node": "^20.5.9",
"@intlify/unplugin-vue-i18n": "^4.0.0",
"@quasar/app-vite": "^2.1.0",
"autoprefixer": "^10.4.2",
"typescript": "^5.9.2"
},
"engines": {
"node": "^28 || ^26 || ^24 || ^22 || ^20",
"npm": ">= 6.13.4",
"yarn": ">= 1.21.1"
}
}

5520
app/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

29
app/postcss.config.js Normal file
View File

@@ -0,0 +1,29 @@
// https://github.com/michael-ciniawsky/postcss-load-config
import autoprefixer from 'autoprefixer';
// import rtlcss from 'postcss-rtlcss'
export default {
plugins: [
// https://github.com/postcss/autoprefixer
autoprefixer({
overrideBrowserslist: [
'last 4 Chrome versions',
'last 4 Firefox versions',
'last 4 Edge versions',
'last 4 Safari versions',
'last 4 Android versions',
'last 4 ChromeAndroid versions',
'last 4 FirefoxAndroid versions',
'last 4 iOS versions',
],
}),
// https://github.com/elchininet/postcss-rtlcss
// If you want to support RTL css, then
// 1. yarn/pnpm/bun/npm install postcss-rtlcss
// 2. optionally set quasar.config.js > framework > lang to an RTL language
// 3. uncomment the following line (and its import statement above):
// rtlcss()
],
};

BIN
app/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

239
app/quasar.config.ts Normal file
View File

@@ -0,0 +1,239 @@
// Configuration for your app
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file
import { defineConfig } from '#q-app/wrappers';
import { fileURLToPath } from 'node:url';
export default defineConfig((ctx) => {
return {
// https://v2.quasar.dev/quasar-cli-vite/prefetch-feature
// preFetch: true,
// app boot file (/src/boot)
// --> boot files are part of "main.js"
// https://v2.quasar.dev/quasar-cli-vite/boot-files
boot: ['i18n'],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#css
css: ['app.scss'],
// https://github.com/quasarframework/quasar/tree/dev/extras
extras: [
// 'ionicons-v4',
// 'mdi-v7',
// 'fontawesome-v6',
// 'eva-icons',
// 'themify',
// 'line-awesome',
// 'roboto-font-latin-ext', // this or either 'roboto-font', NEVER both!
'roboto-font', // optional, you are not bound to it
'material-icons', // optional, you are not bound to it
],
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#build
build: {
target: {
browser: ['es2022', 'firefox115', 'chrome115', 'safari14'],
node: 'node20',
},
typescript: {
strict: true,
vueShim: true,
// extendTsConfig (tsConfig) {}
},
vueRouterMode: 'hash', // available values: 'hash', 'history'
// vueRouterBase,
// vueDevtools,
// vueOptionsAPI: false,
// rebuildCache: true, // rebuilds Vite/linter/etc cache on startup
// publicPath: '/',
// analyze: true,
// env: {},
// rawDefine: {}
// ignorePublicFolder: true,
// minify: false,
// polyfillModulePreload: true,
// distDir
// extendViteConf (viteConf) {},
// viteVuePluginOptions: {},
vitePlugins: [
[
'@intlify/unplugin-vue-i18n/vite',
{
// if you want to use Vue I18n Legacy API, you need to set `compositionOnly: false`
// compositionOnly: false,
// if you want to use named tokens in your Vue I18n messages, such as 'Hello {name}',
// you need to set `runtimeOnly: false`
// runtimeOnly: false,
ssr: ctx.modeName === 'ssr',
// you need to set i18n resource including paths !
include: [fileURLToPath(new URL('./src/i18n', import.meta.url))],
},
],
[
'vite-plugin-checker',
{
vueTsc: true,
eslint: {
lintCommand: 'eslint -c ./eslint.config.js "./src*/**/*.{ts,js,mjs,cjs,vue}"',
useFlatConfig: true,
},
},
{ server: false },
],
],
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#devserver
devServer: {
// https: true,
open: true, // opens browser window automatically
},
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#framework
framework: {
config: {
notify: {
position: 'top-right',
},
},
// iconSet: 'material-icons', // Quasar icon set
// lang: 'en-US', // Quasar language pack
// For special cases outside of where the auto-import strategy can have an impact
// (like functional components as one of the examples),
// you can manually specify Quasar components/directives to be available everywhere:
//
// components: [],
// directives: [],
// Quasar plugins
plugins: ['Dialog', 'Notify'],
},
// animations: 'all', // --- includes all animations
// https://v2.quasar.dev/options/animations
animations: [],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#sourcefiles
// sourceFiles: {
// rootComponent: 'src/App.vue',
// router: 'src/router/index',
// store: 'src/store/index',
// pwaRegisterServiceWorker: 'src-pwa/register-service-worker',
// pwaServiceWorker: 'src-pwa/custom-service-worker',
// pwaManifestFile: 'src-pwa/manifest.json',
// electronMain: 'src-electron/electron-main',
// electronPreload: 'src-electron/electron-preload'
// bexManifestFile: 'src-bex/manifest.json
// },
// https://v2.quasar.dev/quasar-cli-vite/developing-ssr/configuring-ssr
ssr: {
prodPort: 3000, // The default port that the production server should use
// (gets superseded if process.env.PORT is specified at runtime)
middlewares: [
'render', // keep this as last one
],
// extendPackageJson (json) {},
// extendSSRWebserverConf (esbuildConf) {},
// manualStoreSerialization: true,
// manualStoreSsrContextInjection: true,
// manualStoreHydration: true,
// manualPostHydrationTrigger: true,
pwa: false,
// pwaOfflineHtmlFilename: 'offline.html', // do NOT use index.html as name!
// pwaExtendGenerateSWOptions (cfg) {},
// pwaExtendInjectManifestOptions (cfg) {}
},
// https://v2.quasar.dev/quasar-cli-vite/developing-pwa/configuring-pwa
pwa: {
workboxMode: 'GenerateSW', // 'GenerateSW' or 'InjectManifest'
// swFilename: 'sw.js',
// manifestFilename: 'manifest.json',
// extendManifestJson (json) {},
// useCredentialsForManifestTag: true,
// injectPwaMetaTags: false,
// extendPWACustomSWConf (esbuildConf) {},
// extendGenerateSWOptions (cfg) {},
// extendInjectManifestOptions (cfg) {}
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-cordova-apps/configuring-cordova
cordova: {
// noIosLegacyBuildFlag: true, // uncomment only if you know what you are doing
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-capacitor-apps/configuring-capacitor
capacitor: {
hideSplashscreen: true,
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-electron-apps/configuring-electron
electron: {
// extendElectronMainConf (esbuildConf) {},
// extendElectronPreloadConf (esbuildConf) {},
// extendPackageJson (json) {},
// Electron preload scripts (if any) from /src-electron, WITHOUT file extension
preloadScripts: ['electron-preload'],
// specify the debugging port to use for the Electron app when running in development mode
inspectPort: 5858,
bundler: 'packager', // 'packager' or 'builder'
packager: {
// https://github.com/electron-userland/electron-packager/blob/master/docs/api.md#options
// OS X / Mac App Store
// appBundleId: '',
// appCategoryType: '',
// osxSign: '',
// protocol: 'myapp://path',
// Windows only
// win32metadata: { ... }
},
builder: {
// https://www.electron.build/configuration/configuration
appId: 'app',
},
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-browser-extensions/configuring-bex
bex: {
// extendBexScriptsConf (esbuildConf) {},
// extendBexManifestJson (json) {},
/**
* The list of extra scripts (js/ts) not in your bex manifest that you want to
* compile and use in your browser extension. Maybe dynamic use them?
*
* Each entry in the list should be a relative filename to /src-bex/
*
* @example [ 'my-script.ts', 'sub-folder/my-other-script.js' ]
*/
extraScripts: [],
},
};
});

25
app/scripts/gen-swiss.cjs Normal file
View File

@@ -0,0 +1,25 @@
const fs = require('fs')
const path = require('path')
const infile = path.join(__dirname, '..', '..', 'CH.txt')
const out = fs.readFileSync(infile, 'utf8')
const lines = out.split(/\r?\n/)
const map = new Map()
for (const line of lines) {
if (!line || line.trim() === '') continue
const parts = line.split('\t')
const zip = parts[1]
const name = parts[2]
const canton = parts[4] || ''
if (!zip) continue
if (!map.has(zip)) {
map.set(zip, { zip, city: name, canton })
}
}
const arr = Array.from(map.values())
function escapeStr(s){ return String(s).replace(/\\/g,'\\\\').replace(/'/g, "\\'") }
let ts = 'export const SWISS_CITIES = [\n'
for (const e of arr) {
ts += ` { zip: '${escapeStr(e.zip)}', city: '${escapeStr(e.city)}', canton: '${escapeStr(e.canton)}' },\n`
}
ts += ']\n'
console.log(ts)

26
app/scripts/gen-swiss.js Normal file
View File

@@ -0,0 +1,26 @@
const fs = require('fs')
const path = require('path')
const infile = path.join(__dirname, '..', '..', 'CH.txt')
const out = fs.readFileSync(infile, 'utf8')
const lines = out.split(/\r?\n/)
const map = new Map()
for (const line of lines) {
if (!line || line.trim() === '') continue
const parts = line.split('\t')
// parts: 0=CH,1=zip,2=name,3=canton full,4=canton code...
const zip = parts[1]
const name = parts[2]
const canton = parts[4] || ''
if (!zip) continue
if (!map.has(zip)) {
map.set(zip, { zip, city: name, canton })
}
}
const arr = Array.from(map.values())
function escapeStr(s){ return s.replace(/\\/g,'\\\\').replace(/'/g, "\\'") }
let ts = 'export const SWISS_CITIES = [\n'
for (const e of arr) {
ts += ` { zip: '${escapeStr(e.zip)}', city: '${escapeStr(e.city)}', canton: '${escapeStr(e.canton)}' },\n`
}
ts += ']\n'
console.log(ts)

3365
app/scripts/swiss-out.ts Normal file

File diff suppressed because it is too large Load Diff

7
app/src/App.vue Normal file
View File

@@ -0,0 +1,7 @@
<template>
<router-view />
</template>
<script setup lang="ts">
//
</script>

View File

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 356 360">
<path
d="M43.4 303.4c0 3.8-2.3 6.3-7.1 6.3h-15v-22h14.4c4.3 0 6.2 2.2 6.2 5.2 0 2.6-1.5 4.4-3.4 5 2.8.4 4.9 2.5 4.9 5.5zm-8-13H24.1v6.9H35c2.1 0 4-1.3 4-3.8 0-2.2-1.3-3.1-3.7-3.1zm5.1 12.6c0-2.3-1.8-3.7-4-3.7H24.2v7.7h11.7c3.4 0 4.6-1.8 4.6-4zm36.3 4v2.7H56v-22h20.6v2.7H58.9v6.8h14.6v2.3H58.9v7.5h17.9zm23-5.8v8.5H97v-8.5l-11-13.4h3.4l8.9 11 8.8-11h3.4l-10.8 13.4zm19.1-1.8V298c0-7.9 5.2-10.7 12.7-10.7 7.5 0 13 2.8 13 10.7v1.4c0 7.9-5.5 10.8-13 10.8s-12.7-3-12.7-10.8zm22.7 0V298c0-5.7-3.9-8-10-8-6 0-9.8 2.3-9.8 8v1.4c0 5.8 3.8 8.1 9.8 8.1 6 0 10-2.3 10-8.1zm37.2-11.6v21.9h-2.9l-15.8-17.9v17.9h-2.8v-22h3l15.6 18v-18h2.9zm37.9 10.2v1.3c0 7.8-5.2 10.4-12.4 10.4H193v-22h11.2c7.2 0 12.4 2.8 12.4 10.3zm-3 0c0-5.3-3.3-7.6-9.4-7.6h-8.4V307h8.4c6 0 9.5-2 9.5-7.7V298zm50.8-7.6h-9.7v19.3h-3v-19.3h-9.7v-2.6h22.4v2.6zm34.4-2.6v21.9h-3v-10.1h-16.8v10h-2.8v-21.8h2.8v9.2H296v-9.2h2.9zm34.9 19.2v2.7h-20.7v-22h20.6v2.7H316v6.8h14.5v2.3H316v7.5h17.8zM24 340.2v7.3h13.9v2.4h-14v9.6H21v-22h20v2.7H24zm41.5 11.4h-9.8v7.9H53v-22h13.3c5.1 0 8 1.9 8 6.8 0 3.7-2 6.3-5.6 7l6 8.2h-3.3l-5.8-8zm-9.8-2.6H66c3.1 0 5.3-1.5 5.3-4.7 0-3.3-2.2-4.1-5.3-4.1H55.7v8.8zm47.9 6.2H89l-2 4.3h-3.2l10.7-22.2H98l10.7 22.2h-3.2l-2-4.3zm-1-2.3l-6.3-13-6 13h12.2zm46.3-15.3v21.9H146v-17.2L135.7 358h-2.1l-10.2-15.6v17h-2.8v-21.8h3l11 16.9 11.3-17h3zm35 19.3v2.6h-20.7v-22h20.6v2.7H166v6.8h14.5v2.3H166v7.6h17.8zm47-19.3l-8.3 22h-3l-7.1-18.6-7 18.6h-3l-8.2-22h3.3L204 356l6.8-18.5h3.4L221 356l6.6-18.5h3.3zm10 11.6v-1.4c0-7.8 5.2-10.7 12.7-10.7 7.6 0 13 2.9 13 10.7v1.4c0 7.9-5.4 10.8-13 10.8-7.5 0-12.7-3-12.7-10.8zm22.8 0v-1.4c0-5.7-4-8-10-8s-9.9 2.3-9.9 8v1.4c0 5.8 3.8 8.2 9.8 8.2 6.1 0 10-2.4 10-8.2zm28.3 2.4h-9.8v7.9h-2.8v-22h13.2c5.2 0 8 1.9 8 6.8 0 3.7-2 6.3-5.6 7l6 8.2h-3.3l-5.8-8zm-9.8-2.6h10.2c3 0 5.2-1.5 5.2-4.7 0-3.3-2.1-4.1-5.2-4.1h-10.2v8.8zm40.3-1.5l-6.8 5.6v6.4h-2.9v-22h2.9v12.3l15.2-12.2h3.7l-9.9 8.1 10.3 13.8h-3.6l-8.9-12z" />
<path fill="#050A14"
d="M188.4 71.7a10.4 10.4 0 01-20.8 0 10.4 10.4 0 1120.8 0zM224.2 45c-2.2-3.9-5-7.5-8.2-10.7l-12 7c-3.7-3.2-8-5.7-12.6-7.3a49.4 49.4 0 00-9.7 13.9 59 59 0 0140.1 14l7.6-4.4a57 57 0 00-5.2-12.5zM178 125.1c4.5 0 9-.6 13.4-1.7v-14a40 40 0 0012.5-7.2 47.7 47.7 0 00-7.1-15.3 59 59 0 01-32.2 27.7v8.7c4.4 1.2 8.9 1.8 13.4 1.8zM131.8 45c-2.3 4-4 8.1-5.2 12.5l12 7a40 40 0 000 14.4c5.7 1.5 11.3 2 16.9 1.5a59 59 0 01-8-41.7l-7.5-4.3c-3.2 3.2-6 6.7-8.2 10.6z" />
<path fill="#00B4FF"
d="M224.2 98.4c2.3-3.9 4-8 5.2-12.4l-12-7a40 40 0 000-14.5c-5.7-1.5-11.3-2-16.9-1.5a59 59 0 018 41.7l7.5 4.4c3.2-3.2 6-6.8 8.2-10.7zm-92.4 0c2.2 4 5 7.5 8.2 10.7l12-7a40 40 0 0012.6 7.3c4-4.1 7.3-8.8 9.7-13.8a59 59 0 01-40-14l-7.7 4.4c1.2 4.3 3 8.5 5.2 12.4zm46.2-80c-4.5 0-9 .5-13.4 1.7V34a40 40 0 00-12.5 7.2c1.5 5.7 4 10.8 7.1 15.4a59 59 0 0132.2-27.7V20a53.3 53.3 0 00-13.4-1.8z" />
<path fill="#00B4FF"
d="M178 9.2a62.6 62.6 0 11-.1 125.2A62.6 62.6 0 01178 9.2m0-9.2a71.7 71.7 0 100 143.5A71.7 71.7 0 00178 0z" />
<path fill="#050A14"
d="M96.6 212v4.3c-9.2-.8-15.4-5.8-15.4-17.8V180h4.6v18.4c0 8.6 4 12.6 10.8 13.5zm16-31.9v18.4c0 8.9-4.3 12.8-10.9 13.5v4.4c9.2-.7 15.5-5.6 15.5-18v-18.3h-4.7zM62.2 199v-2.2c0-12.7-8.8-17.4-21-17.4-12.1 0-20.7 4.7-20.7 17.4v2.2c0 12.8 8.6 17.6 20.7 17.6 1.5 0 3-.1 4.4-.3l11.8 6.2 2-3.3-8.2-4-6.4-3.1a32 32 0 01-3.6.2c-9.8 0-16-3.9-16-13.3v-2.2c0-9.3 6.2-13.1 16-13.1 9.9 0 16.3 3.8 16.3 13.1v2.2c0 5.3-2.1 8.7-5.6 10.8l4.8 2.4c3.4-2.8 5.5-7 5.5-13.2zM168 215.6h5.1L156 179.7h-4.8l17 36zM143 205l7.4-15.7-2.4-5-15.1 31.4h5.1l3.3-7h18.3l-1.8-3.7H143zm133.7 10.7h5.2l-17.3-35.9h-4.8l17 36zm-25-10.7l7.4-15.7-2.4-5-15.1 31.4h5.1l3.3-7h18.3l-1.7-3.7h-14.8zm73.8-2.5c6-1.2 9-5.4 9-11.4 0-8-4.5-10.9-12.9-10.9h-21.4v35.5h4.6v-31.3h16.5c5 0 8.5 1.4 8.5 6.7 0 5.2-3.5 7.7-8.5 7.7h-11.4v4.1h10.7l9.3 12.8h5.5l-9.9-13.2zm-117.4 9.9c-9.7 0-14.7-2.5-18.6-6.3l-2.2 3.8c5.1 5 11 6.7 21 6.7 1.6 0 3.1-.1 4.6-.3l-1.9-4h-3zm18.4-7c0-6.4-4.7-8.6-13.8-9.4l-10.1-1c-6.7-.7-9.3-2.2-9.3-5.6 0-2.5 1.4-4 4.6-5l-1.8-3.8c-4.7 1.4-7.5 4.2-7.5 8.9 0 5.2 3.4 8.7 13 9.6l11.3 1.2c6.4.6 8.9 2 8.9 5.4 0 2.7-2.1 4.7-6 5.8l1.8 3.9c5.3-1.6 8.9-4.7 8.9-10zm-20.3-21.9c7.9 0 13.3 1.8 18.1 5.7l1.8-3.9a30 30 0 00-19.6-5.9c-2 0-4 .1-5.7.3l1.9 4 3.5-.2z" />
<path fill="#00B4FF"
d="M.5 251.9c29.6-.5 59.2-.8 88.8-1l88.7-.3 88.7.3 44.4.4 44.4.6-44.4.6-44.4.4-88.7.3-88.7-.3a7981 7981 0 01-88.8-1z" />
<path fill="none" d="M-565.2 324H-252v15.8h-313.2z" />
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

0
app/src/boot/.gitkeep Normal file
View File

33
app/src/boot/i18n.ts Normal file
View File

@@ -0,0 +1,33 @@
import { defineBoot } from '#q-app/wrappers';
import { createI18n } from 'vue-i18n';
import messages from 'src/i18n';
export type MessageLanguages = keyof typeof messages;
// Use a permissive message schema so different locales may contain different string values
export type MessageSchema = Record<string, unknown>;
// See https://vue-i18n.intlify.dev/guide/advanced/typescript.html#global-resource-schema-type-definition
/* eslint-disable @typescript-eslint/no-empty-object-type */
declare module 'vue-i18n' {
// define the locale messages schema
export interface DefineLocaleMessage extends MessageSchema {}
// define the datetime format schema
export interface DefineDateTimeFormat {}
// define the number format schema
export interface DefineNumberFormat {}
}
/* eslint-enable @typescript-eslint/no-empty-object-type */
export default defineBoot(({ app }) => {
const i18n = createI18n<{ message: MessageSchema }, MessageLanguages>({
locale: 'it-IT',
legacy: false,
messages,
});
// Set i18n instance on app
app.use(i18n);
});

View File

@@ -0,0 +1,126 @@
<template>
<div>
<div class="row items-center q-gutter-sm q-mb-sm q-mt-md">
<div class="col-auto">
<q-btn dense flat round icon="edit" @click="open">
<q-tooltip class="bg-primary text-white">{{ t('children.editAddress') }}</q-tooltip>
</q-btn>
</div>
<div class="col">
<div class="text-caption">{{ label }}</div>
</div>
</div>
<div class="q-pa-sm bg-grey-2 q-mb-sm">
<div v-if="isEmpty" class="text-negative">{{ hintText }}</div>
<div v-else>{{ formatted }}</div>
</div>
<AddressModal v-model="isOpen" :modelAddress="modalModel" :allowForeign="allowForeign" @save="onSave" @cancel="onCancel" />
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import AddressModal from './AddressModal.vue'
import type { Address as ModalAddress } from '../types/address'
// public Address shape required by the user
export interface AddressOut {
street: string
cap: string | number
city: string
country: { code: string; name: string }
canton: string
}
const props = defineProps<{
modelValue?: AddressOut | null
label?: string
allowForeign?: boolean
hint?: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: AddressOut | null): void
(e: 'save', v: AddressOut): void
(e: 'cancel'): void
}>()
const { t } = useI18n()
const isOpen = ref(false)
const modalModel = ref<ModalAddress | null>(null)
const label = props.label || t('address')
const allowForeign = props.allowForeign ?? true
const formatted = computed(() => {
const v = props.modelValue
console.log('address input formatted', v)
if (!v) return ''
const countryName = v.country?.name || ''
if (v.country.code === 'CH' ) {
return [v.street, String(v.cap || ''), v.city, v.canton].filter(Boolean).join(', ')
}
return [v.street, String(v.cap || ''), v.city, countryName].filter(Boolean).join(', ')
})
const isEmpty = computed(() => {
const v = props.modelValue
if (!v) return true
// consider address empty when no meaningful fields are present
return !(v.street || v.city || v.cap || (v.country && v.country.code))
})
const hintText = computed(() => props.hint || t('validation.insertAddress'))
function open() {
const v = props.modelValue
modalModel.value = v
? {
street: v.street || '',
zip: String(v.cap || ''),
city: v.city || '',
country: (v.country && v.country.code) || '',
canton: v.canton || '',
foreign: (v.country && v.country.code && v.country.code !== 'CH') ? true : false
}
: { street: '', zip: '', city: '', country: '', canton: '', foreign: true }
isOpen.value = true
}
function onSave(a: ModalAddress) {
// normalize country to object {code,name}
let countryObj = { code: '', name: '' }
if (!a.country) {
countryObj = { code: '', name: '' }
} else if (typeof a.country === 'string') {
countryObj = { code: a.country, name: a.country }
} else if (typeof a.country === 'object' && a.country !== null) {
type CountryRef = { code?: string; name?: string }
const c = a.country as CountryRef
countryObj = { code: c.code || '', name: c.name || '' }
}
const out: AddressOut = {
street: a.street || '',
cap: a.zip || '',
city: a.city || '',
country: countryObj,
canton: a.canton || ''
}
emit('update:modelValue', out)
emit('save', out)
isOpen.value = false
}
function onCancel() {
emit('cancel')
isOpen.value = false
}
</script>
<style scoped>
.q-card { width: 100%; margin: 0; }
</style>

View File

@@ -0,0 +1,323 @@
<template>
<q-dialog v-model="visible" persistent>
<q-card class="contained-card">
<q-card-section>
<div class="text-h6">{{ title }}</div>
<q-form ref="formRef" class="q-gutter-md q-mt-md">
<q-input v-model="draft.street" :label="t('address.street')" :rules="[required()]" />
<div v-if="!draft.foreign">
<div class="row items-center q-gutter-sm">
<div class="col-4">
<q-select
hide-selected
fill-input
v-model="selectedSwissZip"
:options="swissOptions"
option-label="label"
option-value="zip"
:label="t('address.zip')"
:input-attrs="{ inputmode: 'numeric', maxlength: 4 }"
@input-value="onSwissZipInputValue"
use-input
input-debounce="200"
emit-value
map-options
:rules="[required()]"
@filter="filterCapFn"
@blur="onZipBlur"
/>
</div>
<div class="col">
<q-input v-model="draft.city" :label="t('address.city')" :rules="[required()]" />
</div>
</div>
</div>
<div class="row items-center q-gutter-sm" v-else>
<q-input class="col-4" v-model="draft.zip" :label="t('address.zip')" :rules="[required(), zipRule]" :input-attrs="{ inputmode: 'numeric', maxlength: 4 }" @input-value="onZipInputValue" />
<q-input class="col" v-model="draft.city" :label="t('address.city')" :rules="[required()]" />
</div>
<q-select
v-if="draft.foreign"
v-model="draft.country"
:options="countryOptions"
option-label="label"
option-value="value"
:label="t('address.country')"
use-input
input-debounce="200"
emit-value
map-options
:rules="[required()]"
/>
<q-toggle v-if="allowForeign" v-model="draft.foreign" :label="t('address.foreign')" />
</q-form>
</q-card-section>
<q-card-actions align="right">
<q-btn flat :label="t('button.cancel')" @click="onCancel" />
<q-btn color="primary" :label="t('button.save')" @click="onSave" />
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch, nextTick, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Address } from '../types/address'
function filterCapFn (val: string, update: (fn: () => void) => void) {
// allow only digits and max 4 characters when filtering
update(() => {
const q = String(val || '').replace(/\D/g, '').slice(0, 4)
const hits = findPostalCodes(q)
swissOptionsRef.value = hits.map(z => ({ zip: z, label: z }))
})
}
import { toRefs } from 'vue'
const props = withDefaults(defineProps<{
modelValue: boolean
modelAddress?: Partial<Address> | null
title?: string
allowForeign?: boolean
}>(), { allowForeign: true })
const { allowForeign } = toRefs(props)
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
(e: 'save', payload: Address): void
(e: 'cancel'): void
}>()
const { t, locale } = useI18n()
const visible = ref(!!props.modelValue)
watch(() => props.modelValue, v => (visible.value = !!v))
watch(visible, v => {
emit('update:modelValue', v)
if (v) populateFromModel()
})
function populateFromModel() {
const src = props.modelAddress || {}
Object.assign(draft, { ...defaultAddress(), ...src })
if (hasCountryCode(src.country)) {
draft.country = src.country.code || ''
}
// if foreign addresses are not allowed, ensure foreign is false
if (!allowForeign.value) draft.foreign = false
// sanitize zip and limit to 4 digits
if (draft.zip) {
const s = String(draft.zip || '').replace(/\D/g, '').slice(0, 4)
draft.zip = s
if (!draft.foreign && s.length) {
if (ALL_CH_POSTAL_CODES.includes(s)) {
selectedSwissZip.value = s
}
if (s.length >= 4) {
const recs = findPostalCodeDetails(s)
if (recs && recs.length) {
const rec = recs[0]
draft.city = rec?.placeName || ''
draft.zip = rec?.postalCode || ''
draft.canton = rec?.cantonCode || null
selectedSwissZip.value = rec?.postalCode || ''
}
}
}
}
}
const defaultAddress = (): Address => ({ street: '', zip: '', city: '', country: '', canton: '', foreign: false })
// keep draft.country as string when editing; on save we'll convert to {code,name}
const draft = reactive<Address>({ ...defaultAddress(), ...(props.modelAddress || {}) })
function hasCountryCode(x: unknown): x is { code: string } {
return typeof x === 'object' && x !== null && Object.prototype.hasOwnProperty.call(x, 'code')
}
if (props.modelAddress && hasCountryCode(props.modelAddress.country)) {
draft.country = props.modelAddress.country.code || ''
}
const formRef = ref<{ validate?: () => Promise<boolean> | boolean; resetValidation?: () => void } | null>(null)
function required() {
const fallback = t('validation.required') || 'Required'
return (v: unknown) => {
if (v === null || v === undefined) return fallback
if (typeof v === 'string') return (v.trim() !== '') || fallback
return true
}
}
const zipRule = (v: unknown) => {
if (v === null || v === undefined || v === '') return t('validation.required') || 'Required'
if (typeof v !== 'string' && typeof v !== 'number') return t('validation.invalidZip') || 'Invalid ZIP'
const s = String(v).trim()
if (/^\d{1,4}$/.test(s)) return true
return t('validation.invalidZip') || 'Invalid ZIP'
}
watch(() => props.modelAddress, (n) => {
const src = n as Partial<Address> | undefined
Object.assign(draft, { ...defaultAddress(), ...(src || {}) })
if (hasCountryCode(src?.country)) {
draft.country = src?.country?.code || ''
}
if (src && Object.prototype.hasOwnProperty.call(src, 'canton')) {
draft.canton = src.canton || ''
}
void nextTick(() => formRef.value?.resetValidation?.())
// ensure selections are populated from the new model
populateFromModel()
}, { deep: true })
// if allowForeign changes to false, force draft.foreign = false
watch(allowForeign, (v) => {
if (!v) draft.foreign = false
})
async function onSave() {
try {
const ok = await (formRef.value?.validate?.() ?? true)
if (ok === false) return
} catch {
return
}
// build saved address: include localized country name + code when foreign
const out: Address = { ...draft }
if (draft.foreign) {
const code = typeof draft.country === 'string' ? draft.country : ''
const label = countryOptions.value.find(o => o.value === code)?.label || code
out.country = code ? { code, name: label } : ''
out.canton = ''
} else {
// non-foreign: explicitly set Switzerland as country ref and persist canton
try {
const dn = new Intl.DisplayNames([String(locale.value)], { type: 'region' })
out.country = { code: 'CH', name: dn.of('CH') || 'Switzerland' }
} catch {
out.country = { code: 'CH', name: 'Switzerland' }
}
out.canton = draft.canton || ''
}
emit('save', out)
visible.value = false
}
function onCancel() {
emit('cancel')
visible.value = false
}
import { COUNTRY_CODES, findPostalCodes, findPostalCodeDetails, ALL_CH_POSTAL_CODES } from '../data/countryCodes'
const countryOptions = computed(() => {
try {
// Intl.DisplayNames with type 'region' localizes country names
const dn = new Intl.DisplayNames([String(locale.value)], { type: 'region' })
return COUNTRY_CODES.map(code => ({ value: code, label: dn.of(code) || code }))
} catch {
return COUNTRY_CODES.map(code => ({ value: code, label: code }))
}
})
// SWISS_CITIES now imported from src/data/countryCodes
const swissOptions = computed(() => swissOptionsRef.value)
// Options for the ZIP select are only the list of postal codes (no city labels)
const swissOptionsRef = ref(ALL_CH_POSTAL_CODES.map(z => ({ zip: z, label: z })))
const selectedSwissZip = ref<string | null>(null)
function onZipInputValue (val: string) {
// sanitize input to digits only and max length 4
const s = String(val || '').replace(/\D/g, '').slice(0, 4)
if (draft.zip !== s) draft.zip = s
}
function onSwissZipInputValue (val: string) {
// sanitize incoming input from the q-select text field
const s = String(val || '').replace(/\D/g, '').slice(0, 4)
// update options shown
swissOptionsRef.value = findPostalCodes(s).map(z => ({ zip: z, label: z }))
// keep draft.zip in sync so watcher can fill city when 4 digits reached
if (!draft.foreign) {
if (draft.zip !== s) draft.zip = s
if (s.length >= 4) {
const recs = findPostalCodeDetails(s)
if (recs && recs.length) {
const rec = recs[0]
draft.zip = rec?.postalCode || ""
draft.city = rec?.placeName || ""
draft.canton = rec?.cantonCode || ''
selectedSwissZip.value = rec?.postalCode || ""
}
}
}
}
// Keep draft.zip in sync with selection; populate city on blur
watch(selectedSwissZip, (z) => {
if (!z) return
const rec = findPostalCodeDetails(z)[0]
if (rec) {
draft.zip = rec.postalCode
// populate city and canton only on blur to avoid aggressive overriding
draft.canton = rec.cantonCode || ''
}
})
function onZipBlur() {
const z = selectedSwissZip.value
if (!z) return
const recs = findPostalCodeDetails(z)
if (recs && recs.length) {
const rec = recs[0]
draft.zip = rec?.postalCode || ""
draft.city = rec?.placeName || ""
draft.canton = rec?.cantonCode || ''
}
}
// initialize selectedSwissZip from draft.zip when appropriate
// initialize selectedSwissZip from draft.zip when appropriate
watch(() => draft.zip, (z) => {
if (!draft.foreign && z) {
// sanitize to digits and max 4
const s = String(z || '').replace(/\D/g, '').slice(0, 4)
if (s !== z) {
draft.zip = s
return
}
if (ALL_CH_POSTAL_CODES.includes(s)) selectedSwissZip.value = s
// when the user types a full 4-digit ZIP, auto-fill city from dataset
if (s.length >= 4) {
const recs = findPostalCodeDetails(s)
if (recs && recs.length) {
const rec = recs[0]
draft.city = rec?.placeName || ''
draft.zip = rec?.postalCode || s
selectedSwissZip.value = rec?.postalCode || s
}
}
}
})
const title = props.title || t('address.modalTitle') || t('address.title') || 'Address'
// If the modal is already open or modelAddress already provided at mount,
// ensure the form is populated once.
void nextTick(() => {
if (props.modelValue || props.modelAddress) populateFromModel()
})
</script>
<style scoped>
.contained-card { min-width: 420px; max-width: 720px }
</style>

View File

@@ -0,0 +1,391 @@
<template>
<div class="comment-attachment column full-width shadow-1 q-pa-sm bg-white" :data-id="id">
<div>{{ title || label }}</div>
<div class="row col q-px-md">
<q-input
class="full-width"
type="textarea"
v-model="comments"
@blur="onCommentsBlur"
:label="t('commenti')"
autogrow
dense
/>
</div>
<div class="row q-ml-md full-width items-center no-wrap">
<div class="col q-ml-md">
<q-file
ref="fileRef"
:model-value="files"
@update:model-value="updateFiles"
accept=".pdf,.docx,.txt,.md"
dense
borderless
hide-bottom-space
:clearable="false"
input-style="display: none"
style="max-width: 400px"
>
<template v-slot:prepend>
<div class="row items-center no-wrap full-width">
<template v-if="!files">
<q-btn
round
dense
flat
color="primary"
icon="attach_file"
:disable="isUploading"
@click.stop="fileRef?.pickFiles()"
>
<q-tooltip>{{ t('pickFiles') }}</q-tooltip>
</q-btn>
</template>
<template v-else>
<q-btn
round
dense
flat
color="primary"
icon="cloud_upload"
:disable="!canUpload || isUploading"
:loading="isUploading"
@click.stop="upload"
/>
<q-btn
v-if="!isUploading"
round
dense
flat
color="primary"
icon="delete"
:disable="!files"
@click.stop.prevent="cancelSelection"
/>
</template>
<div class="comment-attachment__file-name q-px-sm" v-if="fileLabel">
{{ fileLabel }}
</div>
</div>
</template>
</q-file>
</div>
</div>
<div class="row items-start q-gutter-sm q-ml-md q-mt-xs q-mb-none full-width">
<div class="row col q-pa-none q-ma-none q-ml-md">
<div class="text-caption q-mb-sm q-mt-none ">
{{ t('attachments')}}
</div>
<div class="column items-center q-gutter-sm full-width">
<q-chip class="full-width" v-for="(f, i) in local.attachments" :key="i" removable @remove="removeFile(i)">{{ f }}</q-chip>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useQuasar, type QFile } from 'quasar'
import { useUserstore } from '../stores/userstore'
export interface CommentAttachmentData {
comments: string
attachments: string[]
}
type Payload = CommentAttachmentData
const props = defineProps<{
modelValue?: Partial<Payload> | null
label?: string
title?: string
id?: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: Payload | null): void
}>()
const { t } = useI18n()
const $q = useQuasar()
const userStore = useUserstore()
const fileRef = ref<QFile | null>(null)
const initialAttachments = props.modelValue?.attachments
const local = reactive<Payload>({
comments: props.modelValue?.comments || '',
attachments: Array.isArray(initialAttachments) ? [...initialAttachments] : [],
})
const comments = ref(local.comments)
type UploadProgressItem = {
percent: number
color: string
icon: string
xhr?: XMLHttpRequest
}
const files = ref<File | null>(null)
const isUploading = ref(false)
const uploadProgress = ref<UploadProgressItem[]>([])
const allowedExtensions = new Set(['pdf', 'docx', 'txt', 'md'])
function isAllowedFile(file: File): boolean {
const name = (file.name || '').trim().toLowerCase()
const ext = name.includes('.') ? name.split('.').pop() : ''
return !!ext && allowedExtensions.has(ext)
}
const canUpload = computed(() => !!files.value)
const fileLabel = computed(() => {
const current = files.value
return current?.name || ''
})
// const emptyProgress: UploadProgressItem = { percent: 0, color: 'primary', icon: 'attach_file' }
function ensureProgress(index: number): UploadProgressItem {
while (uploadProgress.value.length <= index) {
uploadProgress.value.push({ percent: 0, color: 'primary', icon: 'attach_file' })
}
const item = uploadProgress.value[index]
if (!item) {
const fallback = { percent: 0, color: 'primary', icon: 'attach_file' } satisfies UploadProgressItem
uploadProgress.value[index] = fallback
return fallback
}
return item
}
function emitUpdated() {
emit('update:modelValue', {
comments: local.comments || '',
attachments: [...local.attachments],
})
}
function updateFiles(val: File | null) {
if (val && !isAllowedFile(val)) {
files.value = null
isUploading.value = false
uploadProgress.value = []
return
}
files.value = val
isUploading.value = false
uploadProgress.value = val
? [{ percent: 0, color: 'primary', icon: 'attach_file' }]
: []
}
function cancelFile(index: number) {
const currentProgress = uploadProgress.value
const progress = currentProgress[index]
if (progress?.xhr && progress.percent < 1) {
try {
progress.xhr.abort()
} catch {
// ignore
}
}
// single file selection
files.value = null
const nextProgress = [...currentProgress]
nextProgress.splice(index, 1)
uploadProgress.value = nextProgress
}
function cancelSelection() {
cancelFile(0)
}
async function upload() {
const file = files.value
if (!file) return
if (!isAllowedFile(file)) return
isUploading.value = true
const uploadUrl = 'http://localhost:8082/upload'
await new Promise<void>((resolve) => {
const xhr = new XMLHttpRequest()
const p = ensureProgress(0)
p.xhr = xhr
xhr.upload.onprogress = (e) => {
if (!e.lengthComputable) return
const prog = e.total > 0 ? e.loaded / e.total : 0
ensureProgress(0).percent = Math.max(0, Math.min(1, prog))
}
xhr.onload = () => {
const ok = xhr.status >= 200 && xhr.status < 300
const p = ensureProgress(0)
p.percent = ok ? 1 : p.percent
p.color = ok ? 'positive' : 'negative'
p.icon = ok ? 'check' : 'error'
if (ok) {
try {
const parsed = JSON.parse(xhr.responseText) as {
files?: Array<{ storedName?: string; originalName?: string; name?: string }>
}
const first = Array.isArray(parsed.files) ? parsed.files[0] : undefined
const stored = first?.storedName || first?.originalName || first?.name
local.attachments.push(stored || file.name)
} catch {
local.attachments.push(file.name)
}
emitUpdated()
}
resolve()
}
xhr.onerror = () => {
const p = ensureProgress(0)
p.color = 'negative'
p.icon = 'error'
resolve()
}
xhr.onabort = () => {
const p = ensureProgress(0)
p.color = 'warning'
p.icon = 'close'
resolve()
}
const fd = new FormData()
fd.append('session', props.id ?? '')
fd.append('user', userStore.id)
fd.append('documents', file)
xhr.open('POST', uploadUrl)
xhr.send(fd)
})
// Clear selection after upload; keep uploaded names in local.attachments
files.value = null
uploadProgress.value = []
isUploading.value = false
}
watch(
() => props.modelValue,
(v) => {
if (v && typeof v.comments === 'string') {
local.comments = v.comments
} else if (v === null) {
local.comments = ''
}
// modelValue is Partial<Payload>: don't wipe local attachments if the parent omits the field
if (v && Array.isArray(v.attachments)) {
local.attachments = [...v.attachments]
} else if (v === null) {
local.attachments = []
}
comments.value = local.comments
}
)
function onCommentsBlur() {
local.comments = comments.value || ''
emitUpdated()
}
async function confirmDeleteAttachment(filename: string): Promise<boolean> {
return new Promise((resolve) => {
$q.dialog({
message: "<h6 class='q-my-sm' >" + t('confirmDeleteAttachment') + " </h6> " + filename,
cancel: true,
persistent: true,
html: true
})
.onOk(() => resolve(true))
.onCancel(() => resolve(false))
.onDismiss(() => resolve(false))
})
}
async function removeFile(index: number) {
const filename = local.attachments[index]
if (!filename) return
const confirmed = await confirmDeleteAttachment(filename)
if (!confirmed) return
const id = userStore.id
const session = props.id
// If we don't have enough context to delete on the server, just remove locally.
if (!id || !session) {
local.attachments.splice(index, 1)
emitUpdated()
return
}
try {
const res = await fetch('http://localhost:8082/deleteattachment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, session, filename }),
})
if (!res.ok) return
} catch {
return
}
local.attachments.splice(index, 1)
emitUpdated()
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((x) => typeof x === 'string')
}
onMounted(async () => {
// session comes from component prop id; user id comes from userstore
const session = props.id
const id = userStore.id
if (!session || !id) return
try {
const res = await fetch('http://localhost:8082/loadattachments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, session }),
})
if (!res.ok) return
const files: unknown = await res.json()
if (isStringArray(files)) {
local.attachments = files
emitUpdated()
}
} catch {
// ignore network errors
}
})
</script>
<style scoped>
.comment-attachment { font-size: 14px; }
.comment-attachment__file-name { font-size: 14px !important; width: 100%; background-color: rgba(0,0,0,0.1)}
.text-grey { color: rgba(0,0,0,0.45); }
</style>

View File

@@ -0,0 +1,305 @@
<template>
<q-dialog v-model="visible" persistent>
<q-card class="contained-card">
<q-card-section>
<div class="text-h6">{{ title }}</div>
<q-form ref="formRef" class="q-gutter-md q-mt-md">
<q-input v-model="draft.name" :label="t('children.school')" :rules="[required()]" />
<div v-if="!draft.foreign">
<div class="row items-center q-gutter-sm">
<div class="col-4">
<q-select
hide-selected
fill-input
v-model="selectedSwissCap"
:options="swissOptions"
option-label="label"
option-value="cap"
:label="t('address.zip')"
:input-attrs="{ inputmode: 'numeric', maxlength: 4 }"
@input-value="onSwissCapInputValue"
use-input
input-debounce="200"
emit-value
map-options
:rules="[required()]"
@filter="filterCapFn"
@blur="onCapBlur"
/>
</div>
<div class="col">
<q-input v-model="draft.city" :label="t('address.city')" :rules="[required()]" />
</div>
</div>
</div>
<div v-else>
<div class="row items-center q-gutter-sm">
<q-input
class="col-4"
v-model="draft.cap"
:label="t('address.zip')"
:rules="[required(), capRule]"
:input-attrs="{ inputmode: 'numeric', maxlength: 10 }"
@input-value="onCapInputValue"
/>
<q-input class="col" v-model="draft.city" :label="t('address.city')" :rules="[required()]" />
</div>
<q-select
v-model="draft.country"
:options="countryOptions"
option-label="label"
option-value="value"
:label="t('address.country')"
use-input
input-debounce="200"
emit-value
map-options
:rules="[required()]"
/>
</div>
<q-toggle v-if="allowForeign" v-model="draft.foreign" :label="t('address.foreign')" />
</q-form>
</q-card-section>
<q-card-actions align="right">
<q-btn flat :label="t('button.cancel')" @click="onCancel" />
<q-btn color="primary" :label="t('button.save')" @click="onSave" />
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import { computed, nextTick, reactive, ref, watch, toRefs } from 'vue'
import { useI18n } from 'vue-i18n'
import { COUNTRY_CODES, findPostalCodeDetails, findPostalCodes, ALL_CH_POSTAL_CODES } from '../data/countryCodes'
export interface SchoolOut {
name: string
cap: string
city: string
country: { code: string; name: string } | ''
}
type Draft = {
name: string
cap: string
city: string
country: string
foreign: boolean
}
const props = withDefaults(
defineProps<{
modelValue: boolean
modelSchool?: Partial<SchoolOut> | null
title?: string
allowForeign?: boolean
}>(),
{ allowForeign: true }
)
const { allowForeign } = toRefs(props)
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
(e: 'save', payload: SchoolOut): void
(e: 'cancel'): void
}>()
const { t, locale } = useI18n()
const visible = ref(!!props.modelValue)
watch(() => props.modelValue, v => (visible.value = !!v))
watch(visible, v => {
emit('update:modelValue', v)
if (v) populateFromModel()
})
const defaultDraft = (): Draft => ({ name: '', cap: '', city: '', country: '', foreign: false })
const draft = reactive<Draft>({ ...defaultDraft() })
const formRef = ref<{ validate?: () => Promise<boolean> | boolean; resetValidation?: () => void } | null>(null)
function required() {
const fallback = t('validation.required') || 'Required'
return (v: unknown) => {
if (v === null || v === undefined) return fallback
if (typeof v === 'string') return (v.trim() !== '') || fallback
return true
}
}
const capRule = (v: unknown) => {
if (v === null || v === undefined || v === '') return t('validation.required') || 'Required'
if (typeof v === 'number') return true
if (typeof v === 'string') {
const s = v.trim()
if (s.length === 0) return t('validation.required') || 'Required'
return true
}
return t('validation.invalidZip') || 'Invalid ZIP'
}
const countryOptions = computed(() => {
try {
const dn = new Intl.DisplayNames([String(locale.value)], { type: 'region' })
return COUNTRY_CODES.map(code => ({ value: code, label: dn.of(code) || code }))
} catch {
return COUNTRY_CODES.map(code => ({ value: code, label: code }))
}
})
const swissOptionsRef = ref(ALL_CH_POSTAL_CODES.map(z => ({ cap: z, label: z })))
const swissOptions = computed(() => swissOptionsRef.value)
const selectedSwissCap = ref<string | null>(null)
function filterCapFn(val: string, update: (fn: () => void) => void) {
update(() => {
const q = String(val || '').replace(/\D/g, '').slice(0, 4)
swissOptionsRef.value = findPostalCodes(q).map(z => ({ cap: z, label: z }))
})
}
function onCapInputValue(val: string) {
const s = String(val || '').trim()
if (draft.cap !== s) draft.cap = s
}
function onSwissCapInputValue(val: string) {
const s = String(val || '').replace(/\D/g, '').slice(0, 4)
swissOptionsRef.value = findPostalCodes(s).map(z => ({ cap: z, label: z }))
if (!draft.foreign) {
if (draft.cap !== s) draft.cap = s
if (s.length >= 4) {
const recs = findPostalCodeDetails(s)
if (recs && recs.length) {
const rec = recs[0]
draft.cap = rec?.postalCode || s
draft.city = rec?.placeName || ''
selectedSwissCap.value = rec?.postalCode || s
}
}
}
}
watch(selectedSwissCap, (cap) => {
if (!cap || draft.foreign) return
const rec = findPostalCodeDetails(cap)[0]
if (rec) {
draft.cap = rec.postalCode
}
})
function onCapBlur() {
const cap = selectedSwissCap.value
if (!cap || draft.foreign) return
const recs = findPostalCodeDetails(cap)
if (recs && recs.length) {
const rec = recs[0]
draft.city = rec?.placeName || draft.city
draft.cap = rec?.postalCode || draft.cap
}
}
watch(allowForeign, (v) => {
if (!v) draft.foreign = false
})
function populateFromModel() {
const src = props.modelSchool || {}
const capRaw = (src as Record<string, unknown>).cap
const capStr = typeof capRaw === 'string' ? capRaw : (typeof capRaw === 'number' ? String(capRaw) : '')
const countryCode =
typeof src.country === 'object' && src.country !== null
? (src.country.code || '')
: (typeof src.country === 'string' ? src.country : '')
Object.assign(draft, {
...defaultDraft(),
name: typeof src.name === 'string' ? src.name : '',
cap: capStr,
city: typeof src.city === 'string' ? src.city : '',
country: countryCode,
foreign: !!countryCode && countryCode !== 'CH'
})
if (!allowForeign.value) draft.foreign = false
// init swiss select if CH and cap present
if (!draft.foreign && draft.cap) {
const s = String(draft.cap || '').replace(/\D/g, '').slice(0, 4)
draft.cap = s
if (ALL_CH_POSTAL_CODES.includes(s)) selectedSwissCap.value = s
if (s.length >= 4) {
const recs = findPostalCodeDetails(s)
if (recs && recs.length) {
const rec = recs[0]
draft.city = rec?.placeName || draft.city
draft.cap = rec?.postalCode || draft.cap
selectedSwissCap.value = rec?.postalCode || s
}
}
}
void nextTick(() => formRef.value?.resetValidation?.())
}
async function onSave() {
try {
const ok = await (formRef.value?.validate?.() ?? true)
if (ok === false) return
} catch {
return
}
let countryOut: { code: string; name: string } | '' = ''
if (draft.foreign) {
const code = draft.country || ''
const label = countryOptions.value.find(o => o.value === code)?.label || code
countryOut = code ? { code, name: label } : ''
} else {
try {
const dn = new Intl.DisplayNames([String(locale.value)], { type: 'region' })
countryOut = { code: 'CH', name: dn.of('CH') || 'Switzerland' }
} catch {
countryOut = { code: 'CH', name: 'Switzerland' }
}
}
emit('save', {
name: draft.name || '',
cap: draft.cap || '',
city: draft.city || '',
country: countryOut
})
visible.value = false
}
function onCancel() {
emit('cancel')
visible.value = false
}
const title = computed(() => props.title || t('children.school') || 'School')
// if already open at mount, ensure draft is populated
void nextTick(() => {
if (props.modelValue || props.modelSchool) populateFromModel()
})
</script>
<style scoped>
.contained-card {
min-width: 420px;
max-width: 720px;
}
</style>

View File

@@ -0,0 +1,340 @@
<template>
<div class="comment-attachment column full-width shadow-1 q-pa-sm bg-white" :data-id="id">
<div>{{ title || label }}</div>
<div class="row q-ml-md full-width items-center no-wrap">
<div class="col q-ml-md">
<q-file
ref="fileRef"
:model-value="files"
@update:model-value="updateFiles"
:disable="!!disable"
accept=".pdf,.docx,.txt,.md"
dense
borderless
hide-bottom-space
:clearable="false"
input-style="display: none"
style="max-width: 400px"
>
<template v-slot:prepend>
<div class="row items-center no-wrap full-width">
<template v-if="!files">
<q-btn
round
dense
flat
color="primary"
icon="attach_file"
:disable="!!disable || isUploading"
@click.stop.prevent="fileRef?.pickFiles()"
>
<q-tooltip>{{ t('pickFiles') }}</q-tooltip>
</q-btn>
</template>
<template v-else>
<q-btn
round
dense
flat
color="primary"
icon="cloud_upload"
:disable="!!disable || !canUpload || isUploading"
:loading="isUploading"
@click.stop.prevent="upload"
/>
</template>
<q-chip
v-if="chipLabel"
dense
:removable="!disable"
class="q-ml-sm comment-attachment__file-chip"
:color="isUploaded ? 'positive' : undefined"
:text-color="isUploaded ? 'white' : undefined"
@remove="onChipRemove"
>
<span class="ellipsis">{{ chipLabel }}</span>
</q-chip>
</div>
</template>
</q-file>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useQuasar, type QFile } from 'quasar'
import { useUserstore } from '../stores/userstore'
import { ApiError, deleteAttachment, loadAttachments, uploadDocument } from '../utils/api'
export interface CommentAttachmentData {
comments: string
attachments: string[]
}
type Payload = CommentAttachmentData
const props = defineProps<{
modelValue?: Partial<Payload> | null
label?: string
title?: string
session?: string
id?: string
autoload?: boolean
disable?: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: Payload | null): void
}>()
const { t } = useI18n()
const $q = useQuasar()
const userStore = useUserstore()
const fileRef = ref<QFile | null>(null)
const initialAttachments = props.modelValue?.attachments
const local = reactive<Payload>({
comments: props.modelValue?.comments || '',
attachments: Array.isArray(initialAttachments) ? [...initialAttachments] : [],
})
const comments = ref(local.comments)
type UploadProgressItem = {
percent: number
color: string
icon: string
}
const files = ref<File | null>(null)
const isUploading = ref(false)
const uploadProgress = ref<UploadProgressItem[]>([])
const allowedExtensions = new Set(['pdf', 'docx', 'txt', 'md'])
const allowedLabel = '.pdf, .docx, .txt, .md'
function isAllowedFile(file: File): boolean {
const name = (file.name || '').trim().toLowerCase()
const ext = name.includes('.') ? name.split('.').pop() : ''
return !!ext && allowedExtensions.has(ext)
}
const canUpload = computed(() => !!files.value)
const chipLabel = computed(() => {
const current = files.value
if (current?.name) return current.name
const lastUploaded = local.attachments.length > 0 ? local.attachments[local.attachments.length - 1] : ''
return lastUploaded || ''
})
const isUploaded = computed(() => !files.value && local.attachments.length > 0)
// const emptyProgress: UploadProgressItem = { percent: 0, color: 'primary', icon: 'attach_file' }
function ensureProgress(index: number): UploadProgressItem {
while (uploadProgress.value.length <= index) {
uploadProgress.value.push({ percent: 0, color: 'primary', icon: 'attach_file' })
}
const item = uploadProgress.value[index]
if (!item) {
const fallback = { percent: 0, color: 'primary', icon: 'attach_file' } satisfies UploadProgressItem
uploadProgress.value[index] = fallback
return fallback
}
return item
}
function emitUpdated() {
emit('update:modelValue', {
comments: local.comments || '',
attachments: [...local.attachments],
})
}
function updateFiles(val: File | null) {
if (val && !isAllowedFile(val)) {
$q.notify({ type: 'negative', message: t('fileTypeNotAllowed', { allowed: allowedLabel }) })
files.value = null
isUploading.value = false
uploadProgress.value = []
return
}
files.value = val
isUploading.value = false
uploadProgress.value = val
? [{ percent: 0, color: 'primary', icon: 'attach_file' }]
: []
}
function cancelFile(index: number) {
const currentProgress = uploadProgress.value
// single file selection
files.value = null
const nextProgress = [...currentProgress]
nextProgress.splice(index, 1)
uploadProgress.value = nextProgress
}
function cancelSelection() {
cancelFile(0)
}
async function deleteLastUploadedAttachment() {
const filename = local.attachments.length > 0 ? local.attachments[local.attachments.length - 1] : ''
if (!filename) return
const session = props.session
const prop = props.id
const id = userStore.id
if (!session || !id) {
local.attachments = []
emitUpdated()
return
}
try {
await deleteAttachment({
id,
session,
...(prop ? { prop } : {}),
filename,
})
local.attachments = []
emitUpdated()
$q.notify({ type: 'positive', message: t('fileDeleted') })
} catch (e) {
$q.notify({ type: 'negative', message: e instanceof Error ? e.message : t('deleteFailed') })
}
}
function onChipRemove() {
if (props.disable) return
if (files.value) {
cancelSelection()
return
}
void deleteLastUploadedAttachment()
}
async function upload() {
if (props.disable) return
const file = files.value
if (!file) return
if (!isAllowedFile(file)) {
$q.notify({ type: 'negative', message: t('fileTypeNotAllowed', { allowed: allowedLabel }) })
return
}
const session = props.session
const prop = props.id
const user = userStore.id
if (!session || !user) {
$q.notify({ type: 'warning', message: t('missingUserOrSession') })
return
}
isUploading.value = true
try {
const res = await uploadDocument({
user,
session,
...(prop ? { prop } : {}),
file,
onProgress: (fraction) => {
ensureProgress(0).percent = fraction
},
})
const p = ensureProgress(0)
p.percent = 1
p.color = 'positive'
p.icon = 'check'
const uploadedName = res.files?.[0] ?? file.name
local.attachments.push(uploadedName)
emitUpdated()
$q.notify({ type: 'positive', message: t('fileUploaded') })
} catch (e) {
const p = ensureProgress(0)
p.color = e instanceof ApiError && e.message.includes('cancel') ? 'warning' : 'negative'
p.icon = e instanceof ApiError && e.message.includes('cancel') ? 'close' : 'error'
const isCancel = e instanceof ApiError && e.message.includes('cancel')
$q.notify({
type: p.color === 'warning' ? 'warning' : 'negative',
message: isCancel ? t('uploadCancelled') : (e instanceof Error ? e.message : t('uploadFailed')),
})
}
// Clear selection after upload; keep uploaded names in local.attachments
files.value = null
uploadProgress.value = []
isUploading.value = false
}
watch(
() => props.modelValue,
(v) => {
if (v && typeof v.comments === 'string') {
local.comments = v.comments
} else if (v === null) {
local.comments = ''
}
// modelValue is Partial<Payload>: don't wipe local attachments if the parent omits the field
if (v && Array.isArray(v.attachments)) {
local.attachments = [...v.attachments]
} else if (v === null) {
local.attachments = []
}
comments.value = local.comments
},
{ deep: true }
)
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((x) => typeof x === 'string')
}
onMounted(async () => {
if (props.autoload === false) return
// session comes from component prop id; user id comes from userstore
const session = props.session
const id = userStore.id
if (!session || !id) return
try {
const files = await loadAttachments({
id,
session,
...(props.id ? { prop: props.id } : {}),
})
if (isStringArray(files)) {
local.attachments = [...files]
emitUpdated()
}
} catch {
// ignore network errors
}
})
</script>
<style scoped>
.comment-attachment { font-size: 14px; }
.comment-attachment__file-chip { max-width: 320px; }
.text-grey { color: rgba(0,0,0,0.45); }
</style>

View File

@@ -0,0 +1,123 @@
<template>
<div class="row full-height">
<div class="col-3 q-pa-sm bg-grey-1">
<q-list dense bordered class="vertical-nav">
<q-item v-for="(s, idx) in steps" :key="s.id" clickable @click="active = idx" :active="active === idx">
<q-item-section>
<div class="text-body1">{{ idx + 1 }}. {{ s.title }}</div>
</q-item-section>
</q-item>
</q-list>
</div>
<div class="col q-pa-md full-height">
<q-card flat class="q-pa-md full-height">
<q-card-section>
<div v-if="!currentComponent" class="text-h6">{{ steps[active]?.title }}</div>
<component
v-if="currentComponent && currentStep"
:is="currentComponent"
:step="currentStep"
@next="onChildNext"
@prev="onChildPrev"
/>
<div v-else class="q-mt-md"><!-- placeholder: no dynamic component for this step -->
</div>
</q-card-section>
</q-card>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, defineAsyncComponent } from 'vue'
import type { StepDescriptor } from '../types/types'
const rawSteps = [
{ id: 'welcome', title: 'Benvenuto', order: 0 },
{ id: 'taxpayer', title: 'Dati contribuente e dichiarazione precedente', order: 1 },
{ id: 'marital', title: 'Stato civile', order: 2 },
{ id: 'children', title: 'Figli', order: 3 },
{ id: 'income', title: 'Redditi', order: 4 },
{ id: 'professionalExpenses', title: 'Spese professionali', order: 5 },
{ id: 'sideIncome', title: 'Reddito accessorio', order: 6 },
{ id: 'annuities', title: 'Rendite', order: 7 },
{ id: 'insurance', title: 'Spese assicurative e mediche', order: 8 },
{ id: 'pillar3', title: 'Polizze 3A / 3B', order: 9 },
{ id: 'bankAccounts', title: 'Conti bancari', order: 10 },
{ id: 'otherAssets', title: 'Altri beni / averi', order: 11 },
{ id: 'debts', title: 'Debiti / ipoteche', order: 12 },
{ id: 'properties', title: 'Immobili', order: 13 },
{ id: 'foreign', title: 'Redditi o averi allestero', order: 14 }
] as { id: string; title: string; order: number }[]
const maxOrder = Math.max(...rawSteps.map(s => s.order))
const steps: StepDescriptor[] = new Array(maxOrder + 1).fill(undefined).map(() => ({} as StepDescriptor))
rawSteps.forEach(s => {
steps[s.order] = { id: s.id, title: s.title, order: s.order }
})
const active = ref(0)
const currentComponent = computed(() => {
const id = steps[active.value]?.id
if (id === 'welcome') return defineAsyncComponent(() => import('./steps/WelcomeStep.vue'))
if (id === 'taxpayer') return defineAsyncComponent(() => import('./steps/TaxpayerStep.vue'))
if (id === 'marital') return defineAsyncComponent(() => import('./steps/MaritalStep.vue'))
if (id === 'children') return defineAsyncComponent(() => import('./steps/ChildrenStep.vue'))
if (id === 'income') return defineAsyncComponent(() => import('./steps/IncomeStep.vue'))
return null
})
const currentStep = computed<StepDescriptor | undefined>(() => steps[active.value])
// navigation handled by child step events via onChildNext/onChildPrev
function onChildNext(payload: unknown) {
// payload may be a next step id or undefined
if (typeof payload === 'string') {
const idx = steps.findIndex(s => s.id === payload)
if (idx !== -1) {
active.value = idx
return
}
}
// default: move to next step index if available
if (active.value < steps.length - 1) active.value++
}
function onChildPrev(payload: unknown) {
// payload may be a prev step id or undefined
if (typeof payload === 'string') {
const idx = steps.findIndex(s => s.id === payload)
if (idx !== -1) {
active.value = idx
return
}
}
if (active.value > 0) active.value--
}
</script>
<style scoped>
.q-stepper { max-width: 900px; margin: 0 auto; }
.full-height { height: 100%; }
.vertical-nav .q-item {
border-radius: 4px;
margin-bottom: 4px;
max-width: 350px;
}
.vertical-nav .q-item--active {
background-color: var(--q-color-primary) !important;
}
.vertical-nav .q-item--active .text-body1,
.vertical-nav .q-item--active .q-item__label {
font-weight: 700 !important;
}
</style>

View File

@@ -0,0 +1,441 @@
<template>
<q-card flat class="full-width q-pa-none">
<q-card-section class="full-width">
<div class="row items-center">
<div class="col">
<div class="text-h6">{{ t('CHD') }}</div>
</div>
<div class="col-auto">
<q-btn flat :label="t('button.prev')" @click="emitPrev" class="q-mr-sm" />
<q-btn color="primary" :label="t('button.next')" @click="saveAndNext" />
</div>
</div>
<q-separator class="q-my-sm" />
<q-form ref="formRef" class="q-gutter-md q-mt-md">
<div class="row items-center">
<div class="col">
<q-toggle v-model="form.hasChildren" :label="t('children.hasChildren')" />
</div>
<div class="col-auto">
<q-btn v-if="form.hasChildren" :disable="form.children.length >= 5" color="primary" :label="`+ ${t('children.addChild')}`" @click="openAddModal" />
</div>
</div>
<div v-if="form.hasChildren" class="q-mt-md">
<div class="text-subtitle2 q-mb-sm">{{ t('children.listTitle') }}</div>
<q-list bordered>
<q-item v-for="(child, i) in form.children" :key="i" clickable>
<q-item-section>
<q-item-label><span class="text-weight-bold">{{ child.firstName || '-' }} {{ child.lastName || '' }} ({{ ageFromDate(child.birthDate) }})</span></q-item-label>
<q-item-label v-if="!modalDraft.sameHousehold">{{ formatAddressToString(child.address) }}</q-item-label>
</q-item-section>
<q-item-section side class="row items-center q-gutter-sm">
<q-btn dense flat round icon="edit" @click="openEditModal(i)" />
<q-btn dense flat round icon="delete" color="negative" @click="deleteChild(i)" />
</q-item-section>
</q-item>
</q-list>
</div>
<div v-if="hasTriggeredOverLimit || form.children.length >= 5" class="q-mt-md">
<q-input type="textarea" v-model="form.moreThanFiveChildrenNote" :label="t('children.moreThanFiveChildrenNote')" autogrow />
</div>
</q-form>
<!-- Modal for adding/editing a child -->
<q-dialog v-model="isDialogOpen" persistent>
<q-card class="contained-card child-modal">
<q-card-section>
<div class="text-h6">{{ editingIndex === null ? t('children.addChild') : t('children.editChild') }}</div>
<q-separator class="q-my-sm" />
<q-form ref="modalFormRef" class="q-gutter-md q-mt-md">
<q-input class="q-mt-none" dense v-model="modalDraft.firstName" :label="t('children.firstName')" :rules="modalFirstNameRules" @blur="onNameBlur('firstName')" />
<q-input class="q-mt-none" dense v-model="modalDraft.lastName" :label="t('children.lastName')" :rules="modalLastNameRules" @blur="onNameBlur('lastName')">
<template v-slot:prepend>
<q-btn
dense
flat
round
color="primary"
icon="family_restroom"
:disable="!taxpayerLastName"
@click.stop.prevent="copyLastNameFromTaxpayer"
>
<q-tooltip class="bg-primary text-white">{{ t('children.copyLastNameFromTaxpayer') }}</q-tooltip>
</q-btn>
</template>
</q-input>
<q-input class="q-mt-none" dense v-model="modalDraft.birthDate" type="date" :label="t('children.birthDate')" :rules="modalBirthDateRules" />
<div class="row items-center q-gutter-sm q-mt-none">
<div class="col">
<q-toggle class="q-mt-none" v-model="modalDraft.sameHousehold" :label="t('children.sameHousehold')" />
<div class="q-mt-none" v-if="!modalDraft.sameHousehold">
<q-toggle class="q-mt-none" v-model="modalDraft.alimentiVersati" :label="t('children.alimentiVersati')" />
</div>
</div>
</div>
<div v-if="!modalDraft.sameHousehold" class="q-ml-lg q-mt-none">
<div class="row items-center q-gutter-sm q-mb-xs">
<div class="col-auto">
<q-btn dense flat round icon="edit" @click="openAddressModal">
<q-tooltip class="bg-primary text-white">{{ t('children.editAddress') }}</q-tooltip>
</q-btn>
</div>
<div class="col">
<div class="text-caption">{{ t('children.addressLabel') }}</div>
</div>
</div>
<div class="q-pa-xs bg-grey-2 q-px-md">
<div v-if="!modalDraft.address" class="text-negative">{{ t('validation.insertAddress') }}</div>
<div v-else class="q-pa-xs">{{ formattedModalAddress }}</div>
</div>
</div>
<q-input class="q-mt-none" dense v-model="modalDraft.school" :label="t('children.school')" />
<div class="row">
<q-toggle class="q-mt-none" v-model="modalDraft.hasCareCost" :label="t('children.hasCareCost')" />
<CommentAttachment
class="q-mt-none"
v-if="modalDraft.hasCareCost"
v-model="modalDraft.careCosts"
:label="t('children.careCosts')"
:id="'children'"
/>
</div>
</q-form>
</q-card-section>
<AddressModal v-model="isAddressDialogOpen" :modelAddress="modalAddressObject" :allowForeign="false" @save="onAddressSave" @cancel="onAddressCancel" />
<q-card-actions align="right">
<q-btn flat :label="t('button.cancel')" @click="closeModal" />
<q-btn color="primary" :label="editingIndex === null ? t('button.add') : t('button.save')" @click="confirmModal" />
</q-card-actions>
</q-card>
</q-dialog>
</q-card-section>
</q-card>
</template>
<script setup lang="ts">
import { reactive, onMounted, ref, nextTick, watch, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import AddressModal from '../AddressModal.vue'
import type { Address } from '../../types/address'
import { useChildrenStore, type ChildItem, type ChildrenData } from '../../stores/children'
import { useTaxstore } from '../../stores/taxstore'
import type { StepDescriptor } from '../../types/types'
import CommentAttachment from '../CommentAttachment.vue'
const props = defineProps<{ step?: StepDescriptor }>()
const emit = defineEmits(['next', 'prev'])
const { t } = useI18n()
const store = useChildrenStore()
const taxStore = useTaxstore()
const taxpayerLastName = computed(() => {
const ln = taxStore.getTaxpayer()?.lastName
return typeof ln === 'string' ? ln.trim() : ''
})
// QForm ref
const formRef = ref<{ validate?: () => Promise<boolean> | boolean; resetValidation?: () => void } | null>(null)
const requiredMessage = (fallback = 'Required') => {
const msg = t('validation.required')
return msg && msg !== 'validation.required' ? msg : fallback
}
const required = (msg?: string) => (v: unknown) => {
const message = msg || requiredMessage()
if (v === null || v === undefined) return message
if (typeof v === 'string') return (v.trim() !== '') || message
if (Array.isArray(v)) return v.length > 0 || message
return true
}
const minLength = (n: number, msg?: string) => (v: unknown) => {
const message = msg || `${t('validation.minLength') || `Minimum ${n} chars`}`
if (v === null || v === undefined) return true
if (typeof v === 'string') return (v.trim().length >= n) || message
return true
}
const maxAgeFromJan1 = (years: number, msg?: string) => (v: unknown) => {
const i18nMsg = t('validation.maxAgeFromJan1')
const message = msg || (typeof i18nMsg === 'string' ? i18nMsg : `Age must be at most ${years} years from Jan 1 of this year`)
if (!v) return true
let d: Date
if (v instanceof Date) {
d = v
} else if (typeof v === 'string') {
d = new Date(v)
} else {
return message
}
if (isNaN(d.getTime())) return message
const now = new Date()
const cutoff = new Date(now.getFullYear() - years, 0, 1)
return d >= cutoff || message
}
const formatAddressToString = (v?: Address | null) => {
if (!v) return ''
const country = v.country
const countryCode = typeof country === 'object' && country !== null ? country.code : country
const countryName = typeof country === 'object' && country !== null ? country.name : ''
if (countryCode === 'CH') {
return [v.street, String(v.zip || ''), v.city, v.canton || ''].filter(Boolean).join(', ')
}
return [v.street, String(v.zip || ''), v.city, countryName].filter(Boolean).join(', ')
}
function onNameBlur(field: 'firstName' | 'lastName') {
const raw = (modalDraft[field] ?? '') as unknown
const s = typeof raw === 'string' ? raw.trim() : String(raw)
if (!s) {
modalDraft[field] = ''
return
}
modalDraft[field] = s.charAt(0).toUpperCase() + s.slice(1)
}
function copyLastNameFromTaxpayer() {
const ln = taxpayerLastName.value
if (!ln) return
modalDraft.lastName = ln
}
function ageFromDate(d?: string) {
if (!d) return ''
const dt = new Date(d)
if (isNaN(dt.getTime())) return ''
const now = new Date()
let age = now.getFullYear() - dt.getFullYear()
const m = now.getMonth() - dt.getMonth()
if (m < 0 || (m === 0 && now.getDate() < dt.getDate())) age--
if (age < 0) return ''
return `${age} anni`
}
const makeEmptyChild = (): ChildItem => ({
firstName: '',
lastName: '',
birthDate: '',
sameHousehold: true,
school: '',
hasCareCost: false,
careCosts: { comments: '', attachments: [] },
address: null,
alimentiVersati: false
})
const form = reactive<ChildrenData>({
hasChildren: false,
children: [],
moreThanFiveChildrenNote: ''
})
const hasTriggeredOverLimit = ref(false)
// childRequired intentionally removed; modal rules are used instead
// modal state
const isDialogOpen = ref(false)
const editingIndex = ref<number | null>(null)
const modalDraft = reactive<ChildItem>({ ...makeEmptyChild() })
const modalFormRef = ref<{ validate?: () => Promise<boolean> | boolean; resetValidation?: () => void } | null>(null)
const isAddressDialogOpen = ref(false)
const modalAddressObject = ref<Address | null>(null)
const formattedModalAddress = computed(() => {
const a = modalAddressObject.value
if (!a) return ''
return formatAddressForString(a)
})
// over-limit modal removed; note edited inline
const modalFirstNameRules = [required(), minLength(2)]
const modalLastNameRules = [required(), minLength(2)]
const modalBirthDateRules = [required(), maxAgeFromJan1(25)]
async function openAddModal() {
editingIndex.value = null
Object.assign(modalDraft, makeEmptyChild())
// clear any previously stored address object
modalDraft.address = null
isDialogOpen.value = true
await nextTick()
modalFormRef.value?.resetValidation?.()
}
async function openEditModal(index: number) {
const c = form.children[index]
if (!c) return
editingIndex.value = index
Object.assign(modalDraft, { ...makeEmptyChild(), ...c })
// restore stored address object if present on the child
modalDraft.address = c.address || null
modalAddressObject.value = modalDraft.address || null
isDialogOpen.value = true
await nextTick()
modalFormRef.value?.resetValidation?.()
}
function closeModal() {
isDialogOpen.value = false
}
function openAddressModal() {
// pass an Address prop to the AddressModal: prefer the stored object, otherwise
// construct a minimal Address from the saved address string so the modal can render it
if (modalDraft.address) {
modalAddressObject.value = modalDraft.address
} else {
modalAddressObject.value = null
}
isAddressDialogOpen.value = true
}
function onAddressCancel() {
isAddressDialogOpen.value = false
}
function formatAddressForString(a: Address) {
function hasCountryName(x: unknown): x is { name: string } {
return typeof x === 'object' && x !== null && Object.prototype.hasOwnProperty.call(x, 'name')
}
const countryLabel = a.country && typeof a.country === 'object' && hasCountryName(a.country) ? a.country.name : (a.country || '')
// use tab-separated fields: street<TAB>zip<TAB>city<TAB>country
return `${a.street || ''}\t${a.zip || ''}\t${a.city || ''}\t${countryLabel || ''}`
}
function onAddressSave(a: Address) {
modalAddressObject.value = a
// store object on the draft so it persists with the child entry
modalDraft.address = a
// do not persist the composed string; use formatted string only for rendering
isAddressDialogOpen.value = false
}
async function confirmModal() {
try {
const ok = await (modalFormRef.value?.validate?.() ?? true)
if (ok === false) return
} catch {
return
}
if (editingIndex.value === null) {
// If already at or above limit, mark over-limit and abort
if (form.children.length >= 5) {
isDialogOpen.value = false
hasTriggeredOverLimit.value = true
return
}
// push new child
form.children.push({ ...modalDraft })
// persist immediately
store.setChildren(buildPayload())
// if we've just reached the maximum, mark over-limit
if (form.children.length === 5) {
isDialogOpen.value = false
hasTriggeredOverLimit.value = true
return
}
} else {
form.children[editingIndex.value] = { ...modalDraft }
// persist immediately
store.setChildren(buildPayload())
}
isDialogOpen.value = false
}
function deleteChild(index: number) {
form.children.splice(index, 1)
store.setChildren(buildPayload())
}
// over-limit modal removed; note edited inline and persisted via watcher
onMounted(async () => {
const saved = store.getChildren()
if (saved) {
form.hasChildren = !!saved.hasChildren
if (Array.isArray(saved.children) && saved.children.length) {
// copy up to 5
for (let i = 0; i < 5; i++) {
if (saved.children[i]) {
form.children[i] = { ...form.children[i], ...(saved.children[i] as Partial<ChildItem>) } as ChildItem
}
}
}
form.moreThanFiveChildrenNote = saved.moreThanFiveChildrenNote || ''
if (saved.moreThanFiveChildrenNote || (Array.isArray(saved.children) && saved.children.length >= 5)) {
hasTriggeredOverLimit.value = true
}
}
await nextTick()
formRef.value?.resetValidation?.()
})
// persist when hasChildren toggles
watch(() => form.hasChildren, () => {
store.setChildren(buildPayload())
})
// persist note immediately when edited
watch(() => form.moreThanFiveChildrenNote, () => {
store.setChildren(buildPayload())
})
function buildPayload() {
const payload = {
hasChildren: form.hasChildren,
children: form.children.filter(c => c.firstName || c.lastName || c.birthDate),
moreThanFiveChildrenNote: form.moreThanFiveChildrenNote
}
// limit to 5 items
payload.children = payload.children.slice(0, 5)
return payload
}
async function saveAndNext() {
try {
const ok = await (formRef.value?.validate?.() ?? true)
if (ok === false) return
} catch {
return
}
const payload = buildPayload()
store.setChildren(payload)
emit('next', props.step?.next)
}
function emitPrev() {
const payload = buildPayload()
store.setChildren(payload)
emit('prev', props.step?.prev)
}
defineExpose({ buildPayload })
</script>
<style scoped>
.full-width { width: 100%; }
.contained-card { min-width: 480px; max-width: 720px; }
.contained-card .q-card-section { padding: 16px; }
.contained-card .q-card-actions { padding: 12px 16px; }
.child-modal { min-width: 480px; }
</style>

View File

@@ -0,0 +1,183 @@
<template>
<q-card flat class="full-width q-pa-none">
<q-card-section class="full-width">
<div class="row items-center">
<div class="col">
<div class="text-h5">{{ t('INC') }}</div>
</div>
<div class="col-auto">
<q-btn flat :label="t('button.prev')" @click="emitPrev" class="q-mr-sm" />
<q-btn color="primary" :label="t('button.next')" @click="emitNext" />
</div>
</div>
<q-separator class="q-my-sm" />
<q-form class="q-gutter-md q-mt-md">
<q-select
v-model="form.employType"
:options="employTypeOptions"
:label="t('income.employTypeLabel')"
:hint="!form.employType ? t('income.employTypeHint') : ''"
:persistent-hint="!form.employType"
emit-value
map-options
/>
<SimpleAttachment
v-if="isFieldActive('salaryCertificate')"
v-model="form.attachments.salaryCertificate"
:label="t('income.attachments.salaryCertificate')"
:session="'income'"
:id="'salaryCertificate'"
:autoload="false"
/>
<SimpleAttachment
v-if="isFieldActive('accountingDocuments')"
v-model="form.attachments.accountingDocuments"
:label="t('income.attachments.accountingDocuments')"
:session="'income'"
:id="'accountingDocuments'"
:autoload="false"
/>
<SimpleAttachment
v-if="isFieldActive('avsCertificate')"
v-model="form.attachments.avsCertificate"
:label="t('income.attachments.avsCertificate')"
:session="'income'"
:id="'avsCertificate'"
:autoload="false"
/>
<SimpleAttachment
v-if="isFieldActive('lppCertificate')"
v-model="form.attachments.lppCertificate"
:label="t('income.attachments.lppCertificate')"
:session="'income'"
:id="'lppCertificate'"
:autoload="false"
/>
<SimpleAttachment
v-if="isFieldActive('unemploymentCertificate')"
v-model="form.attachments.unemploymentCertificate"
:label="t('income.attachments.unemploymentCertificate')"
:session="'income'"
:id="'unemploymentCertificate'"
:autoload="false"
/>
</q-form>
</q-card-section>
</q-card>
</template>
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { IncomeData, StepDescriptor } from '../../types/types'
import SimpleAttachment from '../SimpleAttachment.vue'
import { useUserstore } from '../../stores/userstore'
import { loadAttachmentsList } from '../../utils/api'
import { useIncomeStore } from '../../stores/income'
const props = defineProps<{ step?: StepDescriptor }>()
const emit = defineEmits(['next', 'prev'])
const { t } = useI18n()
const userStore = useUserstore()
const incomeStore = useIncomeStore()
const employTypeData = ["EMPLOYED", "SELF_EMPLOYED", "PENSIONER", "UNEMPLOYED"] as const
const incomeInfo = {
EMPLOYED: {activeFields: ['salaryCertificate', 'accountingDocuments'], nextStep: 'marital'},
SELF_EMPLOYED: {activeFields: [ 'accountingDocuments'], nextStep: 'marital'},
PENSIONER: {activeFields: [ 'accountingDocuments', 'avsCertificate', 'lppCertificate' ], nextStep: 'marital'},
UNEMPLOYED: {activeFields: ['unemploymentCertificate'], nextStep: 'marital'}
} as const
const form = incomeStore.data as IncomeData
const attachmentKeys = [
'salaryCertificate',
'accountingDocuments',
'avsCertificate',
'lppCertificate',
'unemploymentCertificate',
] as const
type AttachmentKey = keyof IncomeData['attachments']
const activeFieldSet = computed<Set<AttachmentKey>>(() => {
const selected = form.employType
if (!selected) return new Set<AttachmentKey>()
const info = incomeInfo[selected as keyof typeof incomeInfo]
const keys = (info?.activeFields ?? []) as unknown as AttachmentKey[]
return new Set(keys)
})
function isFieldActive(key: AttachmentKey) {
return activeFieldSet.value.has(key)
}
function clearInactiveAttachmentFields() {
for (const key of attachmentKeys) {
if (!isFieldActive(key)) {
form.attachments[key].comments = ''
form.attachments[key].attachments = []
}
}
}
watch(
() => form.employType,
() => {
clearInactiveAttachmentFields()
}
)
watch(
() => form,
() => {
incomeStore.persist()
},
{ deep: true }
)
const employTypeOptions = computed(() =>
employTypeData.map(v => ({ label: t(`income.employType.${v}`), value: v }))
)
onMounted(async () => {
const id = userStore.id
if (!id) return
// session folder name on backend
const session = 'income'
try {
const list = await loadAttachmentsList({ id, session })
for (const key of attachmentKeys) {
const files = list[key]
if (Array.isArray(files)) {
form.attachments[key].attachments = [...files]
}
}
} catch {
// ignore network errors
}
})
function emitNext() {
emit('next', props.step?.next)
}
function emitPrev() {
emit('prev', props.step?.prev)
}
</script>
<style scoped>
.q-card {
width: 100%;
margin: 0;
}
</style>

View File

@@ -0,0 +1,275 @@
<template>
<q-card flat class="full-width q-pa-none">
<q-card-section class="full-width">
<div class="row items-center">
<div class="col">
<div class="text-h6">{{ t("MAR") }}</div>
</div>
<div class="col-auto">
<q-btn flat :label="t('button.prev')" @click="emitPrev" class="q-mr-sm" />
<q-btn color="primary" :label="t('button.next')" @click="saveAndNext" />
</div>
</div>
<q-separator class="q-my-sm" />
<q-form ref="formRef" class="form q-gutter-md q-mt-md">
<!-- explicit fields for marital step (no v-for) -->
<div class="row items-center q-gutter-md q-mb-sm q-ml-none">
<div class="col q-ml-none">
<q-toggle v-model="form.alimentiVersati" :label="t('children.alimentiVersati')" />
</div>
</div>
<div v-if="form.alimentiVersati" class="row">
<CommentAttachment v-model="form.alimentiCommenti" :label="t('informazionesualimenti')" :id="'marital'"/>
</div>
<!-- maritalStatus (enum) -->
<q-select
:model-value="form.maritalStatus"
:label="$t('marital.maritalStatus')"
@update:model-value="val => form.maritalStatus = val"
:options="getOptions()"
:rules="maritalStatusRules"
emit-value
map-options
/>
<!-- spouse fields (visible only when married/partnered) -->
<template v-if="maritalconfig[form.maritalStatus]?.showSpouseData">
<div class="row ">{{ $t(maritalconfig[form.maritalStatus]?.data) }}</div>
<q-input
:model-value="form.spouseFirstName"
@update:model-value="val => form.spouseFirstName = val"
:label="$t('marital.spouse.firstName')"
:rules="[required()]"
/>
<q-input
:model-value="form.spouseLastName"
@update:model-value="val => form.spouseLastName = val"
:label="$t('marital.spouse.lastName')"
:rules="[required()]"
/>
<q-input
:model-value="form.spouseBirthDate"
@update:model-value="val => form.spouseBirthDate = val"
:label="$t('marital.spouse.birthDate')"
type="date"
:rules="[required()]"
/>
<q-input
v-if="maritalconfig[form.maritalStatus]?.deadDate"
:model-value="form.spouseDeadDate"
@update:model-value="val => form.spouseDeadDate = val"
:label="$t(maritalconfig[form.maritalStatus]?.deadDateLabel)"
type="date"
:rules="[required()]"
/>
<AddressInput
v-if="maritalconfig[form.maritalStatus]?.address"
v-model="form.spouseAddress"
:hint="t(maritalconfig[form.maritalStatus]?.addressHint)"
:label="t('taxpayer.address')"
:allowForeign="true"
@save="onSpouseAddressSave"
@cancel="onSpouseAddressCancel"
/>
</template>
</q-form>
</q-card-section>
</q-card>
</template>
<script setup lang="ts">
import { reactive, onMounted, nextTick, ref, } from 'vue'
import type { StepDescriptor } from '../../types/types'
import { useI18n } from 'vue-i18n'
import { useTaxstore } from '../../stores/taxstore'
import type { AddressOut } from '../AddressInput.vue'
import AddressInput from '../AddressInput.vue'
import CommentAttachment from '../CommentAttachment.vue'
import {type MaritalData } from 'src/stores/marital'
interface option{ label: string; value: string }
type Options = option[]
const maritalItems = [
'celibate',
'maried',
'registrated',
'widower',
'deadunion',
'divorced',
'unionlegal',
'separated',
'uniondisappeared',
]
const maritalconfig = {
celibate:{
showSpouseData: false,
address: ''
,data: ''
,deadDate: false
,deadDateLabel: ''
,addressHint: ''
},
maried:{
showSpouseData: true,
address: 'indirizzocogniuge'
,data: 'daticogniuge'
,deadDate: false
,deadDateLabel: ''
,addressHint: 'inserireindirizzocogniuge'
},
registrated:{
showSpouseData: true,
address: 'indirizzopartner'
,data: 'datipartner'
,deadDate: false
,deadDateLabel: ''
,addressHint: 'inserireindirizzopartner'
},
widower:{
showSpouseData: true,
address: ''
,data: 'daticogniugedefunto'
,deadDate: true
,deadDateLabel: 'datadecesso'
,addressHint: ''
},
deadunion:{
showSpouseData: true,
address: ''
,data: 'datideadpartner'
,deadDate: true
,deadDateLabel: 'datadecesso'
,addressHint: ''
},
divorced:{
showSpouseData: true,
address: 'indirizzoexcogniuge'
,data: 'datiexcogniuge'
,deadDate: false
,deadDateLabel: ''
,addressHint: 'inserireindirizzocogniuge'
},
unionlegal:{
showSpouseData: true,
address: 'indirizzoexpartner'
,data: 'datideadexpartner'
,deadDate: true
,deadDateLabel: 'datascioglimento'
,addressHint: 'inserireindirizzopartner'
},
separated:{
showSpouseData: true,
address: 'indirizzoexcogniuge'
,data: 'datiexcogniuge'
,deadDate: false
,deadDateLabel: ''
,addressHint: 'inserireindirizzocogniuge'
},
uniondisappeared:{
showSpouseData: true,
address: ''
,data: 'datidisapparizedpartner'
,deadDate: true
,deadDateLabel: 'datascomparsa'
,addressHint: ''
}
}
const { t } = useI18n()
const store = useTaxstore()
const { step } = defineProps<{ step?: StepDescriptor }>()
const emit = defineEmits(['next', 'prev'])
// reactive form accepts dynamic keys produced from schema
const form = reactive<MaritalData>({} as MaritalData)
// QForm ref for Quasar validation
const formRef = ref<{ validate?: () => Promise<boolean> | boolean; resetValidation?: () => void } | null>(null)
// spouse address modal state
const isSpouseAddressDialogOpen = ref(false)
function getOptions() {
const items = [] as Options
for (const item of maritalItems){
items.push({label:t(`maritalItem.${item}`), value:item})
}
return items
}
function onSpouseAddressCancel() {
isSpouseAddressDialogOpen.value = false
}
function onSpouseAddressSave(a: AddressOut) {
form.spouseAddress = a
isSpouseAddressDialogOpen.value = false
}
// helper for validation messages
const requiredMessage = (fallback = 'Required') => {
const msg = t('validation.required')
return msg && msg !== 'validation.required' ? msg : fallback
}
const required = (msg?: string) => (v: unknown) => {
const message = msg || requiredMessage()
if (v === null || v === undefined) return message
if (typeof v === 'string') return (v.trim() !== '') || message
return true
}
const maritalStatusRules = [required()]
onMounted(async () => {
const saved = store.getMarital()
// initialize form with saved marital data (map keys directly)
if (saved) {
Object.assign(form, saved)
}
// clear any validation state on mount so fields are not marked invalid initially
await nextTick()
formRef.value?.resetValidation?.()
})
function buildPayload(): MaritalData {
const payload = {} as MaritalData
// copy all form fields into payload
for (const [k, v] of Object.entries(form)) {
payload[k] = v
}
return payload
}
async function saveAndNext() {
const ok = await formRef.value?.validate?.()
if (ok === false) return
const payload = buildPayload()
store.setMarital(payload)
emit('next', step?.next)
}
function emitPrev() {
const payload = buildPayload()
store.setMarital(payload)
emit('prev', step?.prev)
}
// expose buildPayload so parent stepper can trigger save/navigation if needed
defineExpose({ buildPayload })
</script>
<style scoped>
.q-card { width: 100%; margin: 0; }
</style>

Some files were not shown because too many files have changed in this diff Show More