first commit
BIN
app/.DS_Store
vendored
Normal file
7
app/.editorconfig
Normal 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
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.quasar
|
||||||
21
app/.eslintrc.cjs
Normal 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
@@ -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
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/prettierrc",
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
77
app/.quasar/dev-spa/app.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
154
app/.quasar/dev-spa/client-entry.js
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
116
app/.quasar/dev-spa/client-prefetch.js
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
21
app/.quasar/dev-spa/quasar-user-options.js
Normal 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
@@ -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
@@ -0,0 +1,8 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
import { Router } from 'vue-router';
|
||||||
|
|
||||||
|
declare module 'pinia' {
|
||||||
|
export interface PiniaCustomProperties {
|
||||||
|
readonly router: Router;
|
||||||
|
}
|
||||||
|
}
|
||||||
75
app/.quasar/prod-spa/app.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
152
app/.quasar/prod-spa/client-entry.js
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
116
app/.quasar/prod-spa/client-prefetch.js
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
21
app/.quasar/prod-spa/quasar-user-options.js
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||||
41
app/Reports/report-2026-01-10.md
Normal 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
1
app/dist/spa/assets/AddressInput-Brku8Gup.js
vendored
Normal 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};
|
||||||
1
app/dist/spa/assets/AddressInput-DTjVpzGz.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.q-card[data-v-b68f19ad]{width:100%;margin:0}
|
||||||
1
app/dist/spa/assets/ChildrenStep-BaJwTfaC.js
vendored
Normal file
1
app/dist/spa/assets/ChildrenStep-CyFMlGKq.css
vendored
Normal 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}
|
||||||
1
app/dist/spa/assets/CommentAttachment-BuVtM3GK.js
vendored
Normal file
1
app/dist/spa/assets/CommentAttachment-M56Dxc2A.css
vendored
Normal 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}
|
||||||
1
app/dist/spa/assets/ErrorNotFound-C9XXVpv6.js
vendored
Normal 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};
|
||||||
1
app/dist/spa/assets/IncomeStep-Cw8mizyx.css
vendored
Normal 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}
|
||||||
1
app/dist/spa/assets/IncomeStep-RKJ4eG2Y.js
vendored
Normal file
2
app/dist/spa/assets/IndexPage-CwM_myNb.js
vendored
Normal 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 all’estero",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 _};
|
||||||
1
app/dist/spa/assets/IndexPage-sr_89QZh.css
vendored
Normal 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}
|
||||||
BIN
app/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuYjalmUiAw-BepdiOnY.woff
vendored
Normal file
BIN
app/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuZtalmUiAw-4ZhHFPot.woff
vendored
Normal file
BIN
app/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuaabVmUiAw-CNa4tw4G.woff
vendored
Normal file
BIN
app/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWub2bVmUiAw-CHKg1YId.woff
vendored
Normal file
BIN
app/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbFmUiAw-yBxCyPWP.woff
vendored
Normal file
BIN
app/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAw-3fZ6d7DD.woff
vendored
Normal file
1
app/dist/spa/assets/LocalStorage-7Uw3xG9P.js
vendored
Normal file
1
app/dist/spa/assets/MainLayout-Cyzrc7AM.js
vendored
Normal file
1
app/dist/spa/assets/MaritalStep-DL6z_e8a.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.q-card[data-v-d74d6ed8]{width:100%;margin:0}
|
||||||
1
app/dist/spa/assets/MaritalStep-npjGE3Gl.js
vendored
Normal file
1
app/dist/spa/assets/QList-CHwmSUjA.js
vendored
Normal 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};
|
||||||
1
app/dist/spa/assets/TaxpayerStep-BfPZ-rxP.js
vendored
Normal 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};
|
||||||
1
app/dist/spa/assets/TaxpayerStep-CWAfROv3.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.q-card[data-v-e1b08f4f]{width:100%;margin:0}
|
||||||
1
app/dist/spa/assets/WelcomeStep-BTfvnfIz.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.q-card[data-v-f4c03057]{width:100%;margin:0}
|
||||||
1
app/dist/spa/assets/WelcomeStep-Crh-xhDH.js
vendored
Normal 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};
|
||||||
1
app/dist/spa/assets/children-ChLzVyp9.js
vendored
Normal 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};
|
||||||
BIN
app/dist/spa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNa-Dr0goTwe.woff
vendored
Normal file
BIN
app/dist/spa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ-D-x-0Q06.woff2
vendored
Normal file
1
app/dist/spa/assets/i18n-iY85aRww.js
vendored
Normal file
2
app/dist/spa/assets/index-0B2sgEyA.js
vendored
Normal file
1
app/dist/spa/assets/index-BQveqNql.css
vendored
Normal file
1
app/dist/spa/assets/taxstore-oR45_mFg.js
vendored
Normal 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};
|
||||||
1
app/dist/spa/assets/userstore-DBCughIu.js
vendored
Normal file
3
app/dist/spa/assets/vue-i18n.runtime-DPKkE7zN.js
vendored
Normal file
BIN
app/dist/spa/favicon.ico
vendored
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
app/dist/spa/icons/favicon-128x128.png
vendored
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
app/dist/spa/icons/favicon-16x16.png
vendored
Normal file
|
After Width: | Height: | Size: 859 B |
BIN
app/dist/spa/icons/favicon-32x32.png
vendored
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
app/dist/spa/icons/favicon-96x96.png
vendored
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
3
app/dist/spa/index.html
vendored
Normal 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
72
app/docs/Manuale_Utente.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Manuale utente (BRUNO – Frontend)
|
||||||
|
|
||||||
|
## Panoramica
|
||||||
|
Questa applicazione guida l’utente 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** l’upload 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 un’introduzione 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 l’upload 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, 1–2 pagine) oppure una versione “operatore” con esempi di documenti da allegare per ogni step.
|
||||||
BIN
app/docs/Manuale_Utente.pdf
Normal file
43
app/docs/reports/2026-01-11.md
Normal 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
@@ -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
@@ -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
@@ -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
29
app/postcss.config.js
Normal 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
|
After Width: | Height: | Size: 63 KiB |
BIN
app/public/icons/favicon-128x128.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
app/public/icons/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 859 B |
BIN
app/public/icons/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
app/public/icons/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
239
app/quasar.config.ts
Normal 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
@@ -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
@@ -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
7
app/src/App.vue
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
//
|
||||||
|
</script>
|
||||||
15
app/src/assets/quasar-logo-vertical.svg
Normal 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
33
app/src/boot/i18n.ts
Normal 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);
|
||||||
|
});
|
||||||
126
app/src/components/AddressInput.vue
Normal 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>
|
||||||
323
app/src/components/AddressModal.vue
Normal 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>
|
||||||
391
app/src/components/CommentAttachment.vue
Normal 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>
|
||||||
305
app/src/components/SchoolModal.vue
Normal 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>
|
||||||
340
app/src/components/SimpleAttachment.vue
Normal 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>
|
||||||
123
app/src/components/StepsStepper.vue
Normal 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 all’estero', 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>
|
||||||
441
app/src/components/steps/ChildrenStep.vue
Normal 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>
|
||||||
183
app/src/components/steps/IncomeStep.vue
Normal 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>
|
||||||
275
app/src/components/steps/MaritalStep.vue
Normal 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>
|
||||||