Phase 4b: Remaining features — payments, expenses, recurring

invoices, members, reports, settings, customer portal, modules,
installation

82 files, 14293 lines. Completes all feature modules:
- payments: CRUD with send/preview, payment modes
- expenses: CRUD with receipt upload, categories
- recurring-invoices: full frequency logic, limit by date/count
- members: list with roles, invite modal, pending invitations
- reports: sales, profit/loss, expenses, tax with date ranges
- settings: 14 settings views, number customizer, mail config
- customer-portal: consolidated store, 8 views, portal layout
- modules: marketplace index, detail/install, module cards
- installation: 8-step wizard with requirements/db/mail/account

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Darko Gjorgjijoski
2026-04-04 07:30:00 +02:00
parent 774b2614f0
commit d91f6ff2e3
82 changed files with 14293 additions and 0 deletions

View File

@@ -0,0 +1,209 @@
<template>
<BaseWizardStep
:title="$t('wizard.account_info')"
:description="$t('wizard.account_info_desc')"
>
<form @submit.prevent="next">
<div class="grid grid-cols-1 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup :label="$t('settings.account_settings.profile_picture')">
<BaseFileUploader
:avatar="true"
:preview-image="avatarUrl"
@change="onFileInputChange"
@remove="onFileInputRemove"
/>
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup
:label="$t('wizard.name')"
:error="v$.name.$error ? String(v$.name.$errors[0]?.$message) : undefined"
required
>
<BaseInput
v-model.trim="userForm.name"
:invalid="v$.name.$error"
type="text"
name="name"
@input="v$.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('wizard.email')"
:error="v$.email.$error ? String(v$.email.$errors[0]?.$message) : undefined"
required
>
<BaseInput
v-model.trim="userForm.email"
:invalid="v$.email.$error"
type="text"
name="email"
@input="v$.email.$touch()"
/>
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2">
<BaseInputGroup
:label="$t('wizard.password')"
:error="v$.password.$error ? String(v$.password.$errors[0]?.$message) : undefined"
required
>
<BaseInput
v-model.trim="userForm.password"
:invalid="v$.password.$error"
:type="isShowPassword ? 'text' : 'password'"
name="password"
@input="v$.password.$touch()"
>
<template #right>
<BaseIcon
:name="isShowPassword ? 'EyeIcon' : 'EyeSlashIcon'"
class="mr-1 text-muted cursor-pointer"
@click="isShowPassword = !isShowPassword"
/>
</template>
</BaseInput>
</BaseInputGroup>
<BaseInputGroup
:label="$t('wizard.confirm_password')"
:error="v$.confirm_password.$error ? String(v$.confirm_password.$errors[0]?.$message) : undefined"
required
>
<BaseInput
v-model.trim="userForm.confirm_password"
:invalid="v$.confirm_password.$error"
:type="isShowConfirmPassword ? 'text' : 'password'"
name="confirm_password"
@input="v$.confirm_password.$touch()"
>
<template #right>
<BaseIcon
:name="isShowConfirmPassword ? 'EyeIcon' : 'EyeSlashIcon'"
class="mr-1 text-muted cursor-pointer"
@click="isShowConfirmPassword = !isShowConfirmPassword"
/>
</template>
</BaseInput>
</BaseInputGroup>
</div>
<BaseButton :loading="isSaving" :disabled="isSaving" class="mt-4">
<template #left="slotProps">
<BaseIcon v-if="!isSaving" name="ArrowDownOnSquareIcon" :class="slotProps.class" />
</template>
{{ $t('wizard.save_cont') }}
</BaseButton>
</form>
</BaseWizardStep>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import {
helpers,
required,
requiredIf,
sameAs,
minLength,
email,
} from '@vuelidate/validators'
import { useVuelidate } from '@vuelidate/core'
import { client } from '../../../api/client'
interface UserForm {
name: string
email: string
password: string
confirm_password: string
}
interface Emits {
(e: 'next', step: number): void
}
const emit = defineEmits<Emits>()
const { t } = useI18n()
const isSaving = ref<boolean>(false)
const isShowPassword = ref<boolean>(false)
const isShowConfirmPassword = ref<boolean>(false)
const avatarUrl = ref<string>('')
const avatarFileBlob = ref<File | null>(null)
const userForm = reactive<UserForm>({
name: '',
email: '',
password: '',
confirm_password: '',
})
const rules = computed(() => ({
name: {
required: helpers.withMessage(t('validation.required'), required),
},
email: {
required: helpers.withMessage(t('validation.required'), required),
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
password: {
required: helpers.withMessage(t('validation.required'), required),
minLength: helpers.withMessage(
t('validation.password_min_length', { count: 8 }),
minLength(8),
),
},
confirm_password: {
required: helpers.withMessage(
t('validation.required'),
requiredIf(() => !!userForm.password),
),
sameAsPassword: helpers.withMessage(
t('validation.password_incorrect'),
sameAs(computed(() => userForm.password)),
),
},
}))
const v$ = useVuelidate(rules, userForm)
function onFileInputChange(_fileName: string, file: File): void {
avatarFileBlob.value = file
}
function onFileInputRemove(): void {
avatarFileBlob.value = null
}
async function next(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) return
isSaving.value = true
try {
const { data: res } = await client.put('/api/v1/me', userForm)
if (res.data) {
if (avatarFileBlob.value) {
const avatarData = new FormData()
avatarData.append('admin_avatar', avatarFileBlob.value)
await client.post('/api/v1/me/upload-avatar', avatarData)
}
const company = res.data.companies?.[0]
if (company) {
localStorage.setItem('selectedCompany', String(company.id))
}
emit('next', 6)
}
} finally {
isSaving.value = false
}
}
</script>

View File

@@ -0,0 +1,254 @@
<template>
<BaseWizardStep
:title="$t('wizard.company_info')"
:description="$t('wizard.company_info_desc')"
step-container="bg-surface border border-line-default border-solid mb-8 md:w-full p-8 rounded w-full"
>
<form @submit.prevent="next">
<div class="grid grid-cols-1 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup :label="$t('settings.company_info.company_logo')">
<BaseFileUploader
base64
:preview-image="previewLogo"
@change="onFileInputChange"
@remove="onFileInputRemove"
/>
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup
:label="$t('wizard.company_name')"
:error="v$.name.$error ? String(v$.name.$errors[0]?.$message) : undefined"
required
>
<BaseInput
v-model.trim="companyForm.name"
:invalid="v$.name.$error"
type="text"
name="name"
@input="v$.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('wizard.country')"
:error="v$.country_id.$error ? String(v$.country_id.$errors[0]?.$message) : undefined"
:content-loading="isFetchingInitialData"
required
>
<BaseMultiselect
v-model="companyForm.address.country_id"
label="name"
:invalid="v$.country_id.$error"
:options="countries"
value-prop="id"
:can-deselect="false"
:can-clear="false"
:content-loading="isFetchingInitialData"
:placeholder="$t('general.select_country')"
searchable
track-by="name"
/>
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup :label="$t('wizard.state')">
<BaseInput v-model="companyForm.address.state" name="state" type="text" />
</BaseInputGroup>
<BaseInputGroup :label="$t('wizard.city')">
<BaseInput v-model="companyForm.address.city" name="city" type="text" />
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2">
<div>
<BaseInputGroup
:label="$t('wizard.address')"
:error="v$.address_street_1.$error ? String(v$.address_street_1.$errors[0]?.$message) : undefined"
>
<BaseTextarea
v-model.trim="companyForm.address.address_street_1"
:invalid="v$.address_street_1.$error"
:placeholder="$t('general.street_1')"
name="billing_street1"
rows="2"
@input="v$.address_street_1.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup class="mt-1 lg:mt-2 md:mt-2">
<BaseTextarea
v-model="companyForm.address.address_street_2"
:placeholder="$t('general.street_2')"
name="billing_street2"
rows="2"
/>
</BaseInputGroup>
</div>
<div>
<BaseInputGroup :label="$t('wizard.zip_code')">
<BaseInput v-model.trim="companyForm.address.zip" type="text" name="zip" />
</BaseInputGroup>
<BaseInputGroup :label="$t('wizard.phone')" class="mt-4">
<BaseInput v-model.trim="companyForm.address.phone" type="text" name="phone" />
</BaseInputGroup>
</div>
<BaseInputGroup :label="$t('settings.company_info.tax_id')">
<BaseInput v-model.trim="companyForm.tax_id" type="text" name="tax_id" />
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.company_info.vat_id')">
<BaseInput v-model.trim="companyForm.vat_id" type="text" name="vat_id" />
</BaseInputGroup>
</div>
<BaseButton :loading="isSaving" :disabled="isSaving" class="mt-4">
<template #left="slotProps">
<BaseIcon v-if="!isSaving" 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 { required, maxLength, helpers } from '@vuelidate/validators'
import { useVuelidate } from '@vuelidate/core'
import { client } from '../../../api/client'
import { API } from '../../../api/endpoints'
import type { Country } from '../../../types/domain/customer'
interface CompanyAddress {
address_street_1: string
address_street_2: string
website: string
country_id: number | null
state: string
city: string
phone: string
zip: string
}
interface CompanyFormData {
name: string | null
tax_id: string | null
vat_id: string | null
address: CompanyAddress
}
interface Emits {
(e: 'next', step: number): void
}
const emit = defineEmits<Emits>()
const { t } = useI18n()
const isFetchingInitialData = ref<boolean>(false)
const isSaving = ref<boolean>(false)
const previewLogo = ref<string | null>(null)
const logoFileBlob = ref<string | null>(null)
const logoFileName = ref<string | null>(null)
const countries = ref<Country[]>([])
const companyForm = reactive<CompanyFormData>({
name: null,
tax_id: null,
vat_id: null,
address: {
address_street_1: '',
address_street_2: '',
website: '',
country_id: null,
state: '',
city: '',
phone: '',
zip: '',
},
})
const rules = computed(() => ({
name: {
required: helpers.withMessage(t('validation.required'), required),
},
country_id: {
required: helpers.withMessage(t('validation.required'), required),
},
address_street_1: {
maxLength: helpers.withMessage(
t('validation.address_maxlength', { count: 255 }),
maxLength(255),
),
},
}))
const validationState = computed(() => ({
name: companyForm.name,
country_id: companyForm.address.country_id,
address_street_1: companyForm.address.address_street_1,
}))
const v$ = useVuelidate(rules, validationState)
onMounted(async () => {
isFetchingInitialData.value = true
try {
const { data } = await client.get(API.COUNTRIES)
countries.value = data.data ?? data
// Default to US
const us = countries.value.find((c) => c.code === 'US')
if (us) companyForm.address.country_id = us.id
} finally {
isFetchingInitialData.value = false
}
})
function onFileInputChange(
_fileName: string,
file: string,
_fileCount: number,
fileList: { name: string },
): void {
logoFileName.value = fileList.name
logoFileBlob.value = file
}
function onFileInputRemove(): void {
logoFileBlob.value = null
}
async function next(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) return
isSaving.value = true
try {
await client.put(API.COMPANY, companyForm)
if (logoFileBlob.value) {
const logoData = new FormData()
logoData.append(
'company_logo',
JSON.stringify({
name: logoFileName.value,
data: logoFileBlob.value,
}),
)
await client.post(API.COMPANY_UPLOAD_LOGO, logoData)
}
emit('next', 7)
} finally {
isSaving.value = false
}
}
</script>

View File

@@ -0,0 +1,170 @@
<template>
<BaseWizardStep
:title="$t('wizard.database.database')"
:description="$t('wizard.database.desc')"
step-container="w-full p-8 mb-8 bg-surface border border-line-default border-solid rounded md:w-full"
>
<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.database.connection')"
required
>
<BaseMultiselect
v-model="databaseData.database_connection"
:options="databaseDrivers"
label="label"
value-prop="value"
:can-deselect="false"
:can-clear="false"
@update:model-value="onChangeDriver"
/>
</BaseInputGroup>
</div>
<!-- MySQL / PostgreSQL fields -->
<template v-if="databaseData.database_connection !== 'sqlite'">
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup :label="$t('wizard.database.hostname')" required>
<BaseInput v-model="databaseData.database_hostname" type="text" />
</BaseInputGroup>
<BaseInputGroup :label="$t('wizard.database.port')" required>
<BaseInput v-model="databaseData.database_port" type="text" />
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup :label="$t('wizard.database.db_name')" required>
<BaseInput v-model="databaseData.database_name" type="text" />
</BaseInputGroup>
<BaseInputGroup :label="$t('wizard.database.username')" required>
<BaseInput v-model="databaseData.database_username" type="text" />
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2">
<BaseInputGroup :label="$t('wizard.database.password')">
<BaseInput v-model="databaseData.database_password" type="password" />
</BaseInputGroup>
</div>
</template>
<!-- SQLite fields -->
<template v-else>
<div class="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2">
<BaseInputGroup :label="$t('wizard.database.db_name')">
<BaseInput v-model="databaseData.database_name" type="text" disabled />
</BaseInputGroup>
</div>
</template>
<BaseButton :loading="isSaving" :disabled="isSaving" class="mt-4">
<template #left="slotProps">
<BaseIcon name="ArrowRightIcon" :class="slotProps.class" />
</template>
{{ $t('wizard.continue') }}
</BaseButton>
</form>
</BaseWizardStep>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { client } from '../../../api/client'
interface DatabaseConfig {
database_connection: string
database_hostname: string
database_port: string
database_name: string | null
database_username: string | null
database_password: string | null
database_overwrite: boolean
app_url: string
app_locale: string | null
}
interface Emits {
(e: 'next', step: number): void
}
interface DatabaseDriverOption {
label: string
value: string
}
const emit = defineEmits<Emits>()
const { t } = useI18n()
const isSaving = ref<boolean>(false)
const databaseDrivers = ref<DatabaseDriverOption[]>([
{ label: 'MySQL', value: 'mysql' },
{ label: 'PostgreSQL', value: 'pgsql' },
{ label: 'SQLite', value: 'sqlite' },
])
const databaseData = reactive<DatabaseConfig>({
database_connection: 'mysql',
database_hostname: '127.0.0.1',
database_port: '3306',
database_name: null,
database_username: null,
database_password: null,
database_overwrite: false,
app_url: window.location.origin,
app_locale: null,
})
onMounted(() => {
getDatabaseConfig()
})
async function getDatabaseConfig(connection?: string): Promise<void> {
const params: Record<string, string> = {}
if (connection) params.connection = connection
const { data } = await client.get('/api/v1/installation/database/config', { params })
if (data.success) {
databaseData.database_connection = data.config.database_connection
}
if (data.config.database_connection === 'sqlite') {
databaseData.database_name = data.config.database_name
} else {
databaseData.database_name = null
if (data.config.database_host) {
databaseData.database_hostname = data.config.database_host
}
if (data.config.database_port) {
databaseData.database_port = data.config.database_port
}
}
}
function onChangeDriver(connection: string): void {
getDatabaseConfig(connection)
}
async function next(): Promise<void> {
isSaving.value = true
try {
const { data: res } = await client.post(
'/api/v1/installation/database/config',
databaseData,
)
if (res.success) {
await client.post('/api/v1/installation/finish')
emit('next', 3)
}
} finally {
isSaving.value = false
}
}
</script>

View File

@@ -0,0 +1,102 @@
<template>
<BaseWizardStep
:title="$t('wizard.verify_domain.title')"
:description="$t('wizard.verify_domain.desc')"
>
<div class="w-full">
<BaseInputGroup
:label="$t('wizard.verify_domain.app_domain')"
:error="v$.app_domain.$error ? String(v$.app_domain.$errors[0]?.$message) : undefined"
required
>
<BaseInput
v-model="formData.app_domain"
:invalid="v$.app_domain.$error"
type="text"
@input="v$.app_domain.$touch()"
/>
</BaseInputGroup>
</div>
<p class="mt-4 mb-0 text-sm text-body">
{{ $t('wizard.verify_domain.notes.notes') }}
</p>
<ul class="w-full text-body list-disc list-inside">
<li class="text-sm leading-8">
{{ $t('wizard.verify_domain.notes.not_contain') }}
<b class="inline-block px-1 bg-surface-tertiary rounded-xs">https://</b>
{{ $t('wizard.verify_domain.notes.or') }}
<b class="inline-block px-1 bg-surface-tertiary rounded-xs">http</b>
{{ $t('wizard.verify_domain.notes.in_front') }}
</li>
<li class="text-sm leading-8">
{{ $t('wizard.verify_domain.notes.if_you') }}
<b class="inline-block px-1 bg-surface-tertiary">localhost:8080</b>
</li>
</ul>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
class="mt-8"
@click="verifyDomain"
>
{{ $t('wizard.verify_domain.verify_now') }}
</BaseButton>
</BaseWizardStep>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { required, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { client } from '../../../api/client'
interface Emits {
(e: 'next', step: number): void
}
const emit = defineEmits<Emits>()
const { t } = useI18n()
const isSaving = ref<boolean>(false)
const formData = reactive<{ app_domain: string }>({
app_domain: window.location.origin.replace(/(^\w+:|^)\/\//, ''),
})
function isUrl(value: string): boolean {
if (!value) return false
// Simple domain validation -- no protocol prefix
return !value.startsWith('http://') && !value.startsWith('https://')
}
const rules = computed(() => ({
app_domain: {
required: helpers.withMessage(t('validation.required'), required),
isUrl: helpers.withMessage(t('validation.invalid_domain_url'), isUrl),
},
}))
const v$ = useVuelidate(rules, formData)
async function verifyDomain(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) return
isSaving.value = true
try {
await client.put('/api/v1/installation/set-domain', formData)
await client.get('/sanctum/csrf-cookie')
await client.post('/api/v1/installation/login')
const { data } = await client.get('/api/v1/auth/check')
if (data) {
emit('next', 4)
}
} finally {
isSaving.value = false
}
}
</script>

View File

@@ -0,0 +1,168 @@
<template>
<BaseWizardStep
:title="$t('wizard.mail.mail_config')"
:description="$t('wizard.mail.mail_config_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.mail.driver')" required>
<BaseMultiselect
v-model="mailDriver"
:options="mailDriverOptions"
label="label"
value-prop="value"
:can-deselect="false"
:can-clear="false"
@update:model-value="onChangeDriver"
/>
</BaseInputGroup>
</div>
<!-- SMTP Fields -->
<template v-if="mailDriver === 'smtp'">
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup :label="$t('wizard.mail.host')" required>
<BaseInput v-model="mailConfig.mail_host" type="text" />
</BaseInputGroup>
<BaseInputGroup :label="$t('wizard.mail.port')" required>
<BaseInput v-model="mailConfig.mail_port" type="text" />
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup :label="$t('wizard.mail.username')">
<BaseInput v-model="mailConfig.mail_username" type="text" />
</BaseInputGroup>
<BaseInputGroup :label="$t('wizard.mail.password')">
<BaseInput v-model="mailConfig.mail_password" type="password" />
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup :label="$t('wizard.mail.encryption')">
<BaseMultiselect
v-model="mailConfig.mail_encryption"
:options="encryptionOptions"
:can-deselect="true"
:placeholder="$t('wizard.mail.none')"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('wizard.mail.from_mail')">
<BaseInput v-model="mailConfig.from_mail" type="text" />
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2">
<BaseInputGroup :label="$t('wizard.mail.from_name')">
<BaseInput v-model="mailConfig.from_name" type="text" />
</BaseInputGroup>
</div>
</template>
<!-- Basic driver info -->
<template v-if="mailDriver === 'sendmail' || mailDriver === 'mail'">
<p class="text-sm text-muted mb-6">
{{ $t('wizard.mail.basic_mail_desc') }}
</p>
</template>
<BaseButton :loading="isSaving" :disabled="isSaving" class="mt-4">
<template #left="slotProps">
<BaseIcon name="ArrowRightIcon" :class="slotProps.class" />
</template>
{{ $t('wizard.save_cont') }}
</BaseButton>
</form>
</BaseWizardStep>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { client } from '../../../api/client'
interface MailConfig {
mail_driver: string
mail_host: string
mail_port: string
mail_username: string
mail_password: string
mail_encryption: string
from_mail: string
from_name: string
[key: string]: string
}
interface DriverOption {
label: string
value: string
}
interface Emits {
(e: 'next', step: number): void
}
const emit = defineEmits<Emits>()
const isSaving = ref<boolean>(false)
const isFetchingInitialData = ref<boolean>(false)
const mailDriver = ref<string>('smtp')
const mailDriverOptions = ref<DriverOption[]>([
{ label: 'SMTP', value: 'smtp' },
{ label: 'Mailgun', value: 'mailgun' },
{ label: 'SES', value: 'ses' },
{ label: 'Sendmail', value: 'sendmail' },
{ label: 'Mail', value: 'mail' },
])
const encryptionOptions = ref<string[]>(['tls', 'ssl'])
const mailConfig = reactive<MailConfig>({
mail_driver: 'smtp',
mail_host: '',
mail_port: '587',
mail_username: '',
mail_password: '',
mail_encryption: 'tls',
from_mail: '',
from_name: '',
})
onMounted(async () => {
await loadData()
})
async function loadData(): Promise<void> {
isFetchingInitialData.value = true
try {
const { data: configData } = await client.get('/api/v1/mail/config')
if (configData) {
Object.assign(mailConfig, configData)
mailDriver.value = configData.mail_driver ?? 'smtp'
}
} finally {
isFetchingInitialData.value = false
}
}
function onChangeDriver(value: string): void {
mailDriver.value = value
mailConfig.mail_driver = value
}
async function next(): Promise<void> {
isSaving.value = true
try {
mailConfig.mail_driver = mailDriver.value
const { data } = await client.post('/api/v1/mail/config', mailConfig)
if (data.success) {
emit('next', 5)
}
} finally {
isSaving.value = false
}
}
</script>

View File

@@ -0,0 +1,99 @@
<template>
<BaseWizardStep
:title="$t('wizard.permissions.permissions')"
:description="$t('wizard.permissions.permission_desc')"
>
<!-- Placeholders -->
<BaseContentPlaceholders v-if="isFetchingInitialData">
<div
v-for="n in 3"
:key="n"
class="grid grid-flow-row grid-cols-3 lg:gap-24 sm:gap-4 border border-line-default"
>
<BaseContentPlaceholdersText :lines="1" class="col-span-4 p-3" />
</div>
<BaseContentPlaceholdersBox
:rounded="true"
class="mt-10"
style="width: 96px; height: 42px"
/>
</BaseContentPlaceholders>
<div v-else class="relative">
<div
v-for="(permission, index) in permissions"
:key="index"
class="border border-line-default"
>
<div class="grid grid-flow-row grid-cols-3 lg:gap-24 sm:gap-4">
<div class="col-span-2 p-3">{{ permission.folder }}</div>
<div class="p-3 text-right">
<span
v-if="permission.isSet"
class="inline-block w-4 h-4 ml-3 mr-2 rounded-full bg-green-500"
/>
<span
v-else
class="inline-block w-4 h-4 ml-3 mr-2 rounded-full bg-red-500"
/>
<span>{{ permission.permission }}</span>
</div>
</div>
</div>
<BaseButton
v-show="!isFetchingInitialData"
class="mt-10"
:loading="isSaving"
:disabled="isSaving"
@click="next"
>
<template #left="slotProps">
<BaseIcon name="ArrowRightIcon" :class="slotProps.class" />
</template>
{{ $t('wizard.continue') }}
</BaseButton>
</div>
</BaseWizardStep>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { client } from '../../../api/client'
interface Permission {
folder: string
permission: string
isSet: boolean
}
interface Emits {
(e: 'next'): void
}
const emit = defineEmits<Emits>()
const isFetchingInitialData = ref<boolean>(false)
const isSaving = ref<boolean>(false)
const permissions = ref<Permission[]>([])
onMounted(() => {
getPermissions()
})
async function getPermissions(): Promise<void> {
isFetchingInitialData.value = true
try {
const { data } = await client.get('/api/v1/installation/permissions')
permissions.value = data.permissions?.permissions ?? []
} finally {
isFetchingInitialData.value = false
}
}
function next(): void {
isSaving.value = true
emit('next')
isSaving.value = false
}
</script>

View File

@@ -0,0 +1,270 @@
<template>
<BaseWizardStep
:title="$t('wizard.preferences')"
:description="$t('wizard.preferences_desc')"
step-container="bg-surface border border-line-default border-solid mb-8 md:w-full p-8 rounded w-full"
>
<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 { client } from '../../../api/client'
import { API } from '../../../api/endpoints'
interface PreferencesData {
currency: number
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
}
interface LanguageOption {
code: string
name: string
}
interface Emits {
(e: 'next', step: string): void
}
const emit = defineEmits<Emits>()
const { t } = useI18n()
const router = useRouter()
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: 3,
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([
client.get(API.CURRENCIES),
client.get(API.DATE_FORMATS),
client.get(API.TIMEZONES),
client.get(`${API.CONFIG}?key=fiscal_years`),
client.get(`${API.CONFIG}?key=languages`),
])
currencies.value = currRes.data.data ?? currRes.data
dateFormats.value = dateRes.data.data ?? dateRes.data
timeZones.value = tzRes.data.data ?? tzRes.data
fiscalYears.value = fyRes.data.data ?? fyRes.data ?? []
languages.value = langRes.data.data ?? langRes.data ?? []
} finally {
isFetchingInitialData.value = false
}
})
async function next(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) return
const confirmed = window.confirm(t('wizard.currency_set_alert'))
if (!confirmed) return
isSaving.value = true
try {
const settingsPayload = {
settings: { ...currentPreferences },
}
const { data: res } = await client.post(API.COMPANY_SETTINGS, settingsPayload)
if (res) {
const userSettings = {
settings: { language: currentPreferences.language },
}
await client.put(API.ME_SETTINGS, userSettings)
if (res.token) {
localStorage.setItem('auth.token', res.token)
}
emit('next', 'COMPLETED')
router.push('/admin/dashboard')
}
} finally {
isSaving.value = false
}
}
</script>

View File

@@ -0,0 +1,114 @@
<template>
<BaseWizardStep
:title="$t('wizard.req.system_req')"
:description="$t('wizard.req.system_req_desc')"
>
<div class="w-full">
<div class="mb-6">
<div
v-if="phpSupportInfo"
class="grid grid-flow-row grid-cols-3 p-3 border border-line-default lg:gap-24 sm:gap-4"
>
<div class="col-span-2 text-sm">
{{ $t('wizard.req.php_req_version', { version: phpSupportInfo.minimum }) }}
</div>
<div class="text-right">
{{ phpSupportInfo.current }}
<span
v-if="phpSupportInfo.supported"
class="inline-block w-4 h-4 ml-3 mr-2 bg-green-500 rounded-full"
/>
<span
v-else
class="inline-block w-4 h-4 ml-3 mr-2 bg-red-500 rounded-full"
/>
</div>
</div>
<div v-if="requirements">
<div
v-for="(fulfilled, name) in requirements"
:key="name"
class="grid grid-flow-row grid-cols-3 p-3 border border-line-default lg:gap-24 sm:gap-4"
>
<div class="col-span-2 text-sm">{{ name }}</div>
<div class="text-right">
<span
v-if="fulfilled"
class="inline-block w-4 h-4 ml-3 mr-2 bg-green-500 rounded-full"
/>
<span
v-else
class="inline-block w-4 h-4 ml-3 mr-2 bg-red-500 rounded-full"
/>
</div>
</div>
</div>
</div>
<BaseButton v-if="hasNext" @click="next">
{{ $t('wizard.continue') }}
<template #left="slotProps">
<BaseIcon name="ArrowRightIcon" :class="slotProps.class" />
</template>
</BaseButton>
<BaseButton
v-if="!requirements"
:loading="isSaving"
:disabled="isSaving"
@click="getRequirements"
>
{{ $t('wizard.req.check_req') }}
</BaseButton>
</div>
</BaseWizardStep>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { client } from '../../../api/client'
interface PhpSupportInfo {
minimum: string
current: string
supported: boolean
}
interface Emits {
(e: 'next'): void
}
const emit = defineEmits<Emits>()
const requirements = ref<Record<string, boolean> | null>(null)
const phpSupportInfo = ref<PhpSupportInfo | null>(null)
const isSaving = ref<boolean>(false)
const hasNext = computed<boolean>(() => {
if (!requirements.value || !phpSupportInfo.value) return false
const allMet = Object.values(requirements.value).every((v) => v)
return allMet && phpSupportInfo.value.supported
})
onMounted(() => {
getRequirements()
})
async function getRequirements(): Promise<void> {
isSaving.value = true
try {
const { data } = await client.get('/api/v1/installation/requirements')
requirements.value = data?.requirements?.requirements?.php ?? null
phpSupportInfo.value = data?.phpSupportInfo ?? null
} finally {
isSaving.value = false
}
}
function next(): void {
isSaving.value = true
emit('next')
isSaving.value = false
}
</script>