mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-04-18 02:34:08 +00:00
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:
209
resources/scripts-v2/features/installation/views/AccountView.vue
Normal file
209
resources/scripts-v2/features/installation/views/AccountView.vue
Normal 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>
|
||||
254
resources/scripts-v2/features/installation/views/CompanyView.vue
Normal file
254
resources/scripts-v2/features/installation/views/CompanyView.vue
Normal 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>
|
||||
@@ -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>
|
||||
102
resources/scripts-v2/features/installation/views/DomainView.vue
Normal file
102
resources/scripts-v2/features/installation/views/DomainView.vue
Normal 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>
|
||||
168
resources/scripts-v2/features/installation/views/MailView.vue
Normal file
168
resources/scripts-v2/features/installation/views/MailView.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user