first commit
This commit is contained in:
7
app/src/App.vue
Normal file
7
app/src/App.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
//
|
||||
</script>
|
||||
15
app/src/assets/quasar-logo-vertical.svg
Normal file
15
app/src/assets/quasar-logo-vertical.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 356 360">
|
||||
<path
|
||||
d="M43.4 303.4c0 3.8-2.3 6.3-7.1 6.3h-15v-22h14.4c4.3 0 6.2 2.2 6.2 5.2 0 2.6-1.5 4.4-3.4 5 2.8.4 4.9 2.5 4.9 5.5zm-8-13H24.1v6.9H35c2.1 0 4-1.3 4-3.8 0-2.2-1.3-3.1-3.7-3.1zm5.1 12.6c0-2.3-1.8-3.7-4-3.7H24.2v7.7h11.7c3.4 0 4.6-1.8 4.6-4zm36.3 4v2.7H56v-22h20.6v2.7H58.9v6.8h14.6v2.3H58.9v7.5h17.9zm23-5.8v8.5H97v-8.5l-11-13.4h3.4l8.9 11 8.8-11h3.4l-10.8 13.4zm19.1-1.8V298c0-7.9 5.2-10.7 12.7-10.7 7.5 0 13 2.8 13 10.7v1.4c0 7.9-5.5 10.8-13 10.8s-12.7-3-12.7-10.8zm22.7 0V298c0-5.7-3.9-8-10-8-6 0-9.8 2.3-9.8 8v1.4c0 5.8 3.8 8.1 9.8 8.1 6 0 10-2.3 10-8.1zm37.2-11.6v21.9h-2.9l-15.8-17.9v17.9h-2.8v-22h3l15.6 18v-18h2.9zm37.9 10.2v1.3c0 7.8-5.2 10.4-12.4 10.4H193v-22h11.2c7.2 0 12.4 2.8 12.4 10.3zm-3 0c0-5.3-3.3-7.6-9.4-7.6h-8.4V307h8.4c6 0 9.5-2 9.5-7.7V298zm50.8-7.6h-9.7v19.3h-3v-19.3h-9.7v-2.6h22.4v2.6zm34.4-2.6v21.9h-3v-10.1h-16.8v10h-2.8v-21.8h2.8v9.2H296v-9.2h2.9zm34.9 19.2v2.7h-20.7v-22h20.6v2.7H316v6.8h14.5v2.3H316v7.5h17.8zM24 340.2v7.3h13.9v2.4h-14v9.6H21v-22h20v2.7H24zm41.5 11.4h-9.8v7.9H53v-22h13.3c5.1 0 8 1.9 8 6.8 0 3.7-2 6.3-5.6 7l6 8.2h-3.3l-5.8-8zm-9.8-2.6H66c3.1 0 5.3-1.5 5.3-4.7 0-3.3-2.2-4.1-5.3-4.1H55.7v8.8zm47.9 6.2H89l-2 4.3h-3.2l10.7-22.2H98l10.7 22.2h-3.2l-2-4.3zm-1-2.3l-6.3-13-6 13h12.2zm46.3-15.3v21.9H146v-17.2L135.7 358h-2.1l-10.2-15.6v17h-2.8v-21.8h3l11 16.9 11.3-17h3zm35 19.3v2.6h-20.7v-22h20.6v2.7H166v6.8h14.5v2.3H166v7.6h17.8zm47-19.3l-8.3 22h-3l-7.1-18.6-7 18.6h-3l-8.2-22h3.3L204 356l6.8-18.5h3.4L221 356l6.6-18.5h3.3zm10 11.6v-1.4c0-7.8 5.2-10.7 12.7-10.7 7.6 0 13 2.9 13 10.7v1.4c0 7.9-5.4 10.8-13 10.8-7.5 0-12.7-3-12.7-10.8zm22.8 0v-1.4c0-5.7-4-8-10-8s-9.9 2.3-9.9 8v1.4c0 5.8 3.8 8.2 9.8 8.2 6.1 0 10-2.4 10-8.2zm28.3 2.4h-9.8v7.9h-2.8v-22h13.2c5.2 0 8 1.9 8 6.8 0 3.7-2 6.3-5.6 7l6 8.2h-3.3l-5.8-8zm-9.8-2.6h10.2c3 0 5.2-1.5 5.2-4.7 0-3.3-2.1-4.1-5.2-4.1h-10.2v8.8zm40.3-1.5l-6.8 5.6v6.4h-2.9v-22h2.9v12.3l15.2-12.2h3.7l-9.9 8.1 10.3 13.8h-3.6l-8.9-12z" />
|
||||
<path fill="#050A14"
|
||||
d="M188.4 71.7a10.4 10.4 0 01-20.8 0 10.4 10.4 0 1120.8 0zM224.2 45c-2.2-3.9-5-7.5-8.2-10.7l-12 7c-3.7-3.2-8-5.7-12.6-7.3a49.4 49.4 0 00-9.7 13.9 59 59 0 0140.1 14l7.6-4.4a57 57 0 00-5.2-12.5zM178 125.1c4.5 0 9-.6 13.4-1.7v-14a40 40 0 0012.5-7.2 47.7 47.7 0 00-7.1-15.3 59 59 0 01-32.2 27.7v8.7c4.4 1.2 8.9 1.8 13.4 1.8zM131.8 45c-2.3 4-4 8.1-5.2 12.5l12 7a40 40 0 000 14.4c5.7 1.5 11.3 2 16.9 1.5a59 59 0 01-8-41.7l-7.5-4.3c-3.2 3.2-6 6.7-8.2 10.6z" />
|
||||
<path fill="#00B4FF"
|
||||
d="M224.2 98.4c2.3-3.9 4-8 5.2-12.4l-12-7a40 40 0 000-14.5c-5.7-1.5-11.3-2-16.9-1.5a59 59 0 018 41.7l7.5 4.4c3.2-3.2 6-6.8 8.2-10.7zm-92.4 0c2.2 4 5 7.5 8.2 10.7l12-7a40 40 0 0012.6 7.3c4-4.1 7.3-8.8 9.7-13.8a59 59 0 01-40-14l-7.7 4.4c1.2 4.3 3 8.5 5.2 12.4zm46.2-80c-4.5 0-9 .5-13.4 1.7V34a40 40 0 00-12.5 7.2c1.5 5.7 4 10.8 7.1 15.4a59 59 0 0132.2-27.7V20a53.3 53.3 0 00-13.4-1.8z" />
|
||||
<path fill="#00B4FF"
|
||||
d="M178 9.2a62.6 62.6 0 11-.1 125.2A62.6 62.6 0 01178 9.2m0-9.2a71.7 71.7 0 100 143.5A71.7 71.7 0 00178 0z" />
|
||||
<path fill="#050A14"
|
||||
d="M96.6 212v4.3c-9.2-.8-15.4-5.8-15.4-17.8V180h4.6v18.4c0 8.6 4 12.6 10.8 13.5zm16-31.9v18.4c0 8.9-4.3 12.8-10.9 13.5v4.4c9.2-.7 15.5-5.6 15.5-18v-18.3h-4.7zM62.2 199v-2.2c0-12.7-8.8-17.4-21-17.4-12.1 0-20.7 4.7-20.7 17.4v2.2c0 12.8 8.6 17.6 20.7 17.6 1.5 0 3-.1 4.4-.3l11.8 6.2 2-3.3-8.2-4-6.4-3.1a32 32 0 01-3.6.2c-9.8 0-16-3.9-16-13.3v-2.2c0-9.3 6.2-13.1 16-13.1 9.9 0 16.3 3.8 16.3 13.1v2.2c0 5.3-2.1 8.7-5.6 10.8l4.8 2.4c3.4-2.8 5.5-7 5.5-13.2zM168 215.6h5.1L156 179.7h-4.8l17 36zM143 205l7.4-15.7-2.4-5-15.1 31.4h5.1l3.3-7h18.3l-1.8-3.7H143zm133.7 10.7h5.2l-17.3-35.9h-4.8l17 36zm-25-10.7l7.4-15.7-2.4-5-15.1 31.4h5.1l3.3-7h18.3l-1.7-3.7h-14.8zm73.8-2.5c6-1.2 9-5.4 9-11.4 0-8-4.5-10.9-12.9-10.9h-21.4v35.5h4.6v-31.3h16.5c5 0 8.5 1.4 8.5 6.7 0 5.2-3.5 7.7-8.5 7.7h-11.4v4.1h10.7l9.3 12.8h5.5l-9.9-13.2zm-117.4 9.9c-9.7 0-14.7-2.5-18.6-6.3l-2.2 3.8c5.1 5 11 6.7 21 6.7 1.6 0 3.1-.1 4.6-.3l-1.9-4h-3zm18.4-7c0-6.4-4.7-8.6-13.8-9.4l-10.1-1c-6.7-.7-9.3-2.2-9.3-5.6 0-2.5 1.4-4 4.6-5l-1.8-3.8c-4.7 1.4-7.5 4.2-7.5 8.9 0 5.2 3.4 8.7 13 9.6l11.3 1.2c6.4.6 8.9 2 8.9 5.4 0 2.7-2.1 4.7-6 5.8l1.8 3.9c5.3-1.6 8.9-4.7 8.9-10zm-20.3-21.9c7.9 0 13.3 1.8 18.1 5.7l1.8-3.9a30 30 0 00-19.6-5.9c-2 0-4 .1-5.7.3l1.9 4 3.5-.2z" />
|
||||
<path fill="#00B4FF"
|
||||
d="M.5 251.9c29.6-.5 59.2-.8 88.8-1l88.7-.3 88.7.3 44.4.4 44.4.6-44.4.6-44.4.4-88.7.3-88.7-.3a7981 7981 0 01-88.8-1z" />
|
||||
<path fill="none" d="M-565.2 324H-252v15.8h-313.2z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
0
app/src/boot/.gitkeep
Normal file
0
app/src/boot/.gitkeep
Normal file
33
app/src/boot/i18n.ts
Normal file
33
app/src/boot/i18n.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { defineBoot } from '#q-app/wrappers';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
|
||||
import messages from 'src/i18n';
|
||||
|
||||
export type MessageLanguages = keyof typeof messages;
|
||||
// Use a permissive message schema so different locales may contain different string values
|
||||
export type MessageSchema = Record<string, unknown>;
|
||||
|
||||
// See https://vue-i18n.intlify.dev/guide/advanced/typescript.html#global-resource-schema-type-definition
|
||||
/* eslint-disable @typescript-eslint/no-empty-object-type */
|
||||
declare module 'vue-i18n' {
|
||||
// define the locale messages schema
|
||||
export interface DefineLocaleMessage extends MessageSchema {}
|
||||
|
||||
// define the datetime format schema
|
||||
export interface DefineDateTimeFormat {}
|
||||
|
||||
// define the number format schema
|
||||
export interface DefineNumberFormat {}
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-empty-object-type */
|
||||
|
||||
export default defineBoot(({ app }) => {
|
||||
const i18n = createI18n<{ message: MessageSchema }, MessageLanguages>({
|
||||
locale: 'it-IT',
|
||||
legacy: false,
|
||||
messages,
|
||||
});
|
||||
|
||||
// Set i18n instance on app
|
||||
app.use(i18n);
|
||||
});
|
||||
126
app/src/components/AddressInput.vue
Normal file
126
app/src/components/AddressInput.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="row items-center q-gutter-sm q-mb-sm q-mt-md">
|
||||
<div class="col-auto">
|
||||
<q-btn dense flat round icon="edit" @click="open">
|
||||
<q-tooltip class="bg-primary text-white">{{ t('children.editAddress') }}</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="text-caption">{{ label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="q-pa-sm bg-grey-2 q-mb-sm">
|
||||
<div v-if="isEmpty" class="text-negative">{{ hintText }}</div>
|
||||
<div v-else>{{ formatted }}</div>
|
||||
</div>
|
||||
|
||||
<AddressModal v-model="isOpen" :modelAddress="modalModel" :allowForeign="allowForeign" @save="onSave" @cancel="onCancel" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AddressModal from './AddressModal.vue'
|
||||
import type { Address as ModalAddress } from '../types/address'
|
||||
|
||||
// public Address shape required by the user
|
||||
export interface AddressOut {
|
||||
street: string
|
||||
cap: string | number
|
||||
city: string
|
||||
country: { code: string; name: string }
|
||||
canton: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: AddressOut | null
|
||||
label?: string
|
||||
allowForeign?: boolean
|
||||
hint?: string
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: AddressOut | null): void
|
||||
(e: 'save', v: AddressOut): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const modalModel = ref<ModalAddress | null>(null)
|
||||
|
||||
const label = props.label || t('address')
|
||||
const allowForeign = props.allowForeign ?? true
|
||||
|
||||
const formatted = computed(() => {
|
||||
const v = props.modelValue
|
||||
console.log('address input formatted', v)
|
||||
if (!v) return ''
|
||||
const countryName = v.country?.name || ''
|
||||
if (v.country.code === 'CH' ) {
|
||||
return [v.street, String(v.cap || ''), v.city, v.canton].filter(Boolean).join(', ')
|
||||
}
|
||||
return [v.street, String(v.cap || ''), v.city, countryName].filter(Boolean).join(', ')
|
||||
})
|
||||
|
||||
const isEmpty = computed(() => {
|
||||
const v = props.modelValue
|
||||
if (!v) return true
|
||||
// consider address empty when no meaningful fields are present
|
||||
return !(v.street || v.city || v.cap || (v.country && v.country.code))
|
||||
})
|
||||
|
||||
const hintText = computed(() => props.hint || t('validation.insertAddress'))
|
||||
|
||||
function open() {
|
||||
const v = props.modelValue
|
||||
modalModel.value = v
|
||||
? {
|
||||
street: v.street || '',
|
||||
zip: String(v.cap || ''),
|
||||
city: v.city || '',
|
||||
country: (v.country && v.country.code) || '',
|
||||
canton: v.canton || '',
|
||||
foreign: (v.country && v.country.code && v.country.code !== 'CH') ? true : false
|
||||
}
|
||||
: { street: '', zip: '', city: '', country: '', canton: '', foreign: true }
|
||||
isOpen.value = true
|
||||
}
|
||||
|
||||
function onSave(a: ModalAddress) {
|
||||
// normalize country to object {code,name}
|
||||
let countryObj = { code: '', name: '' }
|
||||
if (!a.country) {
|
||||
countryObj = { code: '', name: '' }
|
||||
} else if (typeof a.country === 'string') {
|
||||
countryObj = { code: a.country, name: a.country }
|
||||
} else if (typeof a.country === 'object' && a.country !== null) {
|
||||
type CountryRef = { code?: string; name?: string }
|
||||
const c = a.country as CountryRef
|
||||
countryObj = { code: c.code || '', name: c.name || '' }
|
||||
}
|
||||
|
||||
const out: AddressOut = {
|
||||
street: a.street || '',
|
||||
cap: a.zip || '',
|
||||
city: a.city || '',
|
||||
country: countryObj,
|
||||
canton: a.canton || ''
|
||||
}
|
||||
|
||||
emit('update:modelValue', out)
|
||||
emit('save', out)
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
emit('cancel')
|
||||
isOpen.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.q-card { width: 100%; margin: 0; }
|
||||
</style>
|
||||
323
app/src/components/AddressModal.vue
Normal file
323
app/src/components/AddressModal.vue
Normal file
@@ -0,0 +1,323 @@
|
||||
<template>
|
||||
<q-dialog v-model="visible" persistent>
|
||||
<q-card class="contained-card">
|
||||
<q-card-section>
|
||||
<div class="text-h6">{{ title }}</div>
|
||||
|
||||
<q-form ref="formRef" class="q-gutter-md q-mt-md">
|
||||
<q-input v-model="draft.street" :label="t('address.street')" :rules="[required()]" />
|
||||
|
||||
<div v-if="!draft.foreign">
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<div class="col-4">
|
||||
<q-select
|
||||
hide-selected
|
||||
fill-input
|
||||
v-model="selectedSwissZip"
|
||||
:options="swissOptions"
|
||||
option-label="label"
|
||||
option-value="zip"
|
||||
:label="t('address.zip')"
|
||||
:input-attrs="{ inputmode: 'numeric', maxlength: 4 }"
|
||||
@input-value="onSwissZipInputValue"
|
||||
use-input
|
||||
input-debounce="200"
|
||||
emit-value
|
||||
map-options
|
||||
:rules="[required()]"
|
||||
@filter="filterCapFn"
|
||||
@blur="onZipBlur"
|
||||
/>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-input v-model="draft.city" :label="t('address.city')" :rules="[required()]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="row items-center q-gutter-sm" v-else>
|
||||
<q-input class="col-4" v-model="draft.zip" :label="t('address.zip')" :rules="[required(), zipRule]" :input-attrs="{ inputmode: 'numeric', maxlength: 4 }" @input-value="onZipInputValue" />
|
||||
<q-input class="col" v-model="draft.city" :label="t('address.city')" :rules="[required()]" />
|
||||
</div>
|
||||
<q-select
|
||||
v-if="draft.foreign"
|
||||
v-model="draft.country"
|
||||
:options="countryOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
:label="t('address.country')"
|
||||
use-input
|
||||
input-debounce="200"
|
||||
emit-value
|
||||
map-options
|
||||
:rules="[required()]"
|
||||
/>
|
||||
<q-toggle v-if="allowForeign" v-model="draft.foreign" :label="t('address.foreign')" />
|
||||
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat :label="t('button.cancel')" @click="onCancel" />
|
||||
<q-btn color="primary" :label="t('button.save')" @click="onSave" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch, nextTick, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { Address } from '../types/address'
|
||||
|
||||
function filterCapFn (val: string, update: (fn: () => void) => void) {
|
||||
// allow only digits and max 4 characters when filtering
|
||||
update(() => {
|
||||
const q = String(val || '').replace(/\D/g, '').slice(0, 4)
|
||||
const hits = findPostalCodes(q)
|
||||
swissOptionsRef.value = hits.map(z => ({ zip: z, label: z }))
|
||||
})
|
||||
}
|
||||
|
||||
import { toRefs } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: boolean
|
||||
modelAddress?: Partial<Address> | null
|
||||
title?: string
|
||||
allowForeign?: boolean
|
||||
}>(), { allowForeign: true })
|
||||
const { allowForeign } = toRefs(props)
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void
|
||||
(e: 'save', payload: Address): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const visible = ref(!!props.modelValue)
|
||||
watch(() => props.modelValue, v => (visible.value = !!v))
|
||||
watch(visible, v => {
|
||||
emit('update:modelValue', v)
|
||||
if (v) populateFromModel()
|
||||
})
|
||||
|
||||
function populateFromModel() {
|
||||
const src = props.modelAddress || {}
|
||||
Object.assign(draft, { ...defaultAddress(), ...src })
|
||||
if (hasCountryCode(src.country)) {
|
||||
draft.country = src.country.code || ''
|
||||
}
|
||||
// if foreign addresses are not allowed, ensure foreign is false
|
||||
if (!allowForeign.value) draft.foreign = false
|
||||
// sanitize zip and limit to 4 digits
|
||||
if (draft.zip) {
|
||||
const s = String(draft.zip || '').replace(/\D/g, '').slice(0, 4)
|
||||
draft.zip = s
|
||||
if (!draft.foreign && s.length) {
|
||||
if (ALL_CH_POSTAL_CODES.includes(s)) {
|
||||
selectedSwissZip.value = s
|
||||
}
|
||||
if (s.length >= 4) {
|
||||
const recs = findPostalCodeDetails(s)
|
||||
if (recs && recs.length) {
|
||||
const rec = recs[0]
|
||||
draft.city = rec?.placeName || ''
|
||||
draft.zip = rec?.postalCode || ''
|
||||
draft.canton = rec?.cantonCode || null
|
||||
selectedSwissZip.value = rec?.postalCode || ''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const defaultAddress = (): Address => ({ street: '', zip: '', city: '', country: '', canton: '', foreign: false })
|
||||
// keep draft.country as string when editing; on save we'll convert to {code,name}
|
||||
const draft = reactive<Address>({ ...defaultAddress(), ...(props.modelAddress || {}) })
|
||||
function hasCountryCode(x: unknown): x is { code: string } {
|
||||
return typeof x === 'object' && x !== null && Object.prototype.hasOwnProperty.call(x, 'code')
|
||||
}
|
||||
|
||||
if (props.modelAddress && hasCountryCode(props.modelAddress.country)) {
|
||||
draft.country = props.modelAddress.country.code || ''
|
||||
}
|
||||
|
||||
const formRef = ref<{ validate?: () => Promise<boolean> | boolean; resetValidation?: () => void } | null>(null)
|
||||
|
||||
function required() {
|
||||
const fallback = t('validation.required') || 'Required'
|
||||
return (v: unknown) => {
|
||||
if (v === null || v === undefined) return fallback
|
||||
if (typeof v === 'string') return (v.trim() !== '') || fallback
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const zipRule = (v: unknown) => {
|
||||
if (v === null || v === undefined || v === '') return t('validation.required') || 'Required'
|
||||
if (typeof v !== 'string' && typeof v !== 'number') return t('validation.invalidZip') || 'Invalid ZIP'
|
||||
const s = String(v).trim()
|
||||
if (/^\d{1,4}$/.test(s)) return true
|
||||
return t('validation.invalidZip') || 'Invalid ZIP'
|
||||
}
|
||||
|
||||
watch(() => props.modelAddress, (n) => {
|
||||
const src = n as Partial<Address> | undefined
|
||||
Object.assign(draft, { ...defaultAddress(), ...(src || {}) })
|
||||
if (hasCountryCode(src?.country)) {
|
||||
draft.country = src?.country?.code || ''
|
||||
}
|
||||
if (src && Object.prototype.hasOwnProperty.call(src, 'canton')) {
|
||||
draft.canton = src.canton || ''
|
||||
}
|
||||
void nextTick(() => formRef.value?.resetValidation?.())
|
||||
// ensure selections are populated from the new model
|
||||
populateFromModel()
|
||||
}, { deep: true })
|
||||
|
||||
// if allowForeign changes to false, force draft.foreign = false
|
||||
watch(allowForeign, (v) => {
|
||||
if (!v) draft.foreign = false
|
||||
})
|
||||
|
||||
async function onSave() {
|
||||
try {
|
||||
const ok = await (formRef.value?.validate?.() ?? true)
|
||||
if (ok === false) return
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
// build saved address: include localized country name + code when foreign
|
||||
const out: Address = { ...draft }
|
||||
if (draft.foreign) {
|
||||
const code = typeof draft.country === 'string' ? draft.country : ''
|
||||
const label = countryOptions.value.find(o => o.value === code)?.label || code
|
||||
out.country = code ? { code, name: label } : ''
|
||||
out.canton = ''
|
||||
} else {
|
||||
// non-foreign: explicitly set Switzerland as country ref and persist canton
|
||||
try {
|
||||
const dn = new Intl.DisplayNames([String(locale.value)], { type: 'region' })
|
||||
out.country = { code: 'CH', name: dn.of('CH') || 'Switzerland' }
|
||||
} catch {
|
||||
out.country = { code: 'CH', name: 'Switzerland' }
|
||||
}
|
||||
out.canton = draft.canton || ''
|
||||
}
|
||||
emit('save', out)
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
emit('cancel')
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
import { COUNTRY_CODES, findPostalCodes, findPostalCodeDetails, ALL_CH_POSTAL_CODES } from '../data/countryCodes'
|
||||
|
||||
const countryOptions = computed(() => {
|
||||
try {
|
||||
// Intl.DisplayNames with type 'region' localizes country names
|
||||
const dn = new Intl.DisplayNames([String(locale.value)], { type: 'region' })
|
||||
return COUNTRY_CODES.map(code => ({ value: code, label: dn.of(code) || code }))
|
||||
} catch {
|
||||
return COUNTRY_CODES.map(code => ({ value: code, label: code }))
|
||||
}
|
||||
})
|
||||
|
||||
// SWISS_CITIES now imported from src/data/countryCodes
|
||||
|
||||
const swissOptions = computed(() => swissOptionsRef.value)
|
||||
// Options for the ZIP select are only the list of postal codes (no city labels)
|
||||
const swissOptionsRef = ref(ALL_CH_POSTAL_CODES.map(z => ({ zip: z, label: z })))
|
||||
|
||||
const selectedSwissZip = ref<string | null>(null)
|
||||
|
||||
function onZipInputValue (val: string) {
|
||||
// sanitize input to digits only and max length 4
|
||||
const s = String(val || '').replace(/\D/g, '').slice(0, 4)
|
||||
if (draft.zip !== s) draft.zip = s
|
||||
}
|
||||
|
||||
function onSwissZipInputValue (val: string) {
|
||||
// sanitize incoming input from the q-select text field
|
||||
const s = String(val || '').replace(/\D/g, '').slice(0, 4)
|
||||
// update options shown
|
||||
swissOptionsRef.value = findPostalCodes(s).map(z => ({ zip: z, label: z }))
|
||||
// keep draft.zip in sync so watcher can fill city when 4 digits reached
|
||||
if (!draft.foreign) {
|
||||
if (draft.zip !== s) draft.zip = s
|
||||
if (s.length >= 4) {
|
||||
const recs = findPostalCodeDetails(s)
|
||||
if (recs && recs.length) {
|
||||
const rec = recs[0]
|
||||
draft.zip = rec?.postalCode || ""
|
||||
draft.city = rec?.placeName || ""
|
||||
draft.canton = rec?.cantonCode || ''
|
||||
selectedSwissZip.value = rec?.postalCode || ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep draft.zip in sync with selection; populate city on blur
|
||||
watch(selectedSwissZip, (z) => {
|
||||
if (!z) return
|
||||
const rec = findPostalCodeDetails(z)[0]
|
||||
if (rec) {
|
||||
draft.zip = rec.postalCode
|
||||
// populate city and canton only on blur to avoid aggressive overriding
|
||||
draft.canton = rec.cantonCode || ''
|
||||
}
|
||||
})
|
||||
|
||||
function onZipBlur() {
|
||||
const z = selectedSwissZip.value
|
||||
if (!z) return
|
||||
const recs = findPostalCodeDetails(z)
|
||||
if (recs && recs.length) {
|
||||
const rec = recs[0]
|
||||
draft.zip = rec?.postalCode || ""
|
||||
draft.city = rec?.placeName || ""
|
||||
draft.canton = rec?.cantonCode || ''
|
||||
}
|
||||
}
|
||||
|
||||
// initialize selectedSwissZip from draft.zip when appropriate
|
||||
// initialize selectedSwissZip from draft.zip when appropriate
|
||||
watch(() => draft.zip, (z) => {
|
||||
if (!draft.foreign && z) {
|
||||
// sanitize to digits and max 4
|
||||
const s = String(z || '').replace(/\D/g, '').slice(0, 4)
|
||||
if (s !== z) {
|
||||
draft.zip = s
|
||||
return
|
||||
}
|
||||
if (ALL_CH_POSTAL_CODES.includes(s)) selectedSwissZip.value = s
|
||||
// when the user types a full 4-digit ZIP, auto-fill city from dataset
|
||||
if (s.length >= 4) {
|
||||
const recs = findPostalCodeDetails(s)
|
||||
if (recs && recs.length) {
|
||||
const rec = recs[0]
|
||||
draft.city = rec?.placeName || ''
|
||||
draft.zip = rec?.postalCode || s
|
||||
selectedSwissZip.value = rec?.postalCode || s
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const title = props.title || t('address.modalTitle') || t('address.title') || 'Address'
|
||||
|
||||
// If the modal is already open or modelAddress already provided at mount,
|
||||
// ensure the form is populated once.
|
||||
void nextTick(() => {
|
||||
if (props.modelValue || props.modelAddress) populateFromModel()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.contained-card { min-width: 420px; max-width: 720px }
|
||||
</style>
|
||||
391
app/src/components/CommentAttachment.vue
Normal file
391
app/src/components/CommentAttachment.vue
Normal file
@@ -0,0 +1,391 @@
|
||||
<template>
|
||||
<div class="comment-attachment column full-width shadow-1 q-pa-sm bg-white" :data-id="id">
|
||||
<div>{{ title || label }}</div>
|
||||
<div class="row col q-px-md">
|
||||
<q-input
|
||||
class="full-width"
|
||||
type="textarea"
|
||||
v-model="comments"
|
||||
@blur="onCommentsBlur"
|
||||
:label="t('commenti')"
|
||||
autogrow
|
||||
dense
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="row q-ml-md full-width items-center no-wrap">
|
||||
<div class="col q-ml-md">
|
||||
<q-file
|
||||
ref="fileRef"
|
||||
:model-value="files"
|
||||
@update:model-value="updateFiles"
|
||||
accept=".pdf,.docx,.txt,.md"
|
||||
dense
|
||||
borderless
|
||||
hide-bottom-space
|
||||
:clearable="false"
|
||||
input-style="display: none"
|
||||
style="max-width: 400px"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<div class="row items-center no-wrap full-width">
|
||||
<template v-if="!files">
|
||||
<q-btn
|
||||
round
|
||||
dense
|
||||
flat
|
||||
color="primary"
|
||||
icon="attach_file"
|
||||
:disable="isUploading"
|
||||
@click.stop="fileRef?.pickFiles()"
|
||||
>
|
||||
<q-tooltip>{{ t('pickFiles') }}</q-tooltip>
|
||||
</q-btn>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<q-btn
|
||||
round
|
||||
dense
|
||||
flat
|
||||
color="primary"
|
||||
icon="cloud_upload"
|
||||
:disable="!canUpload || isUploading"
|
||||
:loading="isUploading"
|
||||
@click.stop="upload"
|
||||
/>
|
||||
<q-btn
|
||||
v-if="!isUploading"
|
||||
round
|
||||
dense
|
||||
flat
|
||||
color="primary"
|
||||
icon="delete"
|
||||
:disable="!files"
|
||||
@click.stop.prevent="cancelSelection"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div class="comment-attachment__file-name q-px-sm" v-if="fileLabel">
|
||||
{{ fileLabel }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</q-file>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row items-start q-gutter-sm q-ml-md q-mt-xs q-mb-none full-width">
|
||||
<div class="row col q-pa-none q-ma-none q-ml-md">
|
||||
<div class="text-caption q-mb-sm q-mt-none ">
|
||||
{{ t('attachments')}}
|
||||
</div>
|
||||
<div class="column items-center q-gutter-sm full-width">
|
||||
<q-chip class="full-width" v-for="(f, i) in local.attachments" :key="i" removable @remove="removeFile(i)">{{ f }}</q-chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useQuasar, type QFile } from 'quasar'
|
||||
import { useUserstore } from '../stores/userstore'
|
||||
|
||||
export interface CommentAttachmentData {
|
||||
comments: string
|
||||
attachments: string[]
|
||||
}
|
||||
|
||||
type Payload = CommentAttachmentData
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: Partial<Payload> | null
|
||||
label?: string
|
||||
title?: string
|
||||
id?: string
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: Payload | null): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const $q = useQuasar()
|
||||
const userStore = useUserstore()
|
||||
const fileRef = ref<QFile | null>(null)
|
||||
const initialAttachments = props.modelValue?.attachments
|
||||
|
||||
const local = reactive<Payload>({
|
||||
comments: props.modelValue?.comments || '',
|
||||
attachments: Array.isArray(initialAttachments) ? [...initialAttachments] : [],
|
||||
})
|
||||
|
||||
const comments = ref(local.comments)
|
||||
|
||||
type UploadProgressItem = {
|
||||
percent: number
|
||||
color: string
|
||||
icon: string
|
||||
xhr?: XMLHttpRequest
|
||||
}
|
||||
|
||||
const files = ref<File | null>(null)
|
||||
const isUploading = ref(false)
|
||||
const uploadProgress = ref<UploadProgressItem[]>([])
|
||||
|
||||
const allowedExtensions = new Set(['pdf', 'docx', 'txt', 'md'])
|
||||
|
||||
function isAllowedFile(file: File): boolean {
|
||||
const name = (file.name || '').trim().toLowerCase()
|
||||
const ext = name.includes('.') ? name.split('.').pop() : ''
|
||||
return !!ext && allowedExtensions.has(ext)
|
||||
}
|
||||
|
||||
const canUpload = computed(() => !!files.value)
|
||||
|
||||
const fileLabel = computed(() => {
|
||||
const current = files.value
|
||||
return current?.name || ''
|
||||
})
|
||||
|
||||
// const emptyProgress: UploadProgressItem = { percent: 0, color: 'primary', icon: 'attach_file' }
|
||||
|
||||
function ensureProgress(index: number): UploadProgressItem {
|
||||
while (uploadProgress.value.length <= index) {
|
||||
uploadProgress.value.push({ percent: 0, color: 'primary', icon: 'attach_file' })
|
||||
}
|
||||
const item = uploadProgress.value[index]
|
||||
if (!item) {
|
||||
const fallback = { percent: 0, color: 'primary', icon: 'attach_file' } satisfies UploadProgressItem
|
||||
uploadProgress.value[index] = fallback
|
||||
return fallback
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
function emitUpdated() {
|
||||
emit('update:modelValue', {
|
||||
comments: local.comments || '',
|
||||
attachments: [...local.attachments],
|
||||
})
|
||||
}
|
||||
|
||||
function updateFiles(val: File | null) {
|
||||
if (val && !isAllowedFile(val)) {
|
||||
files.value = null
|
||||
isUploading.value = false
|
||||
uploadProgress.value = []
|
||||
return
|
||||
}
|
||||
|
||||
files.value = val
|
||||
isUploading.value = false
|
||||
|
||||
uploadProgress.value = val
|
||||
? [{ percent: 0, color: 'primary', icon: 'attach_file' }]
|
||||
: []
|
||||
}
|
||||
|
||||
function cancelFile(index: number) {
|
||||
const currentProgress = uploadProgress.value
|
||||
const progress = currentProgress[index]
|
||||
if (progress?.xhr && progress.percent < 1) {
|
||||
try {
|
||||
progress.xhr.abort()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// single file selection
|
||||
files.value = null
|
||||
|
||||
const nextProgress = [...currentProgress]
|
||||
nextProgress.splice(index, 1)
|
||||
uploadProgress.value = nextProgress
|
||||
}
|
||||
|
||||
function cancelSelection() {
|
||||
cancelFile(0)
|
||||
}
|
||||
|
||||
async function upload() {
|
||||
const file = files.value
|
||||
if (!file) return
|
||||
if (!isAllowedFile(file)) return
|
||||
|
||||
isUploading.value = true
|
||||
|
||||
const uploadUrl = 'http://localhost:8082/upload'
|
||||
await new Promise<void>((resolve) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
const p = ensureProgress(0)
|
||||
p.xhr = xhr
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (!e.lengthComputable) return
|
||||
const prog = e.total > 0 ? e.loaded / e.total : 0
|
||||
ensureProgress(0).percent = Math.max(0, Math.min(1, prog))
|
||||
}
|
||||
|
||||
xhr.onload = () => {
|
||||
const ok = xhr.status >= 200 && xhr.status < 300
|
||||
const p = ensureProgress(0)
|
||||
p.percent = ok ? 1 : p.percent
|
||||
p.color = ok ? 'positive' : 'negative'
|
||||
p.icon = ok ? 'check' : 'error'
|
||||
|
||||
if (ok) {
|
||||
try {
|
||||
const parsed = JSON.parse(xhr.responseText) as {
|
||||
files?: Array<{ storedName?: string; originalName?: string; name?: string }>
|
||||
}
|
||||
const first = Array.isArray(parsed.files) ? parsed.files[0] : undefined
|
||||
const stored = first?.storedName || first?.originalName || first?.name
|
||||
local.attachments.push(stored || file.name)
|
||||
} catch {
|
||||
local.attachments.push(file.name)
|
||||
}
|
||||
emitUpdated()
|
||||
}
|
||||
|
||||
resolve()
|
||||
}
|
||||
|
||||
xhr.onerror = () => {
|
||||
const p = ensureProgress(0)
|
||||
p.color = 'negative'
|
||||
p.icon = 'error'
|
||||
resolve()
|
||||
}
|
||||
|
||||
xhr.onabort = () => {
|
||||
const p = ensureProgress(0)
|
||||
p.color = 'warning'
|
||||
p.icon = 'close'
|
||||
resolve()
|
||||
}
|
||||
|
||||
const fd = new FormData()
|
||||
fd.append('session', props.id ?? '')
|
||||
fd.append('user', userStore.id)
|
||||
fd.append('documents', file)
|
||||
xhr.open('POST', uploadUrl)
|
||||
xhr.send(fd)
|
||||
})
|
||||
|
||||
// Clear selection after upload; keep uploaded names in local.attachments
|
||||
files.value = null
|
||||
uploadProgress.value = []
|
||||
isUploading.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(v) => {
|
||||
if (v && typeof v.comments === 'string') {
|
||||
local.comments = v.comments
|
||||
} else if (v === null) {
|
||||
local.comments = ''
|
||||
}
|
||||
|
||||
// modelValue is Partial<Payload>: don't wipe local attachments if the parent omits the field
|
||||
if (v && Array.isArray(v.attachments)) {
|
||||
local.attachments = [...v.attachments]
|
||||
} else if (v === null) {
|
||||
local.attachments = []
|
||||
}
|
||||
|
||||
comments.value = local.comments
|
||||
}
|
||||
)
|
||||
|
||||
function onCommentsBlur() {
|
||||
local.comments = comments.value || ''
|
||||
emitUpdated()
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function confirmDeleteAttachment(filename: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
$q.dialog({
|
||||
message: "<h6 class='q-my-sm' >" + t('confirmDeleteAttachment') + " </h6> " + filename,
|
||||
cancel: true,
|
||||
persistent: true,
|
||||
html: true
|
||||
})
|
||||
.onOk(() => resolve(true))
|
||||
.onCancel(() => resolve(false))
|
||||
.onDismiss(() => resolve(false))
|
||||
})
|
||||
}
|
||||
|
||||
async function removeFile(index: number) {
|
||||
const filename = local.attachments[index]
|
||||
if (!filename) return
|
||||
|
||||
const confirmed = await confirmDeleteAttachment(filename)
|
||||
if (!confirmed) return
|
||||
|
||||
const id = userStore.id
|
||||
const session = props.id
|
||||
|
||||
// If we don't have enough context to delete on the server, just remove locally.
|
||||
if (!id || !session) {
|
||||
local.attachments.splice(index, 1)
|
||||
emitUpdated()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('http://localhost:8082/deleteattachment', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, session, filename }),
|
||||
})
|
||||
if (!res.ok) return
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
local.attachments.splice(index, 1)
|
||||
emitUpdated()
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((x) => typeof x === 'string')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// session comes from component prop id; user id comes from userstore
|
||||
const session = props.id
|
||||
const id = userStore.id
|
||||
if (!session || !id) return
|
||||
|
||||
try {
|
||||
const res = await fetch('http://localhost:8082/loadattachments', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, session }),
|
||||
})
|
||||
if (!res.ok) return
|
||||
const files: unknown = await res.json()
|
||||
if (isStringArray(files)) {
|
||||
local.attachments = files
|
||||
emitUpdated()
|
||||
}
|
||||
} catch {
|
||||
// ignore network errors
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.comment-attachment { font-size: 14px; }
|
||||
.comment-attachment__file-name { font-size: 14px !important; width: 100%; background-color: rgba(0,0,0,0.1)}
|
||||
.text-grey { color: rgba(0,0,0,0.45); }
|
||||
</style>
|
||||
305
app/src/components/SchoolModal.vue
Normal file
305
app/src/components/SchoolModal.vue
Normal file
@@ -0,0 +1,305 @@
|
||||
<template>
|
||||
<q-dialog v-model="visible" persistent>
|
||||
<q-card class="contained-card">
|
||||
<q-card-section>
|
||||
<div class="text-h6">{{ title }}</div>
|
||||
|
||||
<q-form ref="formRef" class="q-gutter-md q-mt-md">
|
||||
<q-input v-model="draft.name" :label="t('children.school')" :rules="[required()]" />
|
||||
|
||||
<div v-if="!draft.foreign">
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<div class="col-4">
|
||||
<q-select
|
||||
hide-selected
|
||||
fill-input
|
||||
v-model="selectedSwissCap"
|
||||
:options="swissOptions"
|
||||
option-label="label"
|
||||
option-value="cap"
|
||||
:label="t('address.zip')"
|
||||
:input-attrs="{ inputmode: 'numeric', maxlength: 4 }"
|
||||
@input-value="onSwissCapInputValue"
|
||||
use-input
|
||||
input-debounce="200"
|
||||
emit-value
|
||||
map-options
|
||||
:rules="[required()]"
|
||||
@filter="filterCapFn"
|
||||
@blur="onCapBlur"
|
||||
/>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-input v-model="draft.city" :label="t('address.city')" :rules="[required()]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<q-input
|
||||
class="col-4"
|
||||
v-model="draft.cap"
|
||||
:label="t('address.zip')"
|
||||
:rules="[required(), capRule]"
|
||||
:input-attrs="{ inputmode: 'numeric', maxlength: 10 }"
|
||||
@input-value="onCapInputValue"
|
||||
/>
|
||||
<q-input class="col" v-model="draft.city" :label="t('address.city')" :rules="[required()]" />
|
||||
</div>
|
||||
|
||||
<q-select
|
||||
v-model="draft.country"
|
||||
:options="countryOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
:label="t('address.country')"
|
||||
use-input
|
||||
input-debounce="200"
|
||||
emit-value
|
||||
map-options
|
||||
:rules="[required()]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<q-toggle v-if="allowForeign" v-model="draft.foreign" :label="t('address.foreign')" />
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat :label="t('button.cancel')" @click="onCancel" />
|
||||
<q-btn color="primary" :label="t('button.save')" @click="onSave" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, reactive, ref, watch, toRefs } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { COUNTRY_CODES, findPostalCodeDetails, findPostalCodes, ALL_CH_POSTAL_CODES } from '../data/countryCodes'
|
||||
|
||||
export interface SchoolOut {
|
||||
name: string
|
||||
cap: string
|
||||
city: string
|
||||
country: { code: string; name: string } | ''
|
||||
}
|
||||
|
||||
type Draft = {
|
||||
name: string
|
||||
cap: string
|
||||
city: string
|
||||
country: string
|
||||
foreign: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
modelSchool?: Partial<SchoolOut> | null
|
||||
title?: string
|
||||
allowForeign?: boolean
|
||||
}>(),
|
||||
{ allowForeign: true }
|
||||
)
|
||||
|
||||
const { allowForeign } = toRefs(props)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void
|
||||
(e: 'save', payload: SchoolOut): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const visible = ref(!!props.modelValue)
|
||||
watch(() => props.modelValue, v => (visible.value = !!v))
|
||||
watch(visible, v => {
|
||||
emit('update:modelValue', v)
|
||||
if (v) populateFromModel()
|
||||
})
|
||||
|
||||
const defaultDraft = (): Draft => ({ name: '', cap: '', city: '', country: '', foreign: false })
|
||||
const draft = reactive<Draft>({ ...defaultDraft() })
|
||||
|
||||
const formRef = ref<{ validate?: () => Promise<boolean> | boolean; resetValidation?: () => void } | null>(null)
|
||||
|
||||
function required() {
|
||||
const fallback = t('validation.required') || 'Required'
|
||||
return (v: unknown) => {
|
||||
if (v === null || v === undefined) return fallback
|
||||
if (typeof v === 'string') return (v.trim() !== '') || fallback
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const capRule = (v: unknown) => {
|
||||
if (v === null || v === undefined || v === '') return t('validation.required') || 'Required'
|
||||
if (typeof v === 'number') return true
|
||||
if (typeof v === 'string') {
|
||||
const s = v.trim()
|
||||
if (s.length === 0) return t('validation.required') || 'Required'
|
||||
return true
|
||||
}
|
||||
return t('validation.invalidZip') || 'Invalid ZIP'
|
||||
}
|
||||
|
||||
const countryOptions = computed(() => {
|
||||
try {
|
||||
const dn = new Intl.DisplayNames([String(locale.value)], { type: 'region' })
|
||||
return COUNTRY_CODES.map(code => ({ value: code, label: dn.of(code) || code }))
|
||||
} catch {
|
||||
return COUNTRY_CODES.map(code => ({ value: code, label: code }))
|
||||
}
|
||||
})
|
||||
|
||||
const swissOptionsRef = ref(ALL_CH_POSTAL_CODES.map(z => ({ cap: z, label: z })))
|
||||
const swissOptions = computed(() => swissOptionsRef.value)
|
||||
const selectedSwissCap = ref<string | null>(null)
|
||||
|
||||
function filterCapFn(val: string, update: (fn: () => void) => void) {
|
||||
update(() => {
|
||||
const q = String(val || '').replace(/\D/g, '').slice(0, 4)
|
||||
swissOptionsRef.value = findPostalCodes(q).map(z => ({ cap: z, label: z }))
|
||||
})
|
||||
}
|
||||
|
||||
function onCapInputValue(val: string) {
|
||||
const s = String(val || '').trim()
|
||||
if (draft.cap !== s) draft.cap = s
|
||||
}
|
||||
|
||||
function onSwissCapInputValue(val: string) {
|
||||
const s = String(val || '').replace(/\D/g, '').slice(0, 4)
|
||||
swissOptionsRef.value = findPostalCodes(s).map(z => ({ cap: z, label: z }))
|
||||
|
||||
if (!draft.foreign) {
|
||||
if (draft.cap !== s) draft.cap = s
|
||||
if (s.length >= 4) {
|
||||
const recs = findPostalCodeDetails(s)
|
||||
if (recs && recs.length) {
|
||||
const rec = recs[0]
|
||||
draft.cap = rec?.postalCode || s
|
||||
draft.city = rec?.placeName || ''
|
||||
selectedSwissCap.value = rec?.postalCode || s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(selectedSwissCap, (cap) => {
|
||||
if (!cap || draft.foreign) return
|
||||
const rec = findPostalCodeDetails(cap)[0]
|
||||
if (rec) {
|
||||
draft.cap = rec.postalCode
|
||||
}
|
||||
})
|
||||
|
||||
function onCapBlur() {
|
||||
const cap = selectedSwissCap.value
|
||||
if (!cap || draft.foreign) return
|
||||
const recs = findPostalCodeDetails(cap)
|
||||
if (recs && recs.length) {
|
||||
const rec = recs[0]
|
||||
draft.city = rec?.placeName || draft.city
|
||||
draft.cap = rec?.postalCode || draft.cap
|
||||
}
|
||||
}
|
||||
|
||||
watch(allowForeign, (v) => {
|
||||
if (!v) draft.foreign = false
|
||||
})
|
||||
|
||||
function populateFromModel() {
|
||||
const src = props.modelSchool || {}
|
||||
const capRaw = (src as Record<string, unknown>).cap
|
||||
const capStr = typeof capRaw === 'string' ? capRaw : (typeof capRaw === 'number' ? String(capRaw) : '')
|
||||
const countryCode =
|
||||
typeof src.country === 'object' && src.country !== null
|
||||
? (src.country.code || '')
|
||||
: (typeof src.country === 'string' ? src.country : '')
|
||||
|
||||
Object.assign(draft, {
|
||||
...defaultDraft(),
|
||||
name: typeof src.name === 'string' ? src.name : '',
|
||||
cap: capStr,
|
||||
city: typeof src.city === 'string' ? src.city : '',
|
||||
country: countryCode,
|
||||
foreign: !!countryCode && countryCode !== 'CH'
|
||||
})
|
||||
|
||||
if (!allowForeign.value) draft.foreign = false
|
||||
|
||||
// init swiss select if CH and cap present
|
||||
if (!draft.foreign && draft.cap) {
|
||||
const s = String(draft.cap || '').replace(/\D/g, '').slice(0, 4)
|
||||
draft.cap = s
|
||||
if (ALL_CH_POSTAL_CODES.includes(s)) selectedSwissCap.value = s
|
||||
if (s.length >= 4) {
|
||||
const recs = findPostalCodeDetails(s)
|
||||
if (recs && recs.length) {
|
||||
const rec = recs[0]
|
||||
draft.city = rec?.placeName || draft.city
|
||||
draft.cap = rec?.postalCode || draft.cap
|
||||
selectedSwissCap.value = rec?.postalCode || s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void nextTick(() => formRef.value?.resetValidation?.())
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
try {
|
||||
const ok = await (formRef.value?.validate?.() ?? true)
|
||||
if (ok === false) return
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
let countryOut: { code: string; name: string } | '' = ''
|
||||
|
||||
if (draft.foreign) {
|
||||
const code = draft.country || ''
|
||||
const label = countryOptions.value.find(o => o.value === code)?.label || code
|
||||
countryOut = code ? { code, name: label } : ''
|
||||
} else {
|
||||
try {
|
||||
const dn = new Intl.DisplayNames([String(locale.value)], { type: 'region' })
|
||||
countryOut = { code: 'CH', name: dn.of('CH') || 'Switzerland' }
|
||||
} catch {
|
||||
countryOut = { code: 'CH', name: 'Switzerland' }
|
||||
}
|
||||
}
|
||||
|
||||
emit('save', {
|
||||
name: draft.name || '',
|
||||
cap: draft.cap || '',
|
||||
city: draft.city || '',
|
||||
country: countryOut
|
||||
})
|
||||
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
emit('cancel')
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
const title = computed(() => props.title || t('children.school') || 'School')
|
||||
|
||||
// if already open at mount, ensure draft is populated
|
||||
void nextTick(() => {
|
||||
if (props.modelValue || props.modelSchool) populateFromModel()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.contained-card {
|
||||
min-width: 420px;
|
||||
max-width: 720px;
|
||||
}
|
||||
</style>
|
||||
340
app/src/components/SimpleAttachment.vue
Normal file
340
app/src/components/SimpleAttachment.vue
Normal file
@@ -0,0 +1,340 @@
|
||||
<template>
|
||||
<div class="comment-attachment column full-width shadow-1 q-pa-sm bg-white" :data-id="id">
|
||||
<div>{{ title || label }}</div>
|
||||
|
||||
<div class="row q-ml-md full-width items-center no-wrap">
|
||||
<div class="col q-ml-md">
|
||||
<q-file
|
||||
ref="fileRef"
|
||||
:model-value="files"
|
||||
@update:model-value="updateFiles"
|
||||
:disable="!!disable"
|
||||
accept=".pdf,.docx,.txt,.md"
|
||||
dense
|
||||
borderless
|
||||
hide-bottom-space
|
||||
:clearable="false"
|
||||
input-style="display: none"
|
||||
style="max-width: 400px"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<div class="row items-center no-wrap full-width">
|
||||
<template v-if="!files">
|
||||
<q-btn
|
||||
round
|
||||
dense
|
||||
flat
|
||||
color="primary"
|
||||
icon="attach_file"
|
||||
:disable="!!disable || isUploading"
|
||||
@click.stop.prevent="fileRef?.pickFiles()"
|
||||
>
|
||||
<q-tooltip>{{ t('pickFiles') }}</q-tooltip>
|
||||
</q-btn>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<q-btn
|
||||
round
|
||||
dense
|
||||
flat
|
||||
color="primary"
|
||||
icon="cloud_upload"
|
||||
:disable="!!disable || !canUpload || isUploading"
|
||||
:loading="isUploading"
|
||||
@click.stop.prevent="upload"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<q-chip
|
||||
v-if="chipLabel"
|
||||
dense
|
||||
:removable="!disable"
|
||||
class="q-ml-sm comment-attachment__file-chip"
|
||||
:color="isUploaded ? 'positive' : undefined"
|
||||
:text-color="isUploaded ? 'white' : undefined"
|
||||
@remove="onChipRemove"
|
||||
>
|
||||
<span class="ellipsis">{{ chipLabel }}</span>
|
||||
</q-chip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</q-file>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useQuasar, type QFile } from 'quasar'
|
||||
import { useUserstore } from '../stores/userstore'
|
||||
import { ApiError, deleteAttachment, loadAttachments, uploadDocument } from '../utils/api'
|
||||
|
||||
export interface CommentAttachmentData {
|
||||
comments: string
|
||||
attachments: string[]
|
||||
}
|
||||
|
||||
type Payload = CommentAttachmentData
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: Partial<Payload> | null
|
||||
label?: string
|
||||
title?: string
|
||||
session?: string
|
||||
id?: string
|
||||
autoload?: boolean
|
||||
disable?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: Payload | null): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const $q = useQuasar()
|
||||
const userStore = useUserstore()
|
||||
const fileRef = ref<QFile | null>(null)
|
||||
const initialAttachments = props.modelValue?.attachments
|
||||
|
||||
const local = reactive<Payload>({
|
||||
comments: props.modelValue?.comments || '',
|
||||
attachments: Array.isArray(initialAttachments) ? [...initialAttachments] : [],
|
||||
})
|
||||
|
||||
const comments = ref(local.comments)
|
||||
|
||||
type UploadProgressItem = {
|
||||
percent: number
|
||||
color: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
const files = ref<File | null>(null)
|
||||
const isUploading = ref(false)
|
||||
const uploadProgress = ref<UploadProgressItem[]>([])
|
||||
|
||||
const allowedExtensions = new Set(['pdf', 'docx', 'txt', 'md'])
|
||||
const allowedLabel = '.pdf, .docx, .txt, .md'
|
||||
|
||||
function isAllowedFile(file: File): boolean {
|
||||
const name = (file.name || '').trim().toLowerCase()
|
||||
const ext = name.includes('.') ? name.split('.').pop() : ''
|
||||
return !!ext && allowedExtensions.has(ext)
|
||||
}
|
||||
|
||||
const canUpload = computed(() => !!files.value)
|
||||
|
||||
const chipLabel = computed(() => {
|
||||
const current = files.value
|
||||
if (current?.name) return current.name
|
||||
|
||||
const lastUploaded = local.attachments.length > 0 ? local.attachments[local.attachments.length - 1] : ''
|
||||
return lastUploaded || ''
|
||||
})
|
||||
|
||||
const isUploaded = computed(() => !files.value && local.attachments.length > 0)
|
||||
|
||||
// const emptyProgress: UploadProgressItem = { percent: 0, color: 'primary', icon: 'attach_file' }
|
||||
|
||||
function ensureProgress(index: number): UploadProgressItem {
|
||||
while (uploadProgress.value.length <= index) {
|
||||
uploadProgress.value.push({ percent: 0, color: 'primary', icon: 'attach_file' })
|
||||
}
|
||||
const item = uploadProgress.value[index]
|
||||
if (!item) {
|
||||
const fallback = { percent: 0, color: 'primary', icon: 'attach_file' } satisfies UploadProgressItem
|
||||
uploadProgress.value[index] = fallback
|
||||
return fallback
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
function emitUpdated() {
|
||||
emit('update:modelValue', {
|
||||
comments: local.comments || '',
|
||||
attachments: [...local.attachments],
|
||||
})
|
||||
}
|
||||
|
||||
function updateFiles(val: File | null) {
|
||||
if (val && !isAllowedFile(val)) {
|
||||
$q.notify({ type: 'negative', message: t('fileTypeNotAllowed', { allowed: allowedLabel }) })
|
||||
files.value = null
|
||||
isUploading.value = false
|
||||
uploadProgress.value = []
|
||||
return
|
||||
}
|
||||
|
||||
files.value = val
|
||||
isUploading.value = false
|
||||
|
||||
uploadProgress.value = val
|
||||
? [{ percent: 0, color: 'primary', icon: 'attach_file' }]
|
||||
: []
|
||||
}
|
||||
|
||||
function cancelFile(index: number) {
|
||||
const currentProgress = uploadProgress.value
|
||||
// single file selection
|
||||
files.value = null
|
||||
|
||||
const nextProgress = [...currentProgress]
|
||||
nextProgress.splice(index, 1)
|
||||
uploadProgress.value = nextProgress
|
||||
}
|
||||
|
||||
function cancelSelection() {
|
||||
cancelFile(0)
|
||||
}
|
||||
|
||||
async function deleteLastUploadedAttachment() {
|
||||
const filename = local.attachments.length > 0 ? local.attachments[local.attachments.length - 1] : ''
|
||||
if (!filename) return
|
||||
|
||||
const session = props.session
|
||||
const prop = props.id
|
||||
const id = userStore.id
|
||||
|
||||
if (!session || !id) {
|
||||
local.attachments = []
|
||||
emitUpdated()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteAttachment({
|
||||
id,
|
||||
session,
|
||||
...(prop ? { prop } : {}),
|
||||
filename,
|
||||
})
|
||||
local.attachments = []
|
||||
emitUpdated()
|
||||
$q.notify({ type: 'positive', message: t('fileDeleted') })
|
||||
} catch (e) {
|
||||
$q.notify({ type: 'negative', message: e instanceof Error ? e.message : t('deleteFailed') })
|
||||
}
|
||||
}
|
||||
|
||||
function onChipRemove() {
|
||||
if (props.disable) return
|
||||
if (files.value) {
|
||||
cancelSelection()
|
||||
return
|
||||
}
|
||||
void deleteLastUploadedAttachment()
|
||||
}
|
||||
|
||||
async function upload() {
|
||||
if (props.disable) return
|
||||
const file = files.value
|
||||
if (!file) return
|
||||
if (!isAllowedFile(file)) {
|
||||
$q.notify({ type: 'negative', message: t('fileTypeNotAllowed', { allowed: allowedLabel }) })
|
||||
return
|
||||
}
|
||||
|
||||
const session = props.session
|
||||
const prop = props.id
|
||||
const user = userStore.id
|
||||
if (!session || !user) {
|
||||
$q.notify({ type: 'warning', message: t('missingUserOrSession') })
|
||||
return
|
||||
}
|
||||
|
||||
isUploading.value = true
|
||||
|
||||
try {
|
||||
const res = await uploadDocument({
|
||||
user,
|
||||
session,
|
||||
...(prop ? { prop } : {}),
|
||||
file,
|
||||
onProgress: (fraction) => {
|
||||
ensureProgress(0).percent = fraction
|
||||
},
|
||||
})
|
||||
|
||||
const p = ensureProgress(0)
|
||||
p.percent = 1
|
||||
p.color = 'positive'
|
||||
p.icon = 'check'
|
||||
|
||||
const uploadedName = res.files?.[0] ?? file.name
|
||||
local.attachments.push(uploadedName)
|
||||
emitUpdated()
|
||||
$q.notify({ type: 'positive', message: t('fileUploaded') })
|
||||
} catch (e) {
|
||||
const p = ensureProgress(0)
|
||||
p.color = e instanceof ApiError && e.message.includes('cancel') ? 'warning' : 'negative'
|
||||
p.icon = e instanceof ApiError && e.message.includes('cancel') ? 'close' : 'error'
|
||||
const isCancel = e instanceof ApiError && e.message.includes('cancel')
|
||||
$q.notify({
|
||||
type: p.color === 'warning' ? 'warning' : 'negative',
|
||||
message: isCancel ? t('uploadCancelled') : (e instanceof Error ? e.message : t('uploadFailed')),
|
||||
})
|
||||
}
|
||||
|
||||
// Clear selection after upload; keep uploaded names in local.attachments
|
||||
files.value = null
|
||||
uploadProgress.value = []
|
||||
isUploading.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(v) => {
|
||||
if (v && typeof v.comments === 'string') {
|
||||
local.comments = v.comments
|
||||
} else if (v === null) {
|
||||
local.comments = ''
|
||||
}
|
||||
|
||||
// modelValue is Partial<Payload>: don't wipe local attachments if the parent omits the field
|
||||
if (v && Array.isArray(v.attachments)) {
|
||||
local.attachments = [...v.attachments]
|
||||
} else if (v === null) {
|
||||
local.attachments = []
|
||||
}
|
||||
|
||||
comments.value = local.comments
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((x) => typeof x === 'string')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (props.autoload === false) return
|
||||
|
||||
// session comes from component prop id; user id comes from userstore
|
||||
const session = props.session
|
||||
const id = userStore.id
|
||||
if (!session || !id) return
|
||||
|
||||
try {
|
||||
const files = await loadAttachments({
|
||||
id,
|
||||
session,
|
||||
...(props.id ? { prop: props.id } : {}),
|
||||
})
|
||||
if (isStringArray(files)) {
|
||||
local.attachments = [...files]
|
||||
emitUpdated()
|
||||
}
|
||||
} catch {
|
||||
// ignore network errors
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.comment-attachment { font-size: 14px; }
|
||||
.comment-attachment__file-chip { max-width: 320px; }
|
||||
.text-grey { color: rgba(0,0,0,0.45); }
|
||||
</style>
|
||||
123
app/src/components/StepsStepper.vue
Normal file
123
app/src/components/StepsStepper.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div class="row full-height">
|
||||
<div class="col-3 q-pa-sm bg-grey-1">
|
||||
<q-list dense bordered class="vertical-nav">
|
||||
<q-item v-for="(s, idx) in steps" :key="s.id" clickable @click="active = idx" :active="active === idx">
|
||||
<q-item-section>
|
||||
<div class="text-body1">{{ idx + 1 }}. {{ s.title }}</div>
|
||||
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</div>
|
||||
|
||||
<div class="col q-pa-md full-height">
|
||||
<q-card flat class="q-pa-md full-height">
|
||||
<q-card-section>
|
||||
<div v-if="!currentComponent" class="text-h6">{{ steps[active]?.title }}</div>
|
||||
<component
|
||||
v-if="currentComponent && currentStep"
|
||||
:is="currentComponent"
|
||||
:step="currentStep"
|
||||
@next="onChildNext"
|
||||
@prev="onChildPrev"
|
||||
/>
|
||||
<div v-else class="q-mt-md"><!-- placeholder: no dynamic component for this step -->
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, defineAsyncComponent } from 'vue'
|
||||
import type { StepDescriptor } from '../types/types'
|
||||
|
||||
const rawSteps = [
|
||||
{ id: 'welcome', title: 'Benvenuto', order: 0 },
|
||||
{ id: 'taxpayer', title: 'Dati contribuente e dichiarazione precedente', order: 1 },
|
||||
{ id: 'marital', title: 'Stato civile', order: 2 },
|
||||
{ id: 'children', title: 'Figli', order: 3 },
|
||||
{ id: 'income', title: 'Redditi', order: 4 },
|
||||
{ id: 'professionalExpenses', title: 'Spese professionali', order: 5 },
|
||||
{ id: 'sideIncome', title: 'Reddito accessorio', order: 6 },
|
||||
{ id: 'annuities', title: 'Rendite', order: 7 },
|
||||
{ id: 'insurance', title: 'Spese assicurative e mediche', order: 8 },
|
||||
{ id: 'pillar3', title: 'Polizze 3A / 3B', order: 9 },
|
||||
{ id: 'bankAccounts', title: 'Conti bancari', order: 10 },
|
||||
{ id: 'otherAssets', title: 'Altri beni / averi', order: 11 },
|
||||
{ id: 'debts', title: 'Debiti / ipoteche', order: 12 },
|
||||
{ id: 'properties', title: 'Immobili', order: 13 },
|
||||
{ id: 'foreign', title: 'Redditi o averi all’estero', order: 14 }
|
||||
] as { id: string; title: string; order: number }[]
|
||||
|
||||
const maxOrder = Math.max(...rawSteps.map(s => s.order))
|
||||
const steps: StepDescriptor[] = new Array(maxOrder + 1).fill(undefined).map(() => ({} as StepDescriptor))
|
||||
rawSteps.forEach(s => {
|
||||
steps[s.order] = { id: s.id, title: s.title, order: s.order }
|
||||
})
|
||||
|
||||
const active = ref(0)
|
||||
|
||||
const currentComponent = computed(() => {
|
||||
const id = steps[active.value]?.id
|
||||
if (id === 'welcome') return defineAsyncComponent(() => import('./steps/WelcomeStep.vue'))
|
||||
if (id === 'taxpayer') return defineAsyncComponent(() => import('./steps/TaxpayerStep.vue'))
|
||||
if (id === 'marital') return defineAsyncComponent(() => import('./steps/MaritalStep.vue'))
|
||||
if (id === 'children') return defineAsyncComponent(() => import('./steps/ChildrenStep.vue'))
|
||||
if (id === 'income') return defineAsyncComponent(() => import('./steps/IncomeStep.vue'))
|
||||
return null
|
||||
})
|
||||
|
||||
const currentStep = computed<StepDescriptor | undefined>(() => steps[active.value])
|
||||
|
||||
// navigation handled by child step events via onChildNext/onChildPrev
|
||||
|
||||
function onChildNext(payload: unknown) {
|
||||
// payload may be a next step id or undefined
|
||||
if (typeof payload === 'string') {
|
||||
const idx = steps.findIndex(s => s.id === payload)
|
||||
if (idx !== -1) {
|
||||
active.value = idx
|
||||
return
|
||||
}
|
||||
}
|
||||
// default: move to next step index if available
|
||||
if (active.value < steps.length - 1) active.value++
|
||||
}
|
||||
|
||||
function onChildPrev(payload: unknown) {
|
||||
// payload may be a prev step id or undefined
|
||||
if (typeof payload === 'string') {
|
||||
const idx = steps.findIndex(s => s.id === payload)
|
||||
if (idx !== -1) {
|
||||
active.value = idx
|
||||
return
|
||||
}
|
||||
}
|
||||
if (active.value > 0) active.value--
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.q-stepper { max-width: 900px; margin: 0 auto; }
|
||||
|
||||
.full-height { height: 100%; }
|
||||
|
||||
.vertical-nav .q-item {
|
||||
border-radius: 4px;
|
||||
margin-bottom: 4px;
|
||||
max-width: 350px;
|
||||
}
|
||||
.vertical-nav .q-item--active {
|
||||
background-color: var(--q-color-primary) !important;
|
||||
}
|
||||
|
||||
.vertical-nav .q-item--active .text-body1,
|
||||
.vertical-nav .q-item--active .q-item__label {
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
|
||||
</style>
|
||||
441
app/src/components/steps/ChildrenStep.vue
Normal file
441
app/src/components/steps/ChildrenStep.vue
Normal file
@@ -0,0 +1,441 @@
|
||||
<template>
|
||||
<q-card flat class="full-width q-pa-none">
|
||||
<q-card-section class="full-width">
|
||||
<div class="row items-center">
|
||||
<div class="col">
|
||||
<div class="text-h6">{{ t('CHD') }}</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat :label="t('button.prev')" @click="emitPrev" class="q-mr-sm" />
|
||||
<q-btn color="primary" :label="t('button.next')" @click="saveAndNext" />
|
||||
</div>
|
||||
</div>
|
||||
<q-separator class="q-my-sm" />
|
||||
|
||||
<q-form ref="formRef" class="q-gutter-md q-mt-md">
|
||||
<div class="row items-center">
|
||||
<div class="col">
|
||||
<q-toggle v-model="form.hasChildren" :label="t('children.hasChildren')" />
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn v-if="form.hasChildren" :disable="form.children.length >= 5" color="primary" :label="`+ ${t('children.addChild')}`" @click="openAddModal" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="form.hasChildren" class="q-mt-md">
|
||||
<div class="text-subtitle2 q-mb-sm">{{ t('children.listTitle') }}</div>
|
||||
|
||||
<q-list bordered>
|
||||
<q-item v-for="(child, i) in form.children" :key="i" clickable>
|
||||
<q-item-section>
|
||||
<q-item-label><span class="text-weight-bold">{{ child.firstName || '-' }} {{ child.lastName || '' }} ({{ ageFromDate(child.birthDate) }})</span></q-item-label>
|
||||
<q-item-label v-if="!modalDraft.sameHousehold">{{ formatAddressToString(child.address) }}</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side class="row items-center q-gutter-sm">
|
||||
<q-btn dense flat round icon="edit" @click="openEditModal(i)" />
|
||||
<q-btn dense flat round icon="delete" color="negative" @click="deleteChild(i)" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</div>
|
||||
|
||||
|
||||
<div v-if="hasTriggeredOverLimit || form.children.length >= 5" class="q-mt-md">
|
||||
<q-input type="textarea" v-model="form.moreThanFiveChildrenNote" :label="t('children.moreThanFiveChildrenNote')" autogrow />
|
||||
</div>
|
||||
</q-form>
|
||||
|
||||
<!-- Modal for adding/editing a child -->
|
||||
<q-dialog v-model="isDialogOpen" persistent>
|
||||
<q-card class="contained-card child-modal">
|
||||
<q-card-section>
|
||||
<div class="text-h6">{{ editingIndex === null ? t('children.addChild') : t('children.editChild') }}</div>
|
||||
<q-separator class="q-my-sm" />
|
||||
<q-form ref="modalFormRef" class="q-gutter-md q-mt-md">
|
||||
<q-input class="q-mt-none" dense v-model="modalDraft.firstName" :label="t('children.firstName')" :rules="modalFirstNameRules" @blur="onNameBlur('firstName')" />
|
||||
<q-input class="q-mt-none" dense v-model="modalDraft.lastName" :label="t('children.lastName')" :rules="modalLastNameRules" @blur="onNameBlur('lastName')">
|
||||
<template v-slot:prepend>
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
round
|
||||
color="primary"
|
||||
icon="family_restroom"
|
||||
:disable="!taxpayerLastName"
|
||||
@click.stop.prevent="copyLastNameFromTaxpayer"
|
||||
>
|
||||
<q-tooltip class="bg-primary text-white">{{ t('children.copyLastNameFromTaxpayer') }}</q-tooltip>
|
||||
</q-btn>
|
||||
</template>
|
||||
</q-input>
|
||||
<q-input class="q-mt-none" dense v-model="modalDraft.birthDate" type="date" :label="t('children.birthDate')" :rules="modalBirthDateRules" />
|
||||
<div class="row items-center q-gutter-sm q-mt-none">
|
||||
<div class="col">
|
||||
<q-toggle class="q-mt-none" v-model="modalDraft.sameHousehold" :label="t('children.sameHousehold')" />
|
||||
<div class="q-mt-none" v-if="!modalDraft.sameHousehold">
|
||||
<q-toggle class="q-mt-none" v-model="modalDraft.alimentiVersati" :label="t('children.alimentiVersati')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div v-if="!modalDraft.sameHousehold" class="q-ml-lg q-mt-none">
|
||||
<div class="row items-center q-gutter-sm q-mb-xs">
|
||||
<div class="col-auto">
|
||||
<q-btn dense flat round icon="edit" @click="openAddressModal">
|
||||
<q-tooltip class="bg-primary text-white">{{ t('children.editAddress') }}</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="text-caption">{{ t('children.addressLabel') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="q-pa-xs bg-grey-2 q-px-md">
|
||||
<div v-if="!modalDraft.address" class="text-negative">{{ t('validation.insertAddress') }}</div>
|
||||
<div v-else class="q-pa-xs">{{ formattedModalAddress }}</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<q-input class="q-mt-none" dense v-model="modalDraft.school" :label="t('children.school')" />
|
||||
<div class="row">
|
||||
<q-toggle class="q-mt-none" v-model="modalDraft.hasCareCost" :label="t('children.hasCareCost')" />
|
||||
<CommentAttachment
|
||||
class="q-mt-none"
|
||||
v-if="modalDraft.hasCareCost"
|
||||
v-model="modalDraft.careCosts"
|
||||
:label="t('children.careCosts')"
|
||||
:id="'children'"
|
||||
/>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
<AddressModal v-model="isAddressDialogOpen" :modelAddress="modalAddressObject" :allowForeign="false" @save="onAddressSave" @cancel="onAddressCancel" />
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat :label="t('button.cancel')" @click="closeModal" />
|
||||
<q-btn color="primary" :label="editingIndex === null ? t('button.add') : t('button.save')" @click="confirmModal" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, onMounted, ref, nextTick, watch, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AddressModal from '../AddressModal.vue'
|
||||
import type { Address } from '../../types/address'
|
||||
import { useChildrenStore, type ChildItem, type ChildrenData } from '../../stores/children'
|
||||
import { useTaxstore } from '../../stores/taxstore'
|
||||
import type { StepDescriptor } from '../../types/types'
|
||||
import CommentAttachment from '../CommentAttachment.vue'
|
||||
|
||||
const props = defineProps<{ step?: StepDescriptor }>()
|
||||
const emit = defineEmits(['next', 'prev'])
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useChildrenStore()
|
||||
const taxStore = useTaxstore()
|
||||
|
||||
const taxpayerLastName = computed(() => {
|
||||
const ln = taxStore.getTaxpayer()?.lastName
|
||||
return typeof ln === 'string' ? ln.trim() : ''
|
||||
})
|
||||
|
||||
// QForm ref
|
||||
const formRef = ref<{ validate?: () => Promise<boolean> | boolean; resetValidation?: () => void } | null>(null)
|
||||
|
||||
const requiredMessage = (fallback = 'Required') => {
|
||||
const msg = t('validation.required')
|
||||
return msg && msg !== 'validation.required' ? msg : fallback
|
||||
}
|
||||
const required = (msg?: string) => (v: unknown) => {
|
||||
const message = msg || requiredMessage()
|
||||
if (v === null || v === undefined) return message
|
||||
if (typeof v === 'string') return (v.trim() !== '') || message
|
||||
if (Array.isArray(v)) return v.length > 0 || message
|
||||
return true
|
||||
}
|
||||
|
||||
const minLength = (n: number, msg?: string) => (v: unknown) => {
|
||||
const message = msg || `${t('validation.minLength') || `Minimum ${n} chars`}`
|
||||
if (v === null || v === undefined) return true
|
||||
if (typeof v === 'string') return (v.trim().length >= n) || message
|
||||
return true
|
||||
}
|
||||
|
||||
const maxAgeFromJan1 = (years: number, msg?: string) => (v: unknown) => {
|
||||
const i18nMsg = t('validation.maxAgeFromJan1')
|
||||
const message = msg || (typeof i18nMsg === 'string' ? i18nMsg : `Age must be at most ${years} years from Jan 1 of this year`)
|
||||
if (!v) return true
|
||||
|
||||
let d: Date
|
||||
if (v instanceof Date) {
|
||||
d = v
|
||||
} else if (typeof v === 'string') {
|
||||
d = new Date(v)
|
||||
} else {
|
||||
return message
|
||||
}
|
||||
|
||||
if (isNaN(d.getTime())) return message
|
||||
const now = new Date()
|
||||
const cutoff = new Date(now.getFullYear() - years, 0, 1)
|
||||
return d >= cutoff || message
|
||||
}
|
||||
|
||||
const formatAddressToString = (v?: Address | null) => {
|
||||
if (!v) return ''
|
||||
|
||||
const country = v.country
|
||||
const countryCode = typeof country === 'object' && country !== null ? country.code : country
|
||||
const countryName = typeof country === 'object' && country !== null ? country.name : ''
|
||||
|
||||
if (countryCode === 'CH') {
|
||||
return [v.street, String(v.zip || ''), v.city, v.canton || ''].filter(Boolean).join(', ')
|
||||
}
|
||||
return [v.street, String(v.zip || ''), v.city, countryName].filter(Boolean).join(', ')
|
||||
}
|
||||
|
||||
function onNameBlur(field: 'firstName' | 'lastName') {
|
||||
const raw = (modalDraft[field] ?? '') as unknown
|
||||
const s = typeof raw === 'string' ? raw.trim() : String(raw)
|
||||
if (!s) {
|
||||
modalDraft[field] = ''
|
||||
return
|
||||
}
|
||||
modalDraft[field] = s.charAt(0).toUpperCase() + s.slice(1)
|
||||
}
|
||||
|
||||
function copyLastNameFromTaxpayer() {
|
||||
const ln = taxpayerLastName.value
|
||||
if (!ln) return
|
||||
modalDraft.lastName = ln
|
||||
}
|
||||
|
||||
function ageFromDate(d?: string) {
|
||||
if (!d) return ''
|
||||
const dt = new Date(d)
|
||||
if (isNaN(dt.getTime())) return ''
|
||||
const now = new Date()
|
||||
let age = now.getFullYear() - dt.getFullYear()
|
||||
const m = now.getMonth() - dt.getMonth()
|
||||
if (m < 0 || (m === 0 && now.getDate() < dt.getDate())) age--
|
||||
if (age < 0) return ''
|
||||
return `${age} anni`
|
||||
}
|
||||
|
||||
const makeEmptyChild = (): ChildItem => ({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
birthDate: '',
|
||||
sameHousehold: true,
|
||||
school: '',
|
||||
hasCareCost: false,
|
||||
careCosts: { comments: '', attachments: [] },
|
||||
address: null,
|
||||
alimentiVersati: false
|
||||
})
|
||||
|
||||
const form = reactive<ChildrenData>({
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
moreThanFiveChildrenNote: ''
|
||||
})
|
||||
|
||||
const hasTriggeredOverLimit = ref(false)
|
||||
|
||||
// childRequired intentionally removed; modal rules are used instead
|
||||
|
||||
// modal state
|
||||
const isDialogOpen = ref(false)
|
||||
const editingIndex = ref<number | null>(null)
|
||||
const modalDraft = reactive<ChildItem>({ ...makeEmptyChild() })
|
||||
const modalFormRef = ref<{ validate?: () => Promise<boolean> | boolean; resetValidation?: () => void } | null>(null)
|
||||
const isAddressDialogOpen = ref(false)
|
||||
const modalAddressObject = ref<Address | null>(null)
|
||||
|
||||
const formattedModalAddress = computed(() => {
|
||||
const a = modalAddressObject.value
|
||||
if (!a) return ''
|
||||
return formatAddressForString(a)
|
||||
})
|
||||
// over-limit modal removed; note edited inline
|
||||
const modalFirstNameRules = [required(), minLength(2)]
|
||||
const modalLastNameRules = [required(), minLength(2)]
|
||||
const modalBirthDateRules = [required(), maxAgeFromJan1(25)]
|
||||
|
||||
async function openAddModal() {
|
||||
editingIndex.value = null
|
||||
Object.assign(modalDraft, makeEmptyChild())
|
||||
// clear any previously stored address object
|
||||
modalDraft.address = null
|
||||
isDialogOpen.value = true
|
||||
await nextTick()
|
||||
modalFormRef.value?.resetValidation?.()
|
||||
}
|
||||
|
||||
async function openEditModal(index: number) {
|
||||
const c = form.children[index]
|
||||
if (!c) return
|
||||
|
||||
editingIndex.value = index
|
||||
Object.assign(modalDraft, { ...makeEmptyChild(), ...c })
|
||||
// restore stored address object if present on the child
|
||||
modalDraft.address = c.address || null
|
||||
modalAddressObject.value = modalDraft.address || null
|
||||
isDialogOpen.value = true
|
||||
await nextTick()
|
||||
modalFormRef.value?.resetValidation?.()
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
isDialogOpen.value = false
|
||||
}
|
||||
|
||||
function openAddressModal() {
|
||||
// pass an Address prop to the AddressModal: prefer the stored object, otherwise
|
||||
// construct a minimal Address from the saved address string so the modal can render it
|
||||
if (modalDraft.address) {
|
||||
modalAddressObject.value = modalDraft.address
|
||||
} else {
|
||||
modalAddressObject.value = null
|
||||
}
|
||||
isAddressDialogOpen.value = true
|
||||
}
|
||||
|
||||
function onAddressCancel() {
|
||||
isAddressDialogOpen.value = false
|
||||
}
|
||||
|
||||
function formatAddressForString(a: Address) {
|
||||
function hasCountryName(x: unknown): x is { name: string } {
|
||||
return typeof x === 'object' && x !== null && Object.prototype.hasOwnProperty.call(x, 'name')
|
||||
}
|
||||
const countryLabel = a.country && typeof a.country === 'object' && hasCountryName(a.country) ? a.country.name : (a.country || '')
|
||||
// use tab-separated fields: street<TAB>zip<TAB>city<TAB>country
|
||||
return `${a.street || ''}\t${a.zip || ''}\t${a.city || ''}\t${countryLabel || ''}`
|
||||
}
|
||||
|
||||
function onAddressSave(a: Address) {
|
||||
modalAddressObject.value = a
|
||||
// store object on the draft so it persists with the child entry
|
||||
modalDraft.address = a
|
||||
// do not persist the composed string; use formatted string only for rendering
|
||||
isAddressDialogOpen.value = false
|
||||
}
|
||||
|
||||
async function confirmModal() {
|
||||
try {
|
||||
const ok = await (modalFormRef.value?.validate?.() ?? true)
|
||||
if (ok === false) return
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (editingIndex.value === null) {
|
||||
// If already at or above limit, mark over-limit and abort
|
||||
if (form.children.length >= 5) {
|
||||
isDialogOpen.value = false
|
||||
hasTriggeredOverLimit.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// push new child
|
||||
form.children.push({ ...modalDraft })
|
||||
// persist immediately
|
||||
store.setChildren(buildPayload())
|
||||
|
||||
// if we've just reached the maximum, mark over-limit
|
||||
if (form.children.length === 5) {
|
||||
isDialogOpen.value = false
|
||||
hasTriggeredOverLimit.value = true
|
||||
return
|
||||
}
|
||||
} else {
|
||||
form.children[editingIndex.value] = { ...modalDraft }
|
||||
// persist immediately
|
||||
store.setChildren(buildPayload())
|
||||
}
|
||||
|
||||
isDialogOpen.value = false
|
||||
}
|
||||
|
||||
function deleteChild(index: number) {
|
||||
form.children.splice(index, 1)
|
||||
store.setChildren(buildPayload())
|
||||
}
|
||||
|
||||
// over-limit modal removed; note edited inline and persisted via watcher
|
||||
|
||||
onMounted(async () => {
|
||||
const saved = store.getChildren()
|
||||
if (saved) {
|
||||
form.hasChildren = !!saved.hasChildren
|
||||
if (Array.isArray(saved.children) && saved.children.length) {
|
||||
// copy up to 5
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (saved.children[i]) {
|
||||
form.children[i] = { ...form.children[i], ...(saved.children[i] as Partial<ChildItem>) } as ChildItem
|
||||
}
|
||||
}
|
||||
}
|
||||
form.moreThanFiveChildrenNote = saved.moreThanFiveChildrenNote || ''
|
||||
if (saved.moreThanFiveChildrenNote || (Array.isArray(saved.children) && saved.children.length >= 5)) {
|
||||
hasTriggeredOverLimit.value = true
|
||||
}
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
formRef.value?.resetValidation?.()
|
||||
})
|
||||
|
||||
// persist when hasChildren toggles
|
||||
watch(() => form.hasChildren, () => {
|
||||
store.setChildren(buildPayload())
|
||||
})
|
||||
|
||||
// persist note immediately when edited
|
||||
watch(() => form.moreThanFiveChildrenNote, () => {
|
||||
store.setChildren(buildPayload())
|
||||
})
|
||||
|
||||
function buildPayload() {
|
||||
const payload = {
|
||||
hasChildren: form.hasChildren,
|
||||
children: form.children.filter(c => c.firstName || c.lastName || c.birthDate),
|
||||
moreThanFiveChildrenNote: form.moreThanFiveChildrenNote
|
||||
}
|
||||
// limit to 5 items
|
||||
payload.children = payload.children.slice(0, 5)
|
||||
return payload
|
||||
}
|
||||
|
||||
async function saveAndNext() {
|
||||
try {
|
||||
const ok = await (formRef.value?.validate?.() ?? true)
|
||||
if (ok === false) return
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const payload = buildPayload()
|
||||
store.setChildren(payload)
|
||||
emit('next', props.step?.next)
|
||||
}
|
||||
|
||||
function emitPrev() {
|
||||
const payload = buildPayload()
|
||||
store.setChildren(payload)
|
||||
emit('prev', props.step?.prev)
|
||||
}
|
||||
|
||||
defineExpose({ buildPayload })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.full-width { width: 100%; }
|
||||
.contained-card { min-width: 480px; max-width: 720px; }
|
||||
.contained-card .q-card-section { padding: 16px; }
|
||||
.contained-card .q-card-actions { padding: 12px 16px; }
|
||||
.child-modal { min-width: 480px; }
|
||||
</style>
|
||||
183
app/src/components/steps/IncomeStep.vue
Normal file
183
app/src/components/steps/IncomeStep.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<q-card flat class="full-width q-pa-none">
|
||||
<q-card-section class="full-width">
|
||||
<div class="row items-center">
|
||||
<div class="col">
|
||||
<div class="text-h5">{{ t('INC') }}</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat :label="t('button.prev')" @click="emitPrev" class="q-mr-sm" />
|
||||
<q-btn color="primary" :label="t('button.next')" @click="emitNext" />
|
||||
</div>
|
||||
</div>
|
||||
<q-separator class="q-my-sm" />
|
||||
|
||||
<q-form class="q-gutter-md q-mt-md">
|
||||
<q-select
|
||||
v-model="form.employType"
|
||||
:options="employTypeOptions"
|
||||
:label="t('income.employTypeLabel')"
|
||||
:hint="!form.employType ? t('income.employTypeHint') : ''"
|
||||
:persistent-hint="!form.employType"
|
||||
emit-value
|
||||
map-options
|
||||
/>
|
||||
|
||||
<SimpleAttachment
|
||||
v-if="isFieldActive('salaryCertificate')"
|
||||
v-model="form.attachments.salaryCertificate"
|
||||
:label="t('income.attachments.salaryCertificate')"
|
||||
:session="'income'"
|
||||
:id="'salaryCertificate'"
|
||||
:autoload="false"
|
||||
/>
|
||||
<SimpleAttachment
|
||||
v-if="isFieldActive('accountingDocuments')"
|
||||
v-model="form.attachments.accountingDocuments"
|
||||
:label="t('income.attachments.accountingDocuments')"
|
||||
:session="'income'"
|
||||
:id="'accountingDocuments'"
|
||||
:autoload="false"
|
||||
/>
|
||||
<SimpleAttachment
|
||||
v-if="isFieldActive('avsCertificate')"
|
||||
v-model="form.attachments.avsCertificate"
|
||||
:label="t('income.attachments.avsCertificate')"
|
||||
:session="'income'"
|
||||
:id="'avsCertificate'"
|
||||
:autoload="false"
|
||||
/>
|
||||
<SimpleAttachment
|
||||
v-if="isFieldActive('lppCertificate')"
|
||||
v-model="form.attachments.lppCertificate"
|
||||
:label="t('income.attachments.lppCertificate')"
|
||||
:session="'income'"
|
||||
:id="'lppCertificate'"
|
||||
:autoload="false"
|
||||
/>
|
||||
<SimpleAttachment
|
||||
v-if="isFieldActive('unemploymentCertificate')"
|
||||
v-model="form.attachments.unemploymentCertificate"
|
||||
:label="t('income.attachments.unemploymentCertificate')"
|
||||
:session="'income'"
|
||||
:id="'unemploymentCertificate'"
|
||||
:autoload="false"
|
||||
/>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { IncomeData, StepDescriptor } from '../../types/types'
|
||||
import SimpleAttachment from '../SimpleAttachment.vue'
|
||||
import { useUserstore } from '../../stores/userstore'
|
||||
import { loadAttachmentsList } from '../../utils/api'
|
||||
import { useIncomeStore } from '../../stores/income'
|
||||
|
||||
const props = defineProps<{ step?: StepDescriptor }>()
|
||||
const emit = defineEmits(['next', 'prev'])
|
||||
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserstore()
|
||||
const incomeStore = useIncomeStore()
|
||||
|
||||
const employTypeData = ["EMPLOYED", "SELF_EMPLOYED", "PENSIONER", "UNEMPLOYED"] as const
|
||||
|
||||
const incomeInfo = {
|
||||
EMPLOYED: {activeFields: ['salaryCertificate', 'accountingDocuments'], nextStep: 'marital'},
|
||||
SELF_EMPLOYED: {activeFields: [ 'accountingDocuments'], nextStep: 'marital'},
|
||||
PENSIONER: {activeFields: [ 'accountingDocuments', 'avsCertificate', 'lppCertificate' ], nextStep: 'marital'},
|
||||
UNEMPLOYED: {activeFields: ['unemploymentCertificate'], nextStep: 'marital'}
|
||||
} as const
|
||||
|
||||
const form = incomeStore.data as IncomeData
|
||||
|
||||
const attachmentKeys = [
|
||||
'salaryCertificate',
|
||||
'accountingDocuments',
|
||||
'avsCertificate',
|
||||
'lppCertificate',
|
||||
'unemploymentCertificate',
|
||||
] as const
|
||||
|
||||
type AttachmentKey = keyof IncomeData['attachments']
|
||||
|
||||
const activeFieldSet = computed<Set<AttachmentKey>>(() => {
|
||||
const selected = form.employType
|
||||
if (!selected) return new Set<AttachmentKey>()
|
||||
|
||||
const info = incomeInfo[selected as keyof typeof incomeInfo]
|
||||
const keys = (info?.activeFields ?? []) as unknown as AttachmentKey[]
|
||||
return new Set(keys)
|
||||
})
|
||||
|
||||
function isFieldActive(key: AttachmentKey) {
|
||||
return activeFieldSet.value.has(key)
|
||||
}
|
||||
|
||||
function clearInactiveAttachmentFields() {
|
||||
for (const key of attachmentKeys) {
|
||||
if (!isFieldActive(key)) {
|
||||
form.attachments[key].comments = ''
|
||||
form.attachments[key].attachments = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => form.employType,
|
||||
() => {
|
||||
clearInactiveAttachmentFields()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => form,
|
||||
() => {
|
||||
incomeStore.persist()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const employTypeOptions = computed(() =>
|
||||
employTypeData.map(v => ({ label: t(`income.employType.${v}`), value: v }))
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
const id = userStore.id
|
||||
if (!id) return
|
||||
|
||||
// session folder name on backend
|
||||
const session = 'income'
|
||||
|
||||
try {
|
||||
const list = await loadAttachmentsList({ id, session })
|
||||
for (const key of attachmentKeys) {
|
||||
const files = list[key]
|
||||
if (Array.isArray(files)) {
|
||||
form.attachments[key].attachments = [...files]
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore network errors
|
||||
}
|
||||
})
|
||||
|
||||
function emitNext() {
|
||||
emit('next', props.step?.next)
|
||||
}
|
||||
|
||||
function emitPrev() {
|
||||
emit('prev', props.step?.prev)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.q-card {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
275
app/src/components/steps/MaritalStep.vue
Normal file
275
app/src/components/steps/MaritalStep.vue
Normal file
@@ -0,0 +1,275 @@
|
||||
<template>
|
||||
<q-card flat class="full-width q-pa-none">
|
||||
<q-card-section class="full-width">
|
||||
<div class="row items-center">
|
||||
<div class="col">
|
||||
<div class="text-h6">{{ t("MAR") }}</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat :label="t('button.prev')" @click="emitPrev" class="q-mr-sm" />
|
||||
<q-btn color="primary" :label="t('button.next')" @click="saveAndNext" />
|
||||
</div>
|
||||
</div>
|
||||
<q-separator class="q-my-sm" />
|
||||
|
||||
<q-form ref="formRef" class="form q-gutter-md q-mt-md">
|
||||
<!-- explicit fields for marital step (no v-for) -->
|
||||
<div class="row items-center q-gutter-md q-mb-sm q-ml-none">
|
||||
<div class="col q-ml-none">
|
||||
<q-toggle v-model="form.alimentiVersati" :label="t('children.alimentiVersati')" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="form.alimentiVersati" class="row">
|
||||
<CommentAttachment v-model="form.alimentiCommenti" :label="t('informazionesualimenti')" :id="'marital'"/>
|
||||
</div>
|
||||
<!-- maritalStatus (enum) -->
|
||||
<q-select
|
||||
:model-value="form.maritalStatus"
|
||||
:label="$t('marital.maritalStatus')"
|
||||
@update:model-value="val => form.maritalStatus = val"
|
||||
:options="getOptions()"
|
||||
:rules="maritalStatusRules"
|
||||
emit-value
|
||||
map-options
|
||||
/>
|
||||
|
||||
<!-- spouse fields (visible only when married/partnered) -->
|
||||
<template v-if="maritalconfig[form.maritalStatus]?.showSpouseData">
|
||||
<div class="row ">{{ $t(maritalconfig[form.maritalStatus]?.data) }}</div>
|
||||
<q-input
|
||||
:model-value="form.spouseFirstName"
|
||||
@update:model-value="val => form.spouseFirstName = val"
|
||||
:label="$t('marital.spouse.firstName')"
|
||||
:rules="[required()]"
|
||||
/>
|
||||
|
||||
<q-input
|
||||
:model-value="form.spouseLastName"
|
||||
@update:model-value="val => form.spouseLastName = val"
|
||||
:label="$t('marital.spouse.lastName')"
|
||||
:rules="[required()]"
|
||||
/>
|
||||
|
||||
<q-input
|
||||
:model-value="form.spouseBirthDate"
|
||||
@update:model-value="val => form.spouseBirthDate = val"
|
||||
:label="$t('marital.spouse.birthDate')"
|
||||
type="date"
|
||||
:rules="[required()]"
|
||||
/>
|
||||
|
||||
<q-input
|
||||
v-if="maritalconfig[form.maritalStatus]?.deadDate"
|
||||
:model-value="form.spouseDeadDate"
|
||||
@update:model-value="val => form.spouseDeadDate = val"
|
||||
:label="$t(maritalconfig[form.maritalStatus]?.deadDateLabel)"
|
||||
type="date"
|
||||
:rules="[required()]"
|
||||
/>
|
||||
|
||||
<AddressInput
|
||||
v-if="maritalconfig[form.maritalStatus]?.address"
|
||||
v-model="form.spouseAddress"
|
||||
:hint="t(maritalconfig[form.maritalStatus]?.addressHint)"
|
||||
:label="t('taxpayer.address')"
|
||||
:allowForeign="true"
|
||||
@save="onSpouseAddressSave"
|
||||
@cancel="onSpouseAddressCancel"
|
||||
/>
|
||||
|
||||
</template>
|
||||
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
|
||||
|
||||
</q-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, onMounted, nextTick, ref, } from 'vue'
|
||||
import type { StepDescriptor } from '../../types/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useTaxstore } from '../../stores/taxstore'
|
||||
import type { AddressOut } from '../AddressInput.vue'
|
||||
import AddressInput from '../AddressInput.vue'
|
||||
import CommentAttachment from '../CommentAttachment.vue'
|
||||
import {type MaritalData } from 'src/stores/marital'
|
||||
|
||||
interface option{ label: string; value: string }
|
||||
|
||||
type Options = option[]
|
||||
|
||||
const maritalItems = [
|
||||
'celibate',
|
||||
'maried',
|
||||
'registrated',
|
||||
'widower',
|
||||
'deadunion',
|
||||
'divorced',
|
||||
'unionlegal',
|
||||
'separated',
|
||||
'uniondisappeared',
|
||||
]
|
||||
|
||||
const maritalconfig = {
|
||||
celibate:{
|
||||
showSpouseData: false,
|
||||
address: ''
|
||||
,data: ''
|
||||
,deadDate: false
|
||||
,deadDateLabel: ''
|
||||
,addressHint: ''
|
||||
},
|
||||
maried:{
|
||||
showSpouseData: true,
|
||||
address: 'indirizzocogniuge'
|
||||
,data: 'daticogniuge'
|
||||
,deadDate: false
|
||||
,deadDateLabel: ''
|
||||
,addressHint: 'inserireindirizzocogniuge'
|
||||
},
|
||||
registrated:{
|
||||
showSpouseData: true,
|
||||
address: 'indirizzopartner'
|
||||
,data: 'datipartner'
|
||||
,deadDate: false
|
||||
,deadDateLabel: ''
|
||||
,addressHint: 'inserireindirizzopartner'
|
||||
},
|
||||
widower:{
|
||||
showSpouseData: true,
|
||||
address: ''
|
||||
,data: 'daticogniugedefunto'
|
||||
,deadDate: true
|
||||
,deadDateLabel: 'datadecesso'
|
||||
,addressHint: ''
|
||||
},
|
||||
deadunion:{
|
||||
showSpouseData: true,
|
||||
address: ''
|
||||
,data: 'datideadpartner'
|
||||
,deadDate: true
|
||||
,deadDateLabel: 'datadecesso'
|
||||
,addressHint: ''
|
||||
},
|
||||
divorced:{
|
||||
showSpouseData: true,
|
||||
address: 'indirizzoexcogniuge'
|
||||
,data: 'datiexcogniuge'
|
||||
,deadDate: false
|
||||
,deadDateLabel: ''
|
||||
,addressHint: 'inserireindirizzocogniuge'
|
||||
},
|
||||
unionlegal:{
|
||||
showSpouseData: true,
|
||||
address: 'indirizzoexpartner'
|
||||
,data: 'datideadexpartner'
|
||||
,deadDate: true
|
||||
,deadDateLabel: 'datascioglimento'
|
||||
,addressHint: 'inserireindirizzopartner'
|
||||
},
|
||||
separated:{
|
||||
showSpouseData: true,
|
||||
address: 'indirizzoexcogniuge'
|
||||
,data: 'datiexcogniuge'
|
||||
,deadDate: false
|
||||
,deadDateLabel: ''
|
||||
,addressHint: 'inserireindirizzocogniuge'
|
||||
},
|
||||
uniondisappeared:{
|
||||
showSpouseData: true,
|
||||
address: ''
|
||||
,data: 'datidisapparizedpartner'
|
||||
,deadDate: true
|
||||
,deadDateLabel: 'datascomparsa'
|
||||
,addressHint: ''
|
||||
}
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useTaxstore()
|
||||
const { step } = defineProps<{ step?: StepDescriptor }>()
|
||||
const emit = defineEmits(['next', 'prev'])
|
||||
// reactive form accepts dynamic keys produced from schema
|
||||
const form = reactive<MaritalData>({} as MaritalData)
|
||||
|
||||
// QForm ref for Quasar validation
|
||||
const formRef = ref<{ validate?: () => Promise<boolean> | boolean; resetValidation?: () => void } | null>(null)
|
||||
|
||||
// spouse address modal state
|
||||
const isSpouseAddressDialogOpen = ref(false)
|
||||
|
||||
function getOptions() {
|
||||
const items = [] as Options
|
||||
for (const item of maritalItems){
|
||||
items.push({label:t(`maritalItem.${item}`), value:item})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
function onSpouseAddressCancel() {
|
||||
isSpouseAddressDialogOpen.value = false
|
||||
}
|
||||
|
||||
function onSpouseAddressSave(a: AddressOut) {
|
||||
form.spouseAddress = a
|
||||
isSpouseAddressDialogOpen.value = false
|
||||
}
|
||||
|
||||
// helper for validation messages
|
||||
const requiredMessage = (fallback = 'Required') => {
|
||||
const msg = t('validation.required')
|
||||
return msg && msg !== 'validation.required' ? msg : fallback
|
||||
}
|
||||
|
||||
const required = (msg?: string) => (v: unknown) => {
|
||||
const message = msg || requiredMessage()
|
||||
if (v === null || v === undefined) return message
|
||||
if (typeof v === 'string') return (v.trim() !== '') || message
|
||||
return true
|
||||
}
|
||||
|
||||
const maritalStatusRules = [required()]
|
||||
|
||||
onMounted(async () => {
|
||||
const saved = store.getMarital()
|
||||
// initialize form with saved marital data (map keys directly)
|
||||
if (saved) {
|
||||
Object.assign(form, saved)
|
||||
}
|
||||
// clear any validation state on mount so fields are not marked invalid initially
|
||||
await nextTick()
|
||||
formRef.value?.resetValidation?.()
|
||||
})
|
||||
|
||||
function buildPayload(): MaritalData {
|
||||
const payload = {} as MaritalData
|
||||
// copy all form fields into payload
|
||||
for (const [k, v] of Object.entries(form)) {
|
||||
payload[k] = v
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
async function saveAndNext() {
|
||||
const ok = await formRef.value?.validate?.()
|
||||
if (ok === false) return
|
||||
const payload = buildPayload()
|
||||
store.setMarital(payload)
|
||||
emit('next', step?.next)
|
||||
}
|
||||
|
||||
function emitPrev() {
|
||||
const payload = buildPayload()
|
||||
store.setMarital(payload)
|
||||
emit('prev', step?.prev)
|
||||
}
|
||||
|
||||
// expose buildPayload so parent stepper can trigger save/navigation if needed
|
||||
defineExpose({ buildPayload })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.q-card { width: 100%; margin: 0; }
|
||||
</style>
|
||||
148
app/src/components/steps/TaxpayerStep.vue
Normal file
148
app/src/components/steps/TaxpayerStep.vue
Normal 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>
|
||||
36
app/src/components/steps/WelcomeStep.vue
Normal file
36
app/src/components/steps/WelcomeStep.vue
Normal 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
1
app/src/css/app.scss
Normal file
@@ -0,0 +1 @@
|
||||
// app global css in SCSS form
|
||||
25
app/src/css/quasar.variables.scss
Normal file
25
app/src/css/quasar.variables.scss
Normal 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
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
7
app/src/env.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
7
app/src/i18n/en-US/index.ts
Normal file
7
app/src/i18n/en-US/index.ts
Normal 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
22
app/src/i18n/index.ts
Normal 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 }
|
||||
}
|
||||
14
app/src/i18n/locales/de-DE/attachments.ts
Normal file
14
app/src/i18n/locales/de-DE/attachments.ts
Normal 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
|
||||
33
app/src/i18n/locales/de-DE/common.ts
Normal file
33
app/src/i18n/locales/de-DE/common.ts
Normal 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
|
||||
118
app/src/i18n/locales/de-DE/steps.ts
Normal file
118
app/src/i18n/locales/de-DE/steps.ts
Normal 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
|
||||
14
app/src/i18n/locales/en-US/attachments.ts
Normal file
14
app/src/i18n/locales/en-US/attachments.ts
Normal 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
|
||||
33
app/src/i18n/locales/en-US/common.ts
Normal file
33
app/src/i18n/locales/en-US/common.ts
Normal 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
|
||||
118
app/src/i18n/locales/en-US/steps.ts
Normal file
118
app/src/i18n/locales/en-US/steps.ts
Normal 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
|
||||
14
app/src/i18n/locales/fr-FR/attachments.ts
Normal file
14
app/src/i18n/locales/fr-FR/attachments.ts
Normal 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
|
||||
33
app/src/i18n/locales/fr-FR/common.ts
Normal file
33
app/src/i18n/locales/fr-FR/common.ts
Normal 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
|
||||
118
app/src/i18n/locales/fr-FR/steps.ts
Normal file
118
app/src/i18n/locales/fr-FR/steps.ts
Normal 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
|
||||
14
app/src/i18n/locales/it-IT/attachments.ts
Normal file
14
app/src/i18n/locales/it-IT/attachments.ts
Normal 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
|
||||
33
app/src/i18n/locales/it-IT/common.ts
Normal file
33
app/src/i18n/locales/it-IT/common.ts
Normal 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
|
||||
118
app/src/i18n/locales/it-IT/steps.ts
Normal file
118
app/src/i18n/locales/it-IT/steps.ts
Normal 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 all’estero",
|
||||
|
||||
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
|
||||
102
app/src/layouts/MainLayout.vue
Normal file
102
app/src/layouts/MainLayout.vue
Normal 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>
|
||||
23
app/src/pages/ErrorNotFound.vue
Normal file
23
app/src/pages/ErrorNotFound.vue
Normal 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>
|
||||
26
app/src/pages/IndexPage.vue
Normal file
26
app/src/pages/IndexPage.vue
Normal 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
37
app/src/router/index.ts
Normal 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
18
app/src/router/routes.ts
Normal 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
283
app/src/schema.json
Normal 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 all’estero",
|
||||
"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
102
app/src/stores/children.ts
Normal 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
66
app/src/stores/income.ts
Normal 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
32
app/src/stores/index.ts
Normal 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
40
app/src/stores/marital.ts
Normal 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 }
|
||||
}
|
||||
34
app/src/stores/taxpayer.ts
Normal file
34
app/src/stores/taxpayer.ts
Normal 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
121
app/src/stores/taxstore.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
})
|
||||
9
app/src/stores/userstore.ts
Normal file
9
app/src/stores/userstore.ts
Normal 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
17
app/src/types/address.ts
Normal 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
41
app/src/types/types.d.ts
vendored
Normal 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
256
app/src/utils/api.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user