Files
InvoiceShelf/resources/scripts/features/installation/views/PreferencesView.vue
Darko Gjorgjijoski 112cc56922 chore(infra): default mail driver to sendmail and expose Vue runtime
Mail DEFAULT_DRIVER changes from smtp to sendmail; DRIVER_ORDER is reshuffled so sendmail is the head of the list on fresh installs. This matches what most self-hosted installs already have working out of the box — SMTP requires provider credentials the typical user doesn't have set up yet. The mail config description is rewritten to drop the 'Laravel' framework reference and to explicitly tell unsure users to leave it on sendmail.

SiteApi::get() now catches GuzzleException (the broader interface) and returns null on network failure instead of bubbling the exception object — callers were treating a non-array return as 'marketplace unavailable' anyway, so null is the correct shape.

main.ts exposes the Vue runtime on window.__invoiceshelf_vue so module JS (compiled against the host's Vue install) can call createApp / defineComponent without re-bundling Vue. invoiceshelf.css adds Tailwind source globs for Modules/**/*.{js,ts,vue,blade.php} so module-contributed classes are picked up by the host CSS pipeline.

Installation wizard PreferencesView was already in the tree waiting for the API field rename (date_formats, time_zones, fiscal_years, languages) that landed in setting.service.ts; this commit catches both sides up together.
2026-04-11 02:00:00 +02:00

310 lines
9.5 KiB
Vue

<template>
<BaseWizardStep
:title="$t('wizard.preferences')"
:description="$t('wizard.preferences_desc')"
>
<form @submit.prevent="next">
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup
:label="$t('wizard.currency')"
:error="v$.currency.$error ? String(v$.currency.$errors[0]?.$message) : undefined"
:content-loading="isFetchingInitialData"
required
>
<BaseMultiselect
v-model="currentPreferences.currency"
:content-loading="isFetchingInitialData"
:options="currencies"
label="name"
value-prop="id"
:searchable="true"
track-by="name"
:placeholder="$t('settings.currencies.select_currency')"
:invalid="v$.currency.$error"
class="w-full"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.preferences.default_language')"
:error="v$.language.$error ? String(v$.language.$errors[0]?.$message) : undefined"
:content-loading="isFetchingInitialData"
required
>
<BaseMultiselect
v-model="currentPreferences.language"
:content-loading="isFetchingInitialData"
:options="languages"
label="name"
value-prop="code"
:placeholder="$t('settings.preferences.select_language')"
class="w-full"
track-by="name"
:searchable="true"
:invalid="v$.language.$error"
/>
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup
:label="$t('wizard.date_format')"
:error="v$.carbon_date_format.$error ? String(v$.carbon_date_format.$errors[0]?.$message) : undefined"
:content-loading="isFetchingInitialData"
required
>
<BaseMultiselect
v-model="currentPreferences.carbon_date_format"
:content-loading="isFetchingInitialData"
:options="dateFormats"
label="display_date"
value-prop="carbon_format_value"
:placeholder="$t('settings.preferences.select_date_format')"
track-by="display_date"
searchable
:invalid="v$.carbon_date_format.$error"
class="w-full"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('wizard.time_zone')"
:error="v$.time_zone.$error ? String(v$.time_zone.$errors[0]?.$message) : undefined"
:content-loading="isFetchingInitialData"
required
>
<BaseMultiselect
v-model="currentPreferences.time_zone"
:content-loading="isFetchingInitialData"
:options="timeZones"
label="key"
value-prop="value"
:placeholder="$t('settings.preferences.select_time_zone')"
track-by="key"
:searchable="true"
:invalid="v$.time_zone.$error"
/>
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2">
<BaseInputGroup
:label="$t('wizard.fiscal_year')"
:error="v$.fiscal_year.$error ? String(v$.fiscal_year.$errors[0]?.$message) : undefined"
:content-loading="isFetchingInitialData"
required
>
<BaseMultiselect
v-model="currentPreferences.fiscal_year"
:content-loading="isFetchingInitialData"
:options="fiscalYearsList"
label="key"
value-prop="value"
:placeholder="$t('settings.preferences.select_financial_year')"
:invalid="v$.fiscal_year.$error"
track-by="key"
:searchable="true"
class="w-full"
/>
</BaseInputGroup>
</div>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
:content-loading="isFetchingInitialData"
class="mt-4"
>
<template #left="slotProps">
<BaseIcon name="ArrowDownOnSquareIcon" :class="slotProps.class" />
</template>
{{ $t('wizard.save_cont') }}
</BaseButton>
</form>
</BaseWizardStep>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { required, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { installClient } from '../../../api/install-client'
import { API } from '../../../api/endpoints'
import { useDialogStore } from '../../../stores/dialog.store'
import { clearInstallWizardAuth } from '../install-auth'
import { useInstallationFeedback } from '../use-installation-feedback'
interface PreferencesData {
currency: number | null
language: string
carbon_date_format: string
time_zone: string
fiscal_year: string
}
interface KeyValueOption {
key: string
value: string
}
interface DateFormatOption {
display_date: string
carbon_format_value: string
}
interface CurrencyOption {
id: number
name: string
code: string
}
interface LanguageOption {
code: string
name: string
}
const dialogStore = useDialogStore()
const { t } = useI18n()
const router = useRouter()
const { isSuccessfulResponse, showRequestError, showResponseError } = useInstallationFeedback()
const isSaving = ref<boolean>(false)
const isFetchingInitialData = ref<boolean>(false)
const currencies = ref<CurrencyOption[]>([])
const languages = ref<LanguageOption[]>([])
const dateFormats = ref<DateFormatOption[]>([])
const timeZones = ref<KeyValueOption[]>([])
const fiscalYears = ref<KeyValueOption[]>([])
const currentPreferences = reactive<PreferencesData>({
currency: null,
language: 'en',
carbon_date_format: 'd M Y',
time_zone: 'UTC',
fiscal_year: '1-12',
})
const fiscalYearsList = computed<KeyValueOption[]>(() => {
return fiscalYears.value.map((item) => ({
...item,
key: t(item.key),
}))
})
const rules = computed(() => ({
currency: {
required: helpers.withMessage(t('validation.required'), required),
},
language: {
required: helpers.withMessage(t('validation.required'), required),
},
carbon_date_format: {
required: helpers.withMessage(t('validation.required'), required),
},
time_zone: {
required: helpers.withMessage(t('validation.required'), required),
},
fiscal_year: {
required: helpers.withMessage(t('validation.required'), required),
},
}))
const v$ = useVuelidate(rules, currentPreferences)
onMounted(async () => {
isFetchingInitialData.value = true
try {
const [currRes, dateRes, tzRes, fyRes, langRes] = await Promise.all([
installClient.get(API.CURRENCIES),
installClient.get(API.DATE_FORMATS),
installClient.get(API.TIMEZONES),
installClient.get(`${API.CONFIG}?key=fiscal_years`),
installClient.get(`${API.CONFIG}?key=languages`),
])
const rawCurrencies: CurrencyOption[] = currRes.data.data ?? currRes.data
currencies.value = rawCurrencies.map((c) => ({
...c,
name: `${c.code} - ${c.name}`,
}))
if (!currentPreferences.currency) {
const usd = currencies.value.find((c) => c.code === 'USD')
if (usd) {
currentPreferences.currency = usd.id
}
}
dateFormats.value = dateRes.data.date_formats ?? dateRes.data.data ?? dateRes.data
timeZones.value = tzRes.data.time_zones ?? tzRes.data.data ?? tzRes.data
fiscalYears.value = fyRes.data.fiscal_years ?? fyRes.data.data ?? fyRes.data ?? []
languages.value = langRes.data.languages ?? langRes.data.data ?? langRes.data ?? []
} catch (error: unknown) {
showRequestError(error)
} finally {
isFetchingInitialData.value = false
}
})
function next(): void {
v$.value.$touch()
if (v$.value.$invalid) return
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('wizard.currency_set_alert'),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'danger',
hideNoButton: false,
size: 'lg',
})
.then(async (res: boolean) => {
if (res) {
isSaving.value = true
try {
const settingsPayload = {
settings: { ...currentPreferences },
}
const { data: response } = await installClient.post(API.COMPANY_SETTINGS, settingsPayload)
if (response) {
const userSettings = {
settings: { language: currentPreferences.language },
}
await installClient.put(API.ME_SETTINGS, userSettings)
const { data: sessionLoginResponse } = await installClient.post(
API.INSTALLATION_SESSION_LOGIN,
)
if (!isSuccessfulResponse(sessionLoginResponse)) {
showResponseError(sessionLoginResponse)
return
}
// Mark the install as complete on the backend so the
// InstallationMiddleware stops redirecting to /installation. The
// OnboardingWizardController persists this to
// Setting::profile_complete.
await installClient.post(API.INSTALLATION_WIZARD_STEP, {
profile_complete: 'COMPLETED',
})
clearInstallWizardAuth()
await router.push('/admin/dashboard')
}
} catch (error: unknown) {
showRequestError(error)
} finally {
isSaving.value = false
}
}
})
}
</script>