first commit

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

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

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

View File

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

After

Width:  |  Height:  |  Size: 4.4 KiB

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,148 @@
<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("TAX") }}</div>
</div>
<div class="col-auto">
<q-btn flat color="secondary" :label="t('button.prev')" @click="goPrev" class="q-mr-sm" />
<q-btn color="primary" :label="t('button.next')" @click="goNext" />
</div>
</div>
<q-separator class="q-my-sm" />
<q-form ref="formRef" class="q-gutter-md q-mt-md">
<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.prevPreparedByUs" :label="t('taxpayer.prevPreparedByUs')" />
</div>
</div>
<div v-if="form.prevPreparedByUs" class="q-mt-sm">
<CommentAttachment v-model="form.prevDeclaration" :label="t('taxpayer.prevDeclaration')" :id="'taxpayer'"/>
</div>
<div>
<q-input v-model="form.firstName" :label="t('taxpayer.firstName')" :rules="personalFirstNameRules" />
<q-input v-model="form.lastName" :label="t('taxpayer.lastName')" :rules="personalLastNameRules" />
<q-input v-model="form.birthDate" type="date" :label="t('taxpayer.birthDate')" :rules="personalBirthDateRules" />
<AddressInput v-model="form.address" :label="t('taxpayer.address')" :allowForeign="true" @save="onAddressSave" @cancel="onAddressCancel" />
</div>
</q-form>
</q-card-section>
</q-card>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref, nextTick, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTaxstore } from '../../stores/taxstore'
import AddressInput from '../AddressInput.vue'
import type { StepDescriptor } from '../../types/types'
import CommentAttachment from '../CommentAttachment.vue'
import { type TaxpayerForm } from 'src/stores/taxpayer'
const props = defineProps<{ step: StepDescriptor }>()
const emit = defineEmits(['next', 'prev'])
const step = props.step
const store = useTaxstore()
const form = reactive<TaxpayerForm>({} as TaxpayerForm)
const { t } = useI18n()
// QForm ref for validation
const formRef = ref<{ validate?: () => Promise<boolean> | boolean; resetValidation?: () => void } | null>(null)
// address input binding (maps between form fields and AddressInput shape)
export type AddrOut = {
street: string
cap: string | number
city: string
country: { code: string; name: string }
canton: string
}
// 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
if (Array.isArray(v)) return v.length > 0 || message
return true
}
const prevPrepared = computed(() => !!form.prevPreparedByUs)
const personalFirstNameRules = computed(() => (prevPrepared.value ? [] : [required()]))
const personalLastNameRules = computed(() => (prevPrepared.value ? [] : [required()]))
const personalBirthDateRules = computed(() => (prevPrepared.value ? [] : [required()]))
// address/zip/city use AddressModal for input; validation handled via modal
onMounted(async () => {
// protect against undefined store data
const saved = store.getTaxpayer() || {}
Object.assign(form, saved)
// load saved data from store into local form
// ensure validation is not shown on mount
await nextTick()
formRef.value?.resetValidation?.()
})
async function goNext() {
// validate, then persist into store and navigate
try {
const validateResult = await (formRef.value?.validate?.() ?? true)
if (validateResult === false) return
} catch {
return
}
// persist into store then navigate
store.setTaxpayer({
prevPreparedByUs: form.prevPreparedByUs,
prevDeclaration: form.prevDeclaration,
firstName: form.firstName,
lastName: form.lastName,
birthDate: form.birthDate,
address: form.address
})
emit('next', step.next)
}
function goPrev() {
// save before navigating back
store.setTaxpayer({
prevPreparedByUs: form.prevPreparedByUs,
prevDeclaration: form.prevDeclaration,
firstName: form.firstName,
lastName: form.lastName,
birthDate: form.birthDate,
address: form.address
})
emit('prev', step.prev)
}
function onAddressCancel() {
// no-op; AddressInput already preserves form via v-model
}
function onAddressSave() {
// AddressInput already updated `form` through the computed `taxAddress`.
}
</script>
<style scoped>
.q-card { width: 100%; margin: 0; }
</style>

View File

@@ -0,0 +1,36 @@
<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("WEL") }}</div>
</div>
<div class="col-auto">
<q-btn color="primary" label="Avanti" @click="goNext" />
</div>
</div>
<q-separator class="q-my-sm" />
<pre>{{ JSON.stringify(step, null, 2) }}</pre>
</q-card-section>
</q-card>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { StepDescriptor } from '../../types/types'
const props = defineProps<{ step: StepDescriptor }>()
const emit = defineEmits(['next'])
const step = props.step
const { t } = useI18n()
function goNext() {
emit('next', step?.next)
}
</script>
<style scoped>
.q-card { width: 100%; margin: 0; }
</style>

1
app/src/css/app.scss Normal file
View File

@@ -0,0 +1 @@
// app global css in SCSS form

View File

@@ -0,0 +1,25 @@
// Quasar SCSS (& Sass) Variables
// --------------------------------------------------
// To customize the look and feel of this app, you can override
// the Sass/SCSS variables found in Quasar's source Sass/SCSS files.
// Check documentation for full list of Quasar variables
// Your own variables (that are declared here) and Quasar's own
// ones will be available out of the box in your .vue/.scss/.sass files
// It's highly recommended to change the default colors
// to match your app's branding.
// Tip: Use the "Theme Builder" on Quasar's documentation website.
$primary: #1976d2;
$secondary: #26a69a;
$accent: #9c27b0;
$dark: #1d1d1d;
$dark-page: #121212;
$positive: #21ba45;
$negative: #c10015;
$info: #31ccec;
$warning: #f2c037;

4570
app/src/data/countryCodes.ts Normal file

File diff suppressed because it is too large Load Diff

7
app/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
declare namespace NodeJS {
interface ProcessEnv {
NODE_ENV: string;
VUE_ROUTER_MODE: 'hash' | 'history' | 'abstract' | undefined;
VUE_ROUTER_BASE: string | undefined;
}
}

View File

@@ -0,0 +1,7 @@
// This is just an example,
// so you can safely delete all default props below
export default {
failed: 'Action failed',
success: 'Action was successful',
};

22
app/src/i18n/index.ts Normal file
View File

@@ -0,0 +1,22 @@
import enCommon from './locales/en-US/common'
import enAttachments from './locales/en-US/attachments'
import enSteps from './locales/en-US/steps'
import itCommon from './locales/it-IT/common'
import itAttachments from './locales/it-IT/attachments'
import itSteps from './locales/it-IT/steps'
import frCommon from './locales/fr-FR/common'
import frAttachments from './locales/fr-FR/attachments'
import frSteps from './locales/fr-FR/steps'
import deCommon from './locales/de-DE/common'
import deAttachments from './locales/de-DE/attachments'
import deSteps from './locales/de-DE/steps'
export default {
'en-US': { ...enCommon, ...enAttachments, ...enSteps },
'it-IT': { ...itCommon, ...itAttachments, ...itSteps },
'fr-FR': { ...frCommon, ...frAttachments, ...frSteps },
'de-DE': { ...deCommon, ...deAttachments, ...deSteps }
}

View File

@@ -0,0 +1,14 @@
export default {
attachments: 'Anhänge',
commenti: 'Kommentare',
pickFiles: 'Dateien auswählen',
confirmDeleteAttachment: 'Möchtest du diesen Anhang löschen? {filename}',
addAttachment: 'Anhängen',
fileTypeNotAllowed: 'Dateityp nicht erlaubt. Erlaubt: {allowed}',
missingUserOrSession: 'Benutzer oder Sitzung fehlt. Bitte Seite neu laden und erneut versuchen.',
fileUploaded: 'Datei hochgeladen',
uploadFailed: 'Upload fehlgeschlagen',
uploadCancelled: 'Upload abgebrochen',
fileDeleted: 'Datei gelöscht',
deleteFailed: 'Löschen fehlgeschlagen'
} as const

View File

@@ -0,0 +1,33 @@
export default {
button: {
saveAndNext: 'Speichern und weiter',
next: 'Weiter',
prev: 'Zurück',
cancel: 'Abbrechen',
save: 'Speichern'
},
validation: {
required: 'Dieses Feld ist erforderlich',
minLength: 'Zu kurz',
maxAgeFromJan1: 'Ungültiges Geburtsdatum',
invalidZip: 'Ungültige PLZ',
insertAddress: 'Bitte Adresse eingeben'
},
address: {
title: 'Adresse',
modalTitle: 'Adresse bearbeiten',
street: 'Strasse',
zip: 'PLZ',
city: 'Stadt',
country: 'Land',
foreign: 'Ausländische Adresse'
},
enum: {
maritalStatus: {
SINGLE: 'Ledig',
SEPARATED: 'Getrennt',
MARRIED: 'Verheiratet',
PARTNERED: 'Partner'
}
}
} as const

View File

@@ -0,0 +1,118 @@
export default {
WEL: 'Willkommen',
TAX: 'Angaben Steuerpflichtiger und vorherige Erklärung',
MAR: 'Zivilstand',
CHD: 'Kinder',
INC: 'Einkommen',
PRO: 'Berufsausgaben',
SID: 'Nebeneinkommen',
ANN: 'Renten',
INS: 'Versicherungs- und Krankheitskosten',
P3: 'Policen 3A / 3B',
BNK: 'Bankkonten',
AST: 'Andere Vermögenswerte',
DEB: 'Schulden / Hypotheken',
PROP: 'Immobilien',
FOR: 'Einkommen oder Vermögen im Ausland',
taxpayer: {
prevPreparedByUs: 'Von uns vorbereitet?',
prevDeclaration: 'Frühere Erklärung',
firstName: 'Vorname',
lastName: 'Nachname',
birthDate: 'Geburtsdatum',
address: 'Adresse',
zip: 'PLZ',
city: 'Stadt'
},
marital: {
title: 'Zivilstand',
maritalStatus: 'Zivilstand',
previousDivorces: 'Frühere Scheidungen',
spouse: {
prefixSpouse: 'Ehepartner',
prefixPartner: 'Partner',
firstName: 'Vorname',
lastName: 'Nachname',
birthDate: 'Geburtsdatum',
address: 'Adresse',
zip: 'PLZ',
city: 'Stadt',
previousDivorces: 'Frühere Scheidungen'
}
},
maritalItem: {
celibate: 'Ledig',
maried: 'Verheiratet',
registrated: 'Registrierte häusliche Partnerschaft',
widower: 'Witwer / Witwe',
deadunion: 'Partnerschaft durch Tod aufgelöst',
divorced: 'Geschieden',
unionlegal: 'Partnerschaft durch gerichtliche Entscheidung aufgelöst',
separated: 'Getrennt',
uniondisappeared: 'Partnerschaft durch Verschollenheit aufgelöst'
},
// message used when spouse address field has no explicit label
'marital.spouse.fillIfDifferent': 'Falls abweichend ausfüllen',
income: {
employTypeLabel: 'Beschäftigungsart',
employTypeHint: 'Bitte eine Beschäftigungsart auswählen',
employType: {
EMPLOYED: 'Angestellt',
SELF_EMPLOYED: 'Selbstständig',
PENSIONER: 'Rentner/in',
UNEMPLOYED: 'Arbeitslos'
},
attachments: {
salaryCertificate: 'Lohnbescheinigung',
accountingDocuments: 'Buchhaltungsunterlagen',
avsCertificate: 'AHV-Bescheinigung',
lppCertificate: 'BVG-Bescheinigung',
unemploymentCertificate: 'Arbeitslosenbescheinigung'
}
},
children: {
hasChildren: 'Haben Sie Kinder?',
addChild: 'Kind hinzufügen',
editChild: 'Kind bearbeiten',
listTitle: 'Kinderliste',
moreThanFiveChildrenNote: 'Notiz für mehr als fünf Kinder',
firstName: 'Vorname',
lastName: 'Nachname',
birthDate: 'Geburtsdatum',
sameHousehold: 'Im selben Haushalt',
addressIfDifferent: 'Adresse (falls abweichend)',
addressLabel: 'Adresse',
alimentiVersati: 'Unterhalt wird gezahlt',
school: 'Schule',
hasCareCost: 'Betreuungskosten',
careCosts: 'Betreuungskosten',
copyLastNameFromTaxpayer: 'Nachname vom Steuerpflichtigen übernehmen',
noAttachments: 'Dokumente anhängen'
},
informazionesualimenti: 'Informationen zu Unterhalt',
inserireindirizzocogniuge: 'Adresse des Ehepartners eingeben',
inserireindirizzopartner: 'Adresse des Partners eingeben',
indirizzocogniuge: 'Adresse des Ehepartners',
indirizzopartner: 'Adresse des Partners',
indirizzocogniugedefunto: 'Adresse des verstorbenen Ehepartners',
indirizzodeadpartner: 'Adresse des verstorbenen Partners',
indirizzoexcogniuge: 'Adresse des Ex-Ehepartners',
indirizzoexpartner: 'Adresse des Ex-Partners',
daticogniuge: 'Angaben zum Ehepartner',
datipartner: 'Angaben zum Partner',
daticogniugedefunto: 'Angaben zum verstorbenen Ehepartner',
datideadpartner: 'Angaben zum verstorbenen Partner',
datiexcogniuge: 'Angaben zum Ex-Ehepartner',
datideadexpartner: 'Angaben zum Ex-Partner',
datidisapparizedpartner: 'Angaben zum verschollenen Partner',
datadecesso: 'Sterbedatum',
datascomparsa: 'Datum des Verschwindens',
datascioglimento: 'Auflösungsdatum'
} as const

View File

@@ -0,0 +1,14 @@
export default {
attachments: 'Attachments',
commenti: 'Comments',
pickFiles: 'Pick files',
confirmDeleteAttachment: 'Do you want to delete this attachment? {filename}',
addAttachment: 'Attach',
fileTypeNotAllowed: 'File type not allowed. Allowed: {allowed}',
missingUserOrSession: 'Missing user or session. Please reload and try again.',
fileUploaded: 'File uploaded',
uploadFailed: 'Upload failed',
uploadCancelled: 'Upload cancelled',
fileDeleted: 'File deleted',
deleteFailed: 'Delete failed'
} as const

View File

@@ -0,0 +1,33 @@
export default {
button: {
saveAndNext: 'Save and Next',
next: 'Next',
prev: 'Back',
cancel: 'Cancel',
save: 'Save'
},
validation: {
required: 'This field is required',
minLength: 'Too short',
maxAgeFromJan1: 'Invalid birth date',
invalidZip: 'Invalid ZIP',
insertAddress: 'Please enter an address'
},
address: {
title: 'Address',
modalTitle: 'Edit address',
street: 'Street',
zip: 'ZIP',
city: 'City',
country: 'Country',
foreign: 'Foreign address'
},
enum: {
maritalStatus: {
SINGLE: 'Single',
SEPARATED: 'Separated',
MARRIED: 'Married',
PARTNERED: 'Partnered'
}
}
} as const

View File

@@ -0,0 +1,118 @@
export default {
WEL: 'Welcome',
TAX: 'Taxpayer data & previous declaration',
MAR: 'Marital status',
CHD: 'Children',
INC: 'Income',
PRO: 'Professional expenses',
SID: 'Supplementary income',
ANN: 'Annuities',
INS: 'Insurance & medical expenses',
P3: 'Policies 3A / 3B',
BNK: 'Bank accounts',
AST: 'Other assets',
DEB: 'Debts / mortgages',
PROP: 'Properties',
FOR: 'Foreign income or assets',
taxpayer: {
prevPreparedByUs: 'Prepared by us?',
prevDeclaration: 'Previous declaration',
firstName: 'First name',
lastName: 'Last name',
birthDate: 'Birth date',
address: 'Address',
zip: 'ZIP',
city: 'City'
},
marital: {
title: 'Marital status',
maritalStatus: 'Marital status',
previousDivorces: 'Previous divorces',
spouse: {
prefixSpouse: 'Spouse',
prefixPartner: 'Partner',
firstName: 'First name',
lastName: 'Last name',
birthDate: 'Birth date',
address: 'Address',
zip: 'ZIP',
city: 'City',
previousDivorces: 'Previous divorces'
}
},
maritalItem: {
celibate: 'Single',
maried: 'Married',
registrated: 'Registered domestic partnership',
widower: 'Widower/Widow',
deadunion: 'Union dissolved by death',
divorced: 'Divorced',
unionlegal: 'Union dissolved by legal decision',
separated: 'Separated',
uniondisappeared: 'Union dissolved by declaration of disappearance'
},
// message used when spouse address field has no explicit label
'marital.spouse.fillIfDifferent': 'Fill if different',
income: {
employTypeLabel: 'Employment type',
employTypeHint: 'Select an employment type',
employType: {
EMPLOYED: 'Employed',
SELF_EMPLOYED: 'Self-employed',
PENSIONER: 'Pensioner',
UNEMPLOYED: 'Unemployed'
},
attachments: {
salaryCertificate: 'Salary certificate',
accountingDocuments: 'Accounting documents',
avsCertificate: 'AVS certificate',
lppCertificate: 'LPP certificate',
unemploymentCertificate: 'Unemployment certificate'
}
},
children: {
hasChildren: 'Do you have children?',
addChild: 'Add child',
editChild: 'Edit child',
listTitle: 'Children list',
moreThanFiveChildrenNote: 'More than five children note',
firstName: 'First name',
lastName: 'Last name',
birthDate: 'Birth date',
sameHousehold: 'Same household',
addressIfDifferent: 'Address (if different)',
addressLabel: 'Address',
alimentiVersati: 'Alimony paid',
school: 'School',
hasCareCost: 'Care costs',
careCosts: 'Care costs',
copyLastNameFromTaxpayer: 'Copy last name from taxpayer',
noAttachments: 'Attach documents'
},
informazionesualimenti: 'Alimony information',
inserireindirizzocogniuge: 'Enter spouse address',
inserireindirizzopartner: 'Enter partner address',
indirizzocogniuge: 'Spouse address',
indirizzopartner: 'Partner address',
indirizzocogniugedefunto: 'Deceased spouse address',
indirizzodeadpartner: 'Deceased partner address',
indirizzoexcogniuge: 'Ex-spouse address',
indirizzoexpartner: 'Ex-partner address',
daticogniuge: 'Spouse details',
datipartner: 'Partner details',
daticogniugedefunto: 'Deceased spouse details',
datideadpartner: 'Deceased partner details',
datiexcogniuge: 'Ex-spouse details',
datideadexpartner: 'Ex-partner details',
datidisapparizedpartner: 'Missing partner details',
datadecesso: 'Date of death',
datascomparsa: 'Date of disappearance',
datascioglimento: 'Dissolution date'
} as const

View File

@@ -0,0 +1,14 @@
export default {
attachments: 'Pièces jointes',
commenti: 'Commentaires',
pickFiles: 'Choisir des fichiers',
confirmDeleteAttachment: 'Voulez-vous supprimer cette pièce jointe ? {filename}',
addAttachment: 'Joindre',
fileTypeNotAllowed: 'Type de fichier non autorisé. Autorisés : {allowed}',
missingUserOrSession: "Utilisateur ou session manquant. Rechargez la page et réessayez.",
fileUploaded: 'Fichier téléversé',
uploadFailed: 'Échec du téléversement',
uploadCancelled: 'Téléversement annulé',
fileDeleted: 'Fichier supprimé',
deleteFailed: 'Échec de la suppression'
} as const

View File

@@ -0,0 +1,33 @@
export default {
button: {
saveAndNext: 'Enregistrer et suivant',
next: 'Suivant',
prev: 'Précédent',
cancel: 'Annuler',
save: 'Enregistrer'
},
validation: {
required: 'Ce champ est obligatoire',
minLength: 'Trop court',
maxAgeFromJan1: 'Date de naissance invalide',
invalidZip: 'Code postal invalide',
insertAddress: "Veuillez saisir l'adresse"
},
address: {
title: 'Adresse',
modalTitle: "Modifier l'adresse",
street: 'Rue / Place',
zip: 'Code postal',
city: 'Ville',
country: 'Pays',
foreign: 'Adresse étrangère'
},
enum: {
maritalStatus: {
SINGLE: 'Célibataire',
SEPARATED: 'Séparé',
MARRIED: 'Marié',
PARTNERED: 'Partenaire'
}
}
} as const

View File

@@ -0,0 +1,118 @@
export default {
WEL: 'Bienvenue',
TAX: 'Données contribuable et déclaration précédente',
MAR: 'État civil',
CHD: 'Enfants',
INC: 'Revenus',
PRO: 'Frais professionnels',
SID: 'Revenu accessoire',
ANN: 'Rentes',
INS: 'Assurances et frais médicaux',
P3: 'Polices 3A / 3B',
BNK: 'Comptes bancaires',
AST: 'Autres biens / avoirs',
DEB: 'Dettes / hypothèques',
PROP: 'Immobilier',
FOR: "Revenus ou avoirs à l'étranger",
taxpayer: {
prevPreparedByUs: 'Préparée par nous?',
prevDeclaration: 'Déclaration précédente',
firstName: 'Prénom',
lastName: 'Nom',
birthDate: 'Date de naissance',
address: 'Adresse',
zip: 'Code postal',
city: 'Ville'
},
marital: {
title: 'État civil',
maritalStatus: 'État civil',
previousDivorces: 'Divorces précédents',
spouse: {
prefixSpouse: 'Conjoint',
prefixPartner: 'Partenaire',
firstName: 'Prénom',
lastName: 'Nom',
birthDate: 'Date de naissance',
address: 'Adresse',
zip: 'Code postal',
city: 'Ville',
previousDivorces: 'Divorces précédents'
}
},
maritalItem: {
celibate: 'Célibataire',
maried: 'Marié(e)',
registrated: 'Union domestique enregistrée',
widower: 'Veuf / Veuve',
deadunion: 'Union dissoute par décès',
divorced: 'Divorcé(e)',
unionlegal: 'Union dissoute par décision judiciaire',
separated: 'Séparé(e)',
uniondisappeared: 'Union dissoute par déclaration de disparition'
},
// message used when spouse address field has no explicit label
'marital.spouse.fillIfDifferent': 'Remplir si différent',
income: {
employTypeLabel: "Type d'emploi",
employTypeHint: "Sélectionner un type d'emploi",
employType: {
EMPLOYED: 'Salarié',
SELF_EMPLOYED: 'Indépendant',
PENSIONER: 'Retraité',
UNEMPLOYED: 'Sans emploi'
},
attachments: {
salaryCertificate: 'Certificat de salaire',
accountingDocuments: 'Documents comptables',
avsCertificate: 'Certificat AVS',
lppCertificate: 'Certificat LPP',
unemploymentCertificate: 'Certificat de chômage'
}
},
children: {
hasChildren: 'Avez-vous des enfants?',
addChild: 'Ajouter un enfant',
editChild: "Modifier l'enfant",
listTitle: 'Liste des enfants',
moreThanFiveChildrenNote: 'Note pour plus de cinq enfants',
firstName: 'Prénom',
lastName: 'Nom',
birthDate: 'Date de naissance',
sameHousehold: 'Même ménage',
addressIfDifferent: 'Adresse (si différente)',
addressLabel: 'Adresse',
alimentiVersati: 'Pension alimentaire versée',
school: 'École',
hasCareCost: 'Frais de garde',
careCosts: 'Frais de garde',
copyLastNameFromTaxpayer: 'Copier le nom du contribuable',
noAttachments: 'Joindre des documents'
},
informazionesualimenti: 'Informations sur la pension alimentaire',
inserireindirizzocogniuge: "Saisir l'adresse du conjoint",
inserireindirizzopartner: "Saisir l'adresse du partenaire",
indirizzocogniuge: 'Adresse du conjoint',
indirizzopartner: 'Adresse du partenaire',
indirizzocogniugedefunto: 'Adresse du conjoint décédé',
indirizzodeadpartner: 'Adresse du partenaire décédé',
indirizzoexcogniuge: "Adresse de l'ex-conjoint",
indirizzoexpartner: "Adresse de l'ex-partenaire",
daticogniuge: 'Données du conjoint',
datipartner: 'Données du partenaire',
daticogniugedefunto: 'Données du conjoint décédé',
datideadpartner: 'Données du partenaire décédé',
datiexcogniuge: "Données de l'ex-conjoint",
datideadexpartner: "Données de l'ex-partenaire",
datidisapparizedpartner: 'Données du partenaire disparu',
datadecesso: 'Date de décès',
datascomparsa: 'Date de disparition',
datascioglimento: 'Date de dissolution'
} as const

View File

@@ -0,0 +1,14 @@
export default {
attachments: 'Allegati',
commenti: 'Commenti',
pickFiles: 'Scegli file',
confirmDeleteAttachment: 'Vuoi eliminare questo allegato? {filename}',
addAttachment: 'Allega',
fileTypeNotAllowed: 'Tipo di file non consentito. Consentiti: {allowed}',
missingUserOrSession: 'Utente o sessione mancanti. Ricarica la pagina e riprova.',
fileUploaded: 'File caricato',
uploadFailed: 'Caricamento fallito',
uploadCancelled: 'Caricamento annullato',
fileDeleted: 'File eliminato',
deleteFailed: 'Eliminazione fallita'
} as const

View File

@@ -0,0 +1,33 @@
export default {
button: {
saveAndNext: 'Salva e Avanti',
next: 'Avanti',
prev: 'Indietro',
cancel: 'Annulla',
save: 'Salva'
},
validation: {
required: 'Campo obbligatorio',
minLength: 'Troppo corto',
maxAgeFromJan1: 'Data di nascita non valida',
invalidZip: 'CAP non valido',
insertAddress: 'Inserire indirizzo'
},
address: {
title: 'Indirizzo',
modalTitle: 'Modifica indirizzo',
street: 'Via / Piazza',
zip: 'CAP',
city: 'Città',
country: 'Nazione',
foreign: 'Indirizzo estero'
},
enum: {
maritalStatus: {
SINGLE: 'Single',
SEPARATED: 'Separato',
MARRIED: 'Sposato',
PARTNERED: 'Unito civilmente'
}
}
} as const

View File

@@ -0,0 +1,118 @@
export default {
WEL: 'Benvenuto',
TAX: 'Dati contribuente e dichiarazione precedente',
MAR: 'Stato civile',
CHD: 'Figli',
INC: 'Redditi',
PRO: 'Spese professionali',
SID: 'Reddito accessorio',
ANN: 'Rendite',
INS: 'Spese assicurative e mediche',
P3: 'Polizze 3A / 3B',
BNK: 'Conti bancari',
AST: 'Altri beni / averi',
DEB: 'Debiti / ipoteche',
PROP: 'Immobili',
FOR: "Redditi o averi allestero",
taxpayer: {
prevPreparedByUs: 'Preparata da noi?',
prevDeclaration: 'Dichiarazione precedente',
firstName: 'Nome',
lastName: 'Cognome',
birthDate: 'Data di nascita',
address: 'Indirizzo',
zip: 'CAP',
city: 'Città'
},
marital: {
title: 'Stato civile',
maritalStatus: 'Stato civile',
previousDivorces: 'Divorzi precedenti',
spouse: {
prefixSpouse: 'Coniuge',
prefixPartner: 'Partner',
firstName: 'Nome',
lastName: 'Cognome',
birthDate: 'Data di nascita',
address: 'Indirizzo',
zip: 'CAP',
city: 'Città',
previousDivorces: 'Divorzi precedenti'
}
},
maritalItem: {
celibate: 'celibe/ nubile',
maried: 'Cognugato/a',
registrated: 'In unione domestica registrata',
widower: 'Vedovo/a',
deadunion: 'Unione domestica sciolta per decesso',
divorced: 'Divorziato/a',
unionlegal: 'Unione domestica sciolta per decisione legale',
separated: 'Separato/a',
uniondisappeared: 'Unione domestica sciolta per dichiarazione di scomparsa'
},
// message used when spouse address field has no explicit label
'marital.spouse.fillIfDifferent': 'Compilare se diverso',
income: {
employTypeLabel: 'Tipo di impiego',
employTypeHint: 'Selezionare un tipo di impiego',
employType: {
EMPLOYED: 'Dipendente',
SELF_EMPLOYED: 'Indipendente',
PENSIONER: 'Pensionato',
UNEMPLOYED: 'Disoccupato'
},
attachments: {
salaryCertificate: 'Certificato salariale',
accountingDocuments: 'Documenti contabili',
avsCertificate: 'Certificato AVS',
lppCertificate: 'Certificato LPP',
unemploymentCertificate: 'Certificato disoccupazione'
}
},
children: {
hasChildren: 'Hai figli?',
addChild: 'Aggiungi figlio',
editChild: 'Modifica figlio',
listTitle: 'Elenco figli',
moreThanFiveChildrenNote: 'Nota per più di cinque figli',
firstName: 'Nome',
lastName: 'Cognome',
birthDate: 'Data di nascita',
sameHousehold: 'Stesso nucleo familiare',
addressIfDifferent: 'Indirizzo (se diverso)',
addressLabel: 'Indirizzo',
alimentiVersati: 'Vengono versati alimenti',
school: 'Scuola',
hasCareCost: 'Spese di cura',
careCosts: 'Spese di cura',
copyLastNameFromTaxpayer: 'Copia cognome dal contribuente',
noAttachments: 'Allega documenti'
},
informazionesualimenti: 'Informazioni su alimenti',
inserireindirizzocogniuge: "Inserire l'indirizzo del coniuge",
inserireindirizzopartner: "Inserire l'indirizzo del partner",
indirizzocogniuge: 'Indirizzo del coniuge',
indirizzopartner: 'Indirizzo del partner',
indirizzocogniugedefunto: 'Indirizzo del coniuge deceduto',
indirizzodeadpartner: 'Indirizzo del partner deceduto',
indirizzoexcogniuge: 'Indirizzo ex-coniuge',
indirizzoexpartner: 'Indirizzo ex-partner',
daticogniuge: 'Dati del coniuge',
datipartner: 'Dati del partner',
daticogniugedefunto: 'Dati del coniuge deceduto',
datideadpartner: 'Dati del partner deceduto',
datiexcogniuge: 'Dati ex-coniuge',
datideadexpartner: 'Dati ex-partner',
datidisapparizedpartner: 'Dati del partner scomparso',
datadecesso: 'Data di decesso',
datascomparsa: 'Data di scomparsa',
datascioglimento: 'Data di scioglimento'
} as const

View File

@@ -0,0 +1,102 @@
<template>
<q-layout view="lHh Lpr lFf">
<q-header elevated>
<q-toolbar>
<q-btn flat dense round icon="menu" aria-label="Menu" @click="toggleLeftDrawer" />
<q-toolbar-title> Quasar App </q-toolbar-title>
<q-space />
<div class="row items-center no-wrap">
<div class="q-mr-md">Quasar v{{ $q.version }}</div>
<q-btn flat dense icon="language" :label="currentLocaleLabel" aria-label="Language">
<q-menu auto-close>
<q-list>
<q-item clickable v-for="loc in locales" :key="loc.code" @click="setLocale(loc.code)">
<q-item-section>{{ loc.label }}</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-btn>
</div>
</q-toolbar>
</q-header>
<q-drawer v-model="leftDrawerOpen" show-if-above bordered>
<q-scroll-area class="fit">
<div class="q-pa-md">
<div class="text-subtitle2 q-mb-sm">Dati contribuente</div>
<pre class="q-pa-sm bg-grey-2 text-body2" style="white-space:pre-wrap">{{ JSON.stringify(unref(taxpayer.data), null, 2) }}</pre>
</div>
<div class="q-pa-md">
<div class="text-subtitle2 q-mb-sm">Dati stato civile</div>
<pre class="q-pa-sm bg-grey-2 text-body2" style="white-space:pre-wrap">{{ JSON.stringify(unref(marital.data), null, 2) }}</pre>
</div>
<div class="q-pa-md">
<div class="text-subtitle2 q-mb-sm">Dati figli</div>
<pre class="q-pa-sm bg-grey-2 text-body2" style="white-space:pre-wrap">{{ JSON.stringify(unref(children.data), null, 2) }}</pre>
</div>
</q-scroll-area>
</q-drawer>
<q-page-container>
<router-view />
</q-page-container>
<q-footer elevated class="bg-white">
<q-toolbar class="q-pl-md q-pr-md">
<div class="row items-center no-wrap">
<div class="col"> </div>
<div>
<q-btn flat dense icon="language" :label="currentLocaleLabel" aria-label="Language">
<q-menu auto-close>
<q-list>
<q-item clickable v-for="loc in locales" :key="loc.code" @click="setLocale(loc.code)">
<q-item-section>{{ loc.label }}</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-btn>
</div>
</div>
</q-toolbar>
</q-footer>
</q-layout>
</template>
<script setup lang="ts">
import { ref, unref, computed } from 'vue';
import { useTaxpayerStore } from '../stores/taxpayer'
import { useMaritalStore } from '../stores/marital';
import { useChildrenStore } from '../stores/children'
import { useI18n } from 'vue-i18n'
const leftDrawerOpen = ref(false);
const taxpayer = useTaxpayerStore()
const marital = useMaritalStore()
const children = useChildrenStore()
const { locale } = useI18n()
const locales = [
{ code: 'it-IT', label: 'Italiano' },
{ code: 'en-US', label: 'English' },
{ code: 'fr-FR', label: 'Français' },
{ code: 'de-DE', label: 'Deutsch' }
]
function setLocale(code: string) {
locale.value = code
}
const currentLocaleLabel = computed(() => {
const found = locales.find(l => l.code === locale.value)
return found ? found.label : String(locale.value)
})
function toggleLeftDrawer() {
leftDrawerOpen.value = !leftDrawerOpen.value;
}
</script>

View File

@@ -0,0 +1,23 @@
<template>
<div class="fullscreen bg-blue text-white text-center q-pa-md flex flex-center">
<div>
<div style="font-size: 30vh">404</div>
<div class="text-h2" style="opacity: 0.4">Oops. Nothing here...</div>
<q-btn
class="q-mt-xl"
color="white"
text-color="blue"
unelevated
to="/"
label="Go Home"
no-caps
/>
</div>
</div>
</template>
<script setup lang="ts">
//
</script>

View File

@@ -0,0 +1,26 @@
<template>
<q-page class="row full-width justify-center full-height">
<steps-stepper class="no-shadow full-height full-width" />
</q-page>
</template>
<script setup lang="ts">
import StepsStepper from 'components/StepsStepper.vue';
</script>
<style scoped>
/* remove shadow and force stepper to fill available space */
:deep(.no-shadow .q-stepper) {
box-shadow: none !important;
height: 100% !important;
width: 100% !important;
}
/* ensure card inside step occupies full height */
:deep(.no-shadow .q-stepper .q-step__content),
:deep(.no-shadow .q-stepper .q-card) {
height: 100% !important;
}
</style>

37
app/src/router/index.ts Normal file
View File

@@ -0,0 +1,37 @@
import { defineRouter } from '#q-app/wrappers';
import {
createMemoryHistory,
createRouter,
createWebHashHistory,
createWebHistory,
} from 'vue-router';
import routes from './routes';
/*
* If not building with SSR mode, you can
* directly export the Router instantiation;
*
* The function below can be async too; either use
* async/await or return a Promise which resolves
* with the Router instance.
*/
export default defineRouter(function (/* { store, ssrContext } */) {
const createHistory = process.env.SERVER
? createMemoryHistory
: process.env.VUE_ROUTER_MODE === 'history'
? createWebHistory
: createWebHashHistory;
const Router = createRouter({
scrollBehavior: () => ({ left: 0, top: 0 }),
routes,
// Leave this as is and make changes in quasar.conf.js instead!
// quasar.conf.js -> build -> vueRouterMode
// quasar.conf.js -> build -> publicPath
history: createHistory(process.env.VUE_ROUTER_BASE),
});
return Router;
});

18
app/src/router/routes.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
path: '/',
component: () => import('layouts/MainLayout.vue'),
children: [{ path: '', component: () => import('pages/IndexPage.vue') }],
},
// Always leave this as last one,
// but you can also remove it
{
path: '/:catchAll(.*)*',
component: () => import('pages/ErrorNotFound.vue'),
},
];
export default routes;

283
app/src/schema.json Normal file
View File

@@ -0,0 +1,283 @@
{
"steps": [
{
"id": "welcome",
"order": 0,
"title": "Benvenuto",
"description": "",
"type": "intro",
"fields": [],
"attachments": [],
"pricing": [],
"next": "taxpayer"
},
{
"id": "taxpayer",
"order": 1,
"title": "Dati contribuente e dichiarazione precedente",
"description": "",
"fields": [
{ "key": "prevPreparedByUs", "type": "boolean", "required": true },
{ "key": "prevDeclaration", "type": "file", "requiredIf": { "prevPreparedByUs": false } },
{ "key": "taxpayer.firstName", "type": "string", "required": true },
{ "key": "taxpayer.lastName", "type": "string", "required": true },
{ "key": "taxpayer.birthDate", "type": "date", "required": true },
{ "key": "taxpayer.address", "type": "string", "required": true },
{ "key": "taxpayer.zip", "type": "string", "required": true },
{ "key": "taxpayer.city", "type": "string", "required": true }
],
"attachments": ["prevDeclaration"],
"pricing": [],
"next": "marital"
},
{
"id": "marital",
"order": 2,
"title": "Stato civile",
"description": "",
"fields": [
{ "key": "maritalStatus", "type": "enum", "values": ["SINGLE", "MARRIED", "PARTNERED"], "required": true },
{ "key": "spouse.firstName", "type": "string", "requiredIf": { "maritalStatus": ["MARRIED","PARTNERED"] } },
{ "key": "spouse.lastName", "type": "string", "requiredIf": { "maritalStatus": ["MARRIED","PARTNERED"] } },
{ "key": "spouse.birthDate", "type": "date", "requiredIf": { "maritalStatus": ["MARRIED","PARTNERED"] } },
{ "key": "spouse.address", "type": "string", "requiredIf": { "maritalStatus": ["MARRIED","PARTNERED"] } },
{ "key": "spouse.zip", "type": "string", "requiredIf": { "maritalStatus": ["MARRIED","PARTNERED"] } },
{ "key": "spouse.city", "type": "string", "requiredIf": { "maritalStatus": ["MARRIED","PARTNERED"] } }
],
"attachments": [],
"pricing": [
{ "amountCHF": 10, "condition": { "maritalStatus": ["MARRIED","PARTNERED"] } }
],
"next": "children"
},
{
"id": "children",
"order": 3,
"title": "Figli",
"description": "",
"fields": [
{ "key": "hasChildren", "type": "boolean", "required": true },
{
"key": "children",
"type": "array",
"maxItems": 5,
"item": {
"firstName": "string",
"lastName": "string",
"birthDate": "date",
"sameHousehold": "boolean",
"addressIfDifferent": "string",
"school": "string",
"careCosts": "file"
}
},
{ "key": "moreThanFiveChildrenNote", "type": "string", "required": false }
],
"attachments": ["children[*].careCosts"],
"pricing": [
{ "amountCHF": 5, "perItem": "children" }
],
"next": "income"
},
{
"id": "income",
"order": 4,
"title": "Redditi",
"description": "",
"fields": [
{
"key": "incomeTypes",
"type": "array",
"values": ["EMPLOYED", "SELF_EMPLOYED", "PENSIONER", "UNEMPLOYED"]
},
{ "key": "employment.percent", "type": "number", "requiredIf": { "incomeTypes": "EMPLOYED" } },
{ "key": "employment.detailsUnder70", "type": "string", "requiredIf": { "employment.percent": "<70" } }
],
"attachments": [
"salaryCertificate",
"accountingDocuments",
"avsCertificate",
"lppCertificate",
"unemploymentCertificate"
],
"pricing": [],
"next": "professionalExpenses"
},
{
"id": "professionalExpenses",
"order": 5,
"title": "Spese professionali",
"description": "",
"fields": [
{ "key": "expensesChanged", "type": "boolean", "required": true },
{ "key": "workplaceDescription", "type": "string", "requiredIf": { "expensesChanged": true } },
{ "key": "commuteMethod", "type": "enum", "values": ["CAR", "BUS", "BIKE", "WALK"] },
{ "key": "commuteKm", "type": "number" },
{ "key": "lunchAtHome", "type": "boolean" },
{ "key": "eatsOut", "type": "boolean" },
{ "key": "hasCanteenOrVouchers", "type": "boolean", "requiredIf": { "eatsOut": true } }
],
"attachments": ["transportSubscription"],
"pricing": [],
"next": "sideIncome"
},
{
"id": "sideIncome",
"order": 6,
"title": "Reddito accessorio",
"description": "",
"fields": [
{ "key": "hasSideIncome", "type": "boolean", "required": true }
],
"attachments": ["sideIncomeDocuments"],
"pricing": [
{ "amountCHF": 5, "condition": { "hasSideIncome": true } }
],
"next": "annuities"
},
{
"id": "annuities",
"order": 7,
"title": "Rendite",
"description": "",
"fields": [
{ "key": "hasAnnuities", "type": "boolean", "required": true }
],
"attachments": ["annuityDocuments"],
"pricing": [
{ "amountCHF": 5, "condition": { "hasAnnuities": true } }
],
"next": "insurance"
},
{
"id": "insurance",
"order": 8,
"title": "Spese assicurative e mediche",
"description": "",
"fields": [],
"attachments": ["healthInsuranceCertificate", "medicalExpenses"],
"pricing": [
{ "amountCHF": 5, "condition": { "medicalExpenses": true } }
],
"next": "pillar3"
},
{
"id": "pillar3",
"order": 9,
"title": "Polizze 3A / 3B",
"description": "",
"fields": [
{ "key": "hasPillar3", "type": "boolean", "required": true }
],
"attachments": ["pillar3Documents"],
"pricing": [
{ "amountCHF": 5, "condition": { "hasPillar3": true } }
],
"next": "bankAccounts"
},
{
"id": "bankAccounts",
"order": 10,
"title": "Conti bancari",
"description": "",
"fields": [
{ "key": "hasBankAccounts", "type": "boolean", "required": true }
],
"attachments": ["bankStatements"],
"pricing": [
{ "amountCHF": 2, "perExtraAttachmentAfter": 2 }
],
"next": "otherAssets"
},
{
"id": "otherAssets",
"order": 11,
"title": "Altri beni / averi",
"description": "",
"fields": [
{ "key": "hasOtherAssets", "type": "boolean", "required": true },
{ "key": "otherAssetsList", "type": "array", "item": { "description": "string", "amount": "number" } }
],
"attachments": ["otherAssetsDocuments"],
"pricing": [
{ "amountCHF": 5, "perItem": "otherAssetsList" }
],
"next": "debts"
},
{
"id": "debts",
"order": 12,
"title": "Debiti / ipoteche",
"description": "",
"fields": [
{ "key": "hasDebts", "type": "boolean", "required": true }
],
"attachments": ["debtCertificates"],
"pricing": [
{ "amountCHF": 5, "perAttachment": true }
],
"next": "properties"
},
{
"id": "properties",
"order": 13,
"title": "Immobili",
"description": "",
"fields": [
{ "key": "hasProperties", "type": "boolean", "required": true },
{
"key": "propertiesList",
"type": "array",
"item": {
"country": "enum",
"address": "string",
"purchaseYear": "number",
"buildingYear": "number",
"isRented": "boolean"
}
}
],
"attachments": ["propertyDocuments", "deed", "maintenanceInvoices"],
"pricing": [
{ "amountCHF": 5, "condition": { "country": "SWISS" } },
{ "amountCHF": 7, "condition": { "country": "ITALY" } },
{ "amountCHF": 7, "condition": { "country": "FOREIGN" } }
],
"next": "foreign"
},
{
"id": "foreign",
"order": 14,
"title": "Redditi o averi allestero",
"description": "",
"fields": [
{ "key": "hasForeignAssets", "type": "boolean", "required": true },
{ "key": "foreignDescription", "type": "string", "requiredIf": { "hasForeignAssets": true } }
],
"attachments": ["foreignDocuments"],
"pricing": [
{ "amountCHF": 5, "perAttachment": true }
],
"next": null
}
]
}

102
app/src/stores/children.ts Normal file
View File

@@ -0,0 +1,102 @@
import { defineStore } from 'pinia'
import { LocalStorage } from 'quasar'
import type { Address } from '../types/address'
interface CommentAttachment {
comments: string
attachments: string[]
}
export interface ChildItem {
firstName: string
lastName: string
birthDate: string
sameHousehold: boolean
alimentiVersati?: boolean
school: string
hasCareCost: boolean
careCosts: CommentAttachment
address?: Address | null
}
export interface ChildrenData {
hasChildren: boolean
children: ChildItem[]
moreThanFiveChildrenNote: string
}
const STORAGE_KEY = 'children:v1'
const DEFAULT: ChildrenData = {
hasChildren: false,
children: [],
moreThanFiveChildrenNote: ''
}
export const useChildrenStore = defineStore('childrenstore', {
state: () => {
try {
let saved: unknown = LocalStorage.getItem(STORAGE_KEY)
if (typeof saved === 'string') {
try {
saved = JSON.parse(saved)
} catch {
saved = null
}
}
if (saved && typeof saved === 'object') {
return { data: { ...(saved as ChildrenData) } }
}
} catch {
// ignore and fall back to default
}
return { data: { ...DEFAULT } as ChildrenData }
},
actions: {
persist() {
try {
// LocalStorage (and JSON) can fail when trying to serialize
// File objects. Build a serializable copy: drop `careCosts`
// (or convert to file meta) before persisting.
const serializable: ChildrenData = {
hasChildren: !!this.data.hasChildren,
children: Array.isArray(this.data.children)
? this.data.children.map(c => {
return {
firstName: c.firstName,
lastName: c.lastName,
birthDate: c.birthDate,
sameHousehold: c.sameHousehold,
alimentiVersati: (c as Partial<ChildItem>).alimentiVersati ?? false,
school: c.school,
hasCareCost: (c as Partial<ChildItem>).hasCareCost ?? false,
careCosts: c.careCosts,
address: (c as Partial<ChildItem>).address ?? null,
}
})
: [],
moreThanFiveChildrenNote: this.data.moreThanFiveChildrenNote || ''
}
LocalStorage.set(STORAGE_KEY, serializable)
} catch (err) {
// keep errors visible in console to aid debugging but don't throw
console.error('children.store: persist error', err)
}
},
getChildren() {
return this.data
},
setChildren(partial: Partial<ChildrenData>) {
this.data = { ...this.data, ...partial }
this.persist()
},
replaceChildren(payload: ChildrenData) {
this.data = payload
this.persist()
},
resetChildren() {
this.data = { ...DEFAULT }
this.persist()
}
}
})

66
app/src/stores/income.ts Normal file
View File

@@ -0,0 +1,66 @@
import { defineStore } from 'pinia'
import { LocalStorage } from 'quasar'
import type { IncomeData } from '../types/types'
const STORAGE_KEY = 'income:v1'
const DEFAULT: IncomeData = {
employType: null,
attachments: {
salaryCertificate: { comments: '', attachments: [] },
accountingDocuments: { comments: '', attachments: [] },
avsCertificate: { comments: '', attachments: [] },
lppCertificate: { comments: '', attachments: [] },
unemploymentCertificate: { comments: '', attachments: [] }
}
}
function isRecord(v: unknown): v is Record<string, unknown> {
return !!v && typeof v === 'object' && !Array.isArray(v)
}
export const useIncomeStore = defineStore('incomestore', {
state: () => {
try {
let saved: unknown = LocalStorage.getItem(STORAGE_KEY)
if (typeof saved === 'string') {
try {
saved = JSON.parse(saved)
} catch {
saved = null
}
}
if (isRecord(saved)) {
return { data: { ...DEFAULT, ...(saved as Partial<IncomeData>) } as IncomeData }
}
} catch {
// ignore and fall back to default
}
return { data: { ...DEFAULT } as IncomeData }
},
actions: {
persist() {
try {
LocalStorage.set(STORAGE_KEY, this.data)
} catch (err) {
console.error('income.store: persist error', err)
}
},
getIncome() {
return this.data
},
setIncome(partial: Partial<IncomeData>) {
this.data = { ...this.data, ...partial }
this.persist()
},
replaceIncome(payload: IncomeData) {
this.data = payload
this.persist()
},
resetIncome() {
this.data = { ...DEFAULT }
this.persist()
}
}
})

32
app/src/stores/index.ts Normal file
View File

@@ -0,0 +1,32 @@
import { defineStore } from '#q-app/wrappers';
import { createPinia } from 'pinia';
/*
* When adding new properties to stores, you should also
* extend the `PiniaCustomProperties` interface.
* @see https://pinia.vuejs.org/core-concepts/plugins.html#typing-new-store-properties
*/
declare module 'pinia' {
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface PiniaCustomProperties {
// add your custom properties here, if any
}
}
/*
* If not building with SSR mode, you can
* directly export the Store instantiation;
*
* The function below can be async too; either use
* async/await or return a Promise which resolves
* with the Store instance.
*/
export default defineStore((/* { ssrContext } */) => {
const pinia = createPinia();
// You can add Pinia plugins here
// pinia.use(SomePiniaPlugin)
return pinia;
});

40
app/src/stores/marital.ts Normal file
View File

@@ -0,0 +1,40 @@
// Thin compatibility wrapper: marital API now lives in the `taxstore`.
import { computed } from 'vue'
import { useTaxstore } from './taxstore'
import type { AddressOut } from 'src/components/AddressInput.vue'
import type { CommentAttachmentData } from 'src/components/CommentAttachment.vue'
export interface MaritalData {
alimentiVersati: boolean
alimentiCommenti: CommentAttachmentData
maritalStatus: string
spouseFirstName: string | number | null
spouseLastName: string | number | null
spouseBirthDate: string | number | FileList | null | undefined
spouseDeadDate: string | number | FileList | null | undefined
spouseTaxNumber: string
spouseAddress: AddressOut
marriageDate: string
separated: boolean
spouseAlimentiVersati: boolean
}
export const useMaritalStore = () => {
const store = useTaxstore()
const data = computed(() => store.getMarital())
function set(partial: Partial<MaritalData>) {
store.setMarital(partial)
}
function replace(payload: MaritalData) {
store.replaceMarital(payload)
}
function reset() {
store.resetMarital()
}
return { data, set, replace, reset }
}

View File

@@ -0,0 +1,34 @@
import { computed } from 'vue'
import { useTaxstore } from './taxstore'
import type { AddressOut } from 'src/components/AddressInput.vue'
import type { CommentAttachmentData } from 'src/components/CommentAttachment.vue'
export interface TaxpayerForm {
prevPreparedByUs: boolean
prevDeclaration: CommentAttachmentData
firstName: string
lastName: string
birthDate: string
address: AddressOut
}
// Compatibility wrapper around `taxstore` for code expecting `useTaxpayerStore()`
export const useTaxpayerStore = () => {
const store = useTaxstore()
const data = computed(() => store.getTaxpayer())
function set(partial: Partial<TaxpayerForm>) {
store.setTaxpayer(partial)
}
function replace(payload: TaxpayerForm) {
store.replaceTaxpayer(payload)
}
function reset() {
store.resetTaxpayer()
}
return { data, set, replace, reset }
}

121
app/src/stores/taxstore.ts Normal file
View File

@@ -0,0 +1,121 @@
import { defineStore } from 'pinia'
import { LocalStorage } from 'quasar'
import { type TaxpayerForm } from './taxpayer'
import { type MaritalData } from './marital'
const STORAGE_KEY = 'taxstore:v1'
const defaultTaxpayer: TaxpayerForm = {
prevPreparedByUs: false,
prevDeclaration: { comments: '', attachments: [] },
firstName: '',
lastName: '',
birthDate: '',
address: {
street: '',
cap: '',
city: '',
country: { code: '', name: '' },
canton: ''
}
}
const defaultMarital: MaritalData = {
alimentiVersati: false,
alimentiCommenti: { comments: '', attachments: [] },
maritalStatus: '',
spouseFirstName: '',
spouseLastName: '',
spouseBirthDate: '',
spouseDeadDate : '',
spouseTaxNumber: '',
spouseAddress: { street: '', cap: '', city: '', country: { code: '', name: '' }, canton: '' },
marriageDate: '',
separated: false,
spouseAlimentiVersati: false
}
export const useTaxstore = defineStore('taxstore', {
state: () => {
// try to load persisted state from Quasar LocalStorage
try {
const saved: unknown = LocalStorage.getItem(STORAGE_KEY)
if (saved && typeof saved === 'object') {
const s = saved as Record<string, unknown>
if (Array.isArray(s.items)) {
const items = s.items as Array<
| { key: 'taxpayer'; data: TaxpayerForm }
| { key: 'marital'; data: MaritalData }
>
return { items }
}
}
} catch {
// ignore parsing errors and fall back to defaults
}
return {
items: [
{ key: 'taxpayer', data: { ...defaultTaxpayer } as TaxpayerForm },
{ key: 'marital', data: { ...defaultMarital } as MaritalData }
] as Array<{ key: 'taxpayer'; data: TaxpayerForm } | { key: 'marital'; data: MaritalData }>
}
},
actions: {
// persist helper
persist() {
try {
LocalStorage.set(STORAGE_KEY, { items: this.items })
} catch {
// ignore storage errors (e.g., quota exceeded)
}
},
// Type-guard helpers
_findTaxpayer(): { key: 'taxpayer'; data: TaxpayerForm } | undefined {
return this.items.find((i): i is { key: 'taxpayer'; data: TaxpayerForm } => i.key === 'taxpayer')
},
_findMarital(): { key: 'marital'; data: MaritalData } | undefined {
return this.items.find((i): i is { key: 'marital'; data: MaritalData } => i.key === 'marital')
},
// Taxpayer-specific helpers
getTaxpayer() {
return this._findTaxpayer()?.data
},
setTaxpayer(partial: Partial<TaxpayerForm>) {
const it = this._findTaxpayer()
if (it) it.data = { ...it.data, ...partial }
this.persist()
},
replaceTaxpayer(payload: TaxpayerForm) {
const it = this._findTaxpayer()
if (it) it.data = payload
this.persist()
},
resetTaxpayer() {
const it = this._findTaxpayer()
if (it) it.data = { ...defaultTaxpayer }
this.persist()
},
// Marital-specific helpers
getMarital() {
return this._findMarital()?.data
},
setMarital(partial: Partial<MaritalData>) {
const it = this._findMarital()
if (it) it.data = { ...it.data, ...partial }
this.persist()
},
replaceMarital(payload: MaritalData) {
const it = this._findMarital()
if (it) it.data = payload
this.persist()
},
resetMarital() {
const it = this._findMarital()
if (it) it.data = { ...defaultMarital }
this.persist()
}
}
})

View File

@@ -0,0 +1,9 @@
import { defineStore } from 'pinia'
const ZERO_UUID = '00000000-0000-0000-0000-000000000000'
export const useUserstore = defineStore('userstore', {
state: () => ({
id: ZERO_UUID,
}),
})

17
app/src/types/address.ts Normal file
View File

@@ -0,0 +1,17 @@
export interface CountryRef {
code: string
name: string
}
export interface Address {
street: string
zip: string
city: string
// when present, can be either a raw code (legacy) or an object with code+localized name
country?: string | CountryRef | null
// optional Swiss canton code (e.g. 'ZH', 'BE') when applicable
canton?: string | null
foreign: boolean
}
export type PartialAddress = Partial<Address>

41
app/src/types/types.d.ts vendored Normal file
View File

@@ -0,0 +1,41 @@
// Shared app-wide types
// TypeScript descriptors for the form schema
export interface StepDescriptor {
id: string
order: number
title: string
pricing?: PricingDescriptor[]
next?: string | null
prev?: string | null
}
export interface PricingDescriptor {
amountCHF: number
condition?: Record<string, unknown>
perItem?: string
perAttachment?: boolean
perExtraAttachmentAfter?: number
}
export type EmployTypeValue = 'EMPLOYED' | 'SELF_EMPLOYED' | 'PENSIONER' | 'UNEMPLOYED'
export interface SimpleAttachmentData {
comments: string
attachments: string[]
}
export interface IncomeData {
employType: EmployTypeValue | null
attachments: {
salaryCertificate: SimpleAttachmentData
accountingDocuments: SimpleAttachmentData
avsCertificate: SimpleAttachmentData
lppCertificate: SimpleAttachmentData
unemploymentCertificate: SimpleAttachmentData
}
}

256
app/src/utils/api.ts Normal file
View File

@@ -0,0 +1,256 @@
export type ApiBaseUrl = string
export const DEFAULT_API_BASE_URL: ApiBaseUrl = 'http://localhost:8082'
export type UploadDocumentParams = {
user: string
session: string
prop?: string
file: File
baseUrl?: ApiBaseUrl
onProgress?: (fraction: number) => void
signal?: AbortSignal
}
export type LoadAttachmentsParams = {
id: string
session: string
prop?: string
baseUrl?: ApiBaseUrl
}
export type DeleteAttachmentParams = {
id: string
session: string
prop?: string
filename: string
baseUrl?: ApiBaseUrl
}
export type ApiUploadResponse = {
ok?: boolean
files?: string[]
}
export type ApiDeleteAttachmentResponse = {
ok?: boolean
deleted?: boolean
file?: string
}
export type ApiAttachmentsListResponse = Record<string, string[]>
export class ApiError extends Error {
status: number | undefined
constructor(message: string, status?: number) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
function apiUrl(baseUrl: ApiBaseUrl | undefined, path: string): string {
const base = (baseUrl || DEFAULT_API_BASE_URL).replace(/\/+$/, '')
const p = path.startsWith('/') ? path : `/${path}`
return `${base}${p}`
}
async function parseJsonSafe(response: Response): Promise<unknown> {
const text = await response.text()
if (!text) return null
try {
return JSON.parse(text) as unknown
} catch {
return text
}
}
function normalizeUploadedFiles(payload: unknown): string[] {
if (!payload || typeof payload !== 'object') return []
const files = (payload as { files?: unknown }).files
if (!files) return []
if (Array.isArray(files)) {
const out: string[] = []
for (const item of files) {
if (typeof item === 'string') {
out.push(item)
continue
}
if (item && typeof item === 'object') {
const obj = item as { storedName?: unknown; originalName?: unknown; name?: unknown }
const name =
(typeof obj.storedName === 'string' && obj.storedName) ||
(typeof obj.originalName === 'string' && obj.originalName) ||
(typeof obj.name === 'string' && obj.name) ||
''
if (name) out.push(name)
}
}
return out
}
return []
}
export async function uploadDocument(params: UploadDocumentParams): Promise<ApiUploadResponse> {
const { user, session, prop, file, baseUrl, onProgress, signal } = params
if (!user) throw new ApiError('missing user')
if (!session) throw new ApiError('missing session')
if (!file) throw new ApiError('missing file')
const url = apiUrl(baseUrl, '/upload')
return await new Promise<ApiUploadResponse>((resolve, reject) => {
const xhr = new XMLHttpRequest()
if (signal) {
if (signal.aborted) {
reject(new ApiError('aborted'))
return
}
signal.addEventListener(
'abort',
() => {
try {
xhr.abort()
} catch {
// ignore
}
},
{ once: true }
)
}
xhr.upload.onprogress = (e) => {
if (!onProgress) return
if (!e.lengthComputable) return
const fraction = e.total > 0 ? e.loaded / e.total : 0
onProgress(Math.max(0, Math.min(1, fraction)))
}
xhr.onload = () => {
const ok = xhr.status >= 200 && xhr.status < 300
let parsed: unknown = null
try {
parsed = xhr.responseText ? (JSON.parse(xhr.responseText) as unknown) : null
} catch {
parsed = xhr.responseText
}
if (!ok) {
reject(new ApiError(`Upload failed (HTTP ${xhr.status})`, xhr.status))
return
}
const files = normalizeUploadedFiles(parsed)
resolve({ ok: true, files })
}
xhr.onerror = () => {
reject(new ApiError('Upload failed (network error)'))
}
xhr.onabort = () => {
reject(new ApiError('Upload cancelled'))
}
const fd = new FormData()
fd.append('user', user)
fd.append('session', session)
if (prop) fd.append('prop', prop)
fd.append('documents', file)
xhr.open('POST', url)
xhr.send(fd)
})
}
export async function loadAttachments(params: LoadAttachmentsParams): Promise<string[]> {
const { id, session, prop, baseUrl } = params
if (!id) throw new ApiError('missing id')
if (!session) throw new ApiError('missing session')
const res = await fetch(apiUrl(baseUrl, '/loadattachments'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, session, prop }),
})
if (!res.ok) {
const body = await parseJsonSafe(res)
const msg = typeof body === 'string' ? body : `Load attachments failed (HTTP ${res.status})`
throw new ApiError(msg, res.status)
}
const data = (await res.json()) as unknown
if (Array.isArray(data) && data.every((x) => typeof x === 'string')) {
return data
}
return []
}
export async function deleteAttachment(params: DeleteAttachmentParams): Promise<ApiDeleteAttachmentResponse> {
const { id, session, prop, filename, baseUrl } = params
if (!id) throw new ApiError('missing id')
if (!session) throw new ApiError('missing session')
if (!filename) throw new ApiError('missing filename')
const res = await fetch(apiUrl(baseUrl, '/deleteattachment'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, session, prop, filename }),
})
if (!res.ok) {
const body = await parseJsonSafe(res)
const msg = typeof body === 'string' ? body : `Delete attachment failed (HTTP ${res.status})`
throw new ApiError(msg, res.status)
}
const data = (await res.json()) as unknown
return (data && typeof data === 'object' ? (data as ApiDeleteAttachmentResponse) : {})
}
export async function loadAttachmentsList(params: LoadAttachmentsParams): Promise<ApiAttachmentsListResponse> {
const { id, session, prop, baseUrl } = params
if (!id) throw new ApiError('missing id')
if (!session) throw new ApiError('missing session')
if (prop) throw new ApiError('prop is not supported for loadAttachmentsList')
const res = await fetch(apiUrl(baseUrl, '/loadattachmentslist'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, session }),
})
if (!res.ok) {
const body = await parseJsonSafe(res)
const msg = typeof body === 'string' ? body : `Load attachments list failed (HTTP ${res.status})`
throw new ApiError(msg, res.status)
}
const data = (await res.json()) as unknown
if (!data || typeof data !== 'object' || Array.isArray(data)) {
return {}
}
const result: ApiAttachmentsListResponse = {}
for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
if (!key) continue
if (Array.isArray(value) && value.every((x) => typeof x === 'string')) {
result[key] = value
}
}
return result
}