Finalize Typescript restructure

This commit is contained in:
Darko Gjorgjijoski
2026-04-06 17:59:15 +02:00
parent cab785172e
commit 74b4b2df4e
209 changed files with 12419 additions and 1745 deletions

View File

@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
import { required, minLength, maxLength, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useModalStore } from '@v2/stores/modal.store'
import { useNotificationStore } from '@v2/stores/notification.store'
import { expenseService } from '@v2/api/services/expense.service'
interface CategoryForm {
@@ -13,6 +14,7 @@ interface CategoryForm {
}
const modalStore = useModalStore()
const notificationStore = useNotificationStore()
const { t } = useI18n()
const isSaving = ref<boolean>(false)
@@ -77,11 +79,19 @@ async function submitCategoryData(): Promise<void> {
name: currentCategory.value.name,
description: currentCategory.value.description || null,
})
notificationStore.showNotification({
type: 'success',
message: 'settings.expense_category.updated_message',
})
} else {
await expenseService.createCategory({
name: currentCategory.value.name,
description: currentCategory.value.description || null,
})
notificationStore.showNotification({
type: 'success',
message: 'settings.expense_category.created_message',
})
}
isSaving.value = false

View File

@@ -0,0 +1,260 @@
<script setup lang="ts">
import { computed, ref, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { required, minLength, helpers } from '@vuelidate/validators'
import { useVuelidate } from '@vuelidate/core'
import { useModalStore } from '@v2/stores/modal.store'
import { useCompanyStore } from '@v2/stores/company.store'
import { useGlobalStore } from '@v2/stores/global.store'
const router = useRouter()
const modalStore = useModalStore()
const companyStore = useCompanyStore()
const globalStore = useGlobalStore()
const { t } = useI18n()
const isSaving = ref<boolean>(false)
const isFetchingInitialData = ref<boolean>(false)
const previewLogo = ref<string | null>(null)
const companyLogoFileBlob = ref<string | null>(null)
const companyLogoName = ref<string | null>(null)
const newCompanyForm = reactive<{
name: string
currency: string | number
address: { country_id: number | null }
}>({
name: '',
currency: '',
address: {
country_id: null,
},
})
const modalActive = computed<boolean>(
() => modalStore.active && modalStore.componentName === 'CompanyModal',
)
const rules = computed(() => ({
newCompanyForm: {
name: {
required: helpers.withMessage(t('validation.required'), required),
minLength: helpers.withMessage(
t('validation.name_min_length', { count: 3 }),
minLength(3),
),
},
address: {
country_id: {
required: helpers.withMessage(t('validation.required'), required),
},
},
currency: {
required: helpers.withMessage(t('validation.required'), required),
},
},
}))
const v$ = useVuelidate(rules, { newCompanyForm })
async function getInitials(): Promise<void> {
isFetchingInitialData.value = true
await globalStore.fetchCurrencies()
await globalStore.fetchCountries()
newCompanyForm.currency = companyStore.selectedCompanyCurrency?.id ?? ''
newCompanyForm.address.country_id =
(companyStore.selectedCompany as Record<string, unknown>)?.address
? ((companyStore.selectedCompany as Record<string, unknown>).address as Record<string, unknown>)?.country_id as number | null
: null
isFetchingInitialData.value = false
}
function onFileInputChange(fileName: string, file: string): void {
companyLogoName.value = fileName
companyLogoFileBlob.value = file
}
function onFileInputRemove(): void {
companyLogoName.value = null
companyLogoFileBlob.value = null
}
async function submitCompanyData(): Promise<void> {
v$.value.newCompanyForm.$touch()
if (v$.value.$invalid) {
return
}
isSaving.value = true
try {
const res = await companyStore.addNewCompany(newCompanyForm as never)
if (res.data) {
companyStore.setSelectedCompany(res.data)
if (companyLogoFileBlob.value) {
const logoData = new FormData()
logoData.append(
'company_logo',
JSON.stringify({
name: companyLogoName.value,
data: companyLogoFileBlob.value,
}),
)
await companyStore.updateCompanyLogo(logoData)
}
globalStore.setIsAppLoaded(false)
await globalStore.bootstrap()
closeCompanyModal()
router.push('/admin/dashboard')
}
} finally {
isSaving.value = false
}
}
function resetNewCompanyForm(): void {
newCompanyForm.name = ''
newCompanyForm.currency = ''
newCompanyForm.address.country_id = null
v$.value.$reset()
}
function closeCompanyModal(): void {
modalStore.closeModal()
setTimeout(() => {
resetNewCompanyForm()
}, 300)
}
</script>
<template>
<BaseModal :show="modalActive" @close="closeCompanyModal" @open="getInitials">
<template #header>
<div class="flex justify-between w-full">
{{ modalStore.title }}
<BaseIcon
name="XMarkIcon"
class="w-6 h-6 text-muted cursor-pointer"
@click="closeCompanyModal"
/>
</div>
</template>
<form action="" @submit.prevent="submitCompanyData">
<div class="p-4 mb-16 sm:p-6 space-y-4">
<BaseInputGrid layout="one-column">
<BaseInputGroup
:content-loading="isFetchingInitialData"
:label="$t('settings.company_info.company_logo')"
>
<BaseContentPlaceholders v-if="isFetchingInitialData">
<BaseContentPlaceholdersBox :rounded="true" class="w-full h-24" />
</BaseContentPlaceholders>
<div v-else class="flex flex-col items-center">
<BaseFileUploader
:preview-image="previewLogo"
base64
@remove="onFileInputRemove"
@change="onFileInputChange"
/>
</div>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.company_info.company_name')"
:error="
v$.newCompanyForm.name.$error &&
v$.newCompanyForm.name.$errors[0].$message
"
:content-loading="isFetchingInitialData"
required
>
<BaseInput
v-model="newCompanyForm.name"
:invalid="v$.newCompanyForm.name.$error"
:content-loading="isFetchingInitialData"
@input="v$.newCompanyForm.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:content-loading="isFetchingInitialData"
:label="$t('settings.company_info.country')"
:error="
v$.newCompanyForm.address.country_id.$error &&
v$.newCompanyForm.address.country_id.$errors[0].$message
"
required
>
<BaseMultiselect
v-model="newCompanyForm.address.country_id"
:content-loading="isFetchingInitialData"
label="name"
:invalid="v$.newCompanyForm.address.country_id.$error"
:options="globalStore.countries"
value-prop="id"
:can-deselect="true"
:can-clear="false"
searchable
track-by="name"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('wizard.currency')"
:error="
v$.newCompanyForm.currency.$error &&
v$.newCompanyForm.currency.$errors[0].$message
"
:content-loading="isFetchingInitialData"
:help-text="$t('wizard.currency_set_alert')"
required
>
<BaseMultiselect
v-model="newCompanyForm.currency"
:content-loading="isFetchingInitialData"
:options="globalStore.currencies"
label="name"
value-prop="id"
:searchable="true"
track-by="name"
:placeholder="$t('settings.currencies.select_currency')"
:invalid="v$.newCompanyForm.currency.$error"
class="w-full"
/>
</BaseInputGroup>
</BaseInputGrid>
</div>
<div class="z-0 flex justify-end p-4 bg-surface-secondary border-t border-line-default">
<BaseButton
class="mr-3 text-sm"
variant="primary-outline"
type="button"
@click="closeCompanyModal"
>
{{ $t('general.cancel') }}
</BaseButton>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
variant="primary"
type="submit"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
name="ArrowDownOnSquareIcon"
:class="slotProps.class"
/>
</template>
{{ $t('general.save') }}
</BaseButton>
</div>
</form>
</BaseModal>
</template>

View File

@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
import useVuelidate from '@vuelidate/core'
import { required, numeric, helpers } from '@vuelidate/validators'
import { useModalStore } from '@v2/stores/modal.store'
import { useNotificationStore } from '@v2/stores/notification.store'
import { customFieldService } from '@v2/api/services/custom-field.service'
import type { CreateCustomFieldPayload } from '@v2/api/services/custom-field.service'
@@ -32,6 +33,7 @@ interface CustomFieldForm {
}
const modalStore = useModalStore()
const notificationStore = useNotificationStore()
const { t } = useI18n()
const isSaving = ref<boolean>(false)
@@ -178,6 +180,13 @@ async function submitCustomFieldData(): Promise<void> {
isSaving.value = true
let defaultAnswer = currentCustomField.value.default_answer
// Handle Time type — convert object {HH, mm, ss} to 'HH:mm' string
if (currentCustomField.value.type === 'Time' && typeof defaultAnswer === 'object' && defaultAnswer !== null) {
const timeObj = defaultAnswer as Record<string, string>
defaultAnswer = `${timeObj.HH ?? '00'}:${timeObj.mm ?? '00'}`
}
const payload: CreateCustomFieldPayload = {
name: currentCustomField.value.name,
label: currentCustomField.value.label,
@@ -189,13 +198,22 @@ async function submitCustomFieldData(): Promise<void> {
? currentCustomField.value.options.map((o) => o.name)
: null,
order: currentCustomField.value.order,
default_answer: defaultAnswer as string ?? null,
}
try {
if (isEdit.value && currentCustomField.value.id) {
await customFieldService.update(currentCustomField.value.id, payload)
notificationStore.showNotification({
type: 'success',
message: 'settings.custom_fields.updated_message',
})
} else {
await customFieldService.create(payload)
notificationStore.showNotification({
type: 'success',
message: 'settings.custom_fields.created_message',
})
}
isSaving.value = false
@@ -208,6 +226,17 @@ async function submitCustomFieldData(): Promise<void> {
}
}
const newOptionValue = ref<string>('')
function onAddOption(): void {
if (!newOptionValue.value?.trim()) return
currentCustomField.value.options = [
{ name: newOptionValue.value.trim() },
...currentCustomField.value.options,
]
newOptionValue.value = ''
}
function addNewOption(option: string): void {
currentCustomField.value.options = [
{ name: option },
@@ -357,6 +386,22 @@ function closeCustomFieldModal(): void {
v-if="isDropdownSelected"
:label="$t('settings.custom_fields.options')"
>
<!-- Add Option Input -->
<div class="flex items-center mt-1">
<BaseInput
v-model="newOptionValue"
type="text"
class="w-full md:w-96"
:placeholder="$t('settings.custom_fields.press_enter_to_add')"
@keydown.enter.prevent.stop="onAddOption"
/>
<BaseIcon
name="PlusCircleIcon"
class="ml-1 text-primary-500 cursor-pointer"
@click="onAddOption"
/>
</div>
<div
v-for="(option, index) in currentCustomField.options"
:key="index"

View File

@@ -0,0 +1,139 @@
<script setup lang="ts">
import { computed, ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { required, helpers, sameAs } from '@vuelidate/validators'
import { useVuelidate } from '@vuelidate/core'
import { useModalStore } from '@v2/stores/modal.store'
import { useCompanyStore } from '@v2/stores/company.store'
import { useGlobalStore } from '@v2/stores/global.store'
const companyStore = useCompanyStore()
const modalStore = useModalStore()
const globalStore = useGlobalStore()
const router = useRouter()
const { t } = useI18n()
const isDeleting = ref<boolean>(false)
const formData = reactive<{ id: number | null; name: string }>({
id: null,
name: '',
})
const modalActive = computed<boolean>(
() => modalStore.active && modalStore.componentName === 'DeleteCompanyModal',
)
const companyName = computed<string>(
() => companyStore.selectedCompany?.name ?? '',
)
const rules = computed(() => ({
name: {
required: helpers.withMessage(t('validation.required'), required),
sameAsName: helpers.withMessage(
t('validation.company_name_not_same'),
sameAs(companyName.value),
),
},
}))
const v$ = useVuelidate(rules, formData)
function setInitialData(): void {
formData.id = companyStore.selectedCompany?.id ?? null
formData.name = ''
}
async function submitDelete(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) return
isDeleting.value = true
try {
await companyStore.deleteCompany(formData)
closeModal()
const remaining = companyStore.companies.filter(
(c) => c.id !== formData.id,
)
if (remaining.length) {
companyStore.setSelectedCompany(remaining[0])
}
router.push('/admin/dashboard')
globalStore.setIsAppLoaded(false)
await globalStore.bootstrap()
} catch {
// Error handled
} finally {
isDeleting.value = false
}
}
function closeModal(): void {
modalStore.closeModal()
setTimeout(() => {
formData.id = null
formData.name = ''
v$.value.$reset()
}, 300)
}
</script>
<template>
<BaseModal :show="modalActive" @close="closeModal" @open="setInitialData">
<div class="px-6 pt-6">
<h6 class="font-medium text-lg text-heading">
{{ $t('settings.company_info.are_you_absolutely_sure') }}
</h6>
<p class="mt-2 text-sm text-muted" style="max-width: 680px">
{{ $t('settings.company_info.delete_company_modal_desc', { company: companyName }) }}
</p>
</div>
<form @submit.prevent="submitDelete">
<div class="p-4 sm:p-6 space-y-4">
<BaseInputGroup
:label="$t('settings.company_info.delete_company_modal_label', { company: companyName })"
:error="v$.name.$error ? String(v$.name.$errors[0]?.$message) : undefined"
required
>
<BaseInput
v-model="formData.name"
:invalid="v$.name.$error"
@input="v$.name.$touch()"
/>
</BaseInputGroup>
</div>
<div class="z-0 flex justify-end p-4 bg-surface-secondary border-t border-line-default">
<BaseButton
class="mr-3 text-sm"
variant="primary-outline"
type="button"
@click="closeModal"
>
{{ $t('general.cancel') }}
</BaseButton>
<BaseButton
:loading="isDeleting"
:disabled="isDeleting"
variant="danger"
type="submit"
>
<template #left="slotProps">
<BaseIcon
v-if="!isDeleting"
name="TrashIcon"
:class="slotProps.class"
/>
</template>
{{ $t('general.delete') }}
</BaseButton>
</div>
</form>
</BaseModal>
</template>

View File

@@ -1,7 +1,11 @@
<script setup lang="ts">
import { computed, reactive, inject } from 'vue'
import { useCompanyStore } from '@v2/stores/company.store'
import { useEstimateStore } from '@v2/features/company/estimates/store'
import NumberCustomizer from './NumberCustomizer.vue'
import EstimatesTabExpiryDate from './EstimatesTabExpiryDate.vue'
import EstimatesTabConvertEstimate from './EstimatesTabConvertEstimate.vue'
import EstimatesTabDefaultFormats from './EstimatesTabDefaultFormats.vue'
interface Utils {
mergeSettings: (target: Record<string, unknown>, source: Record<string, unknown>) => void
@@ -9,6 +13,7 @@ interface Utils {
const utils = inject<Utils>('utils')!
const companyStore = useCompanyStore()
const estimateStore = useEstimateStore()
const estimateSettings = reactive<{ estimate_email_attachment: string | null }>({
estimate_email_attachment: null,
@@ -41,8 +46,14 @@ const sendAsAttachmentField = computed<boolean>({
</script>
<template>
<NumberCustomizer type="estimate" :type-store="companyStore" />
<NumberCustomizer type="estimate" :type-store="estimateStore" />
<BaseDivider class="mt-6 mb-2" />
<EstimatesTabExpiryDate />
<BaseDivider class="mt-6 mb-2" />
<EstimatesTabConvertEstimate />
<BaseDivider class="mt-6 mb-2" />
<EstimatesTabDefaultFormats />
<BaseDivider class="mt-6 mb-2" />
<ul class="divide-y divide-line-default">

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import { reactive, inject } from 'vue'
import { useCompanyStore } from '@v2/stores/company.store'
import { useGlobalStore } from '@v2/stores/global.store'
interface Utils {
mergeSettings: (target: Record<string, unknown>, source: Record<string, unknown>) => void
}
const companyStore = useCompanyStore()
const globalStore = useGlobalStore()
const utils = inject<Utils>('utils')!
const settingsForm = reactive<{ estimate_convert_action: string | null }>({
estimate_convert_action: null,
})
utils.mergeSettings(
settingsForm as unknown as Record<string, unknown>,
{ ...companyStore.selectedCompanySettings }
)
const convertEstimateOptions = [
{ key: 'settings.customization.estimates.no_action', value: 'no_action' },
{ key: 'settings.customization.estimates.delete_estimate', value: 'delete_estimate' },
{ key: 'settings.customization.estimates.mark_estimate_as_accepted', value: 'mark_estimate_as_accepted' },
]
async function submitForm() {
const data = {
settings: {
...settingsForm,
},
}
await companyStore.updateCompanySettings({
data,
message: 'settings.customization.estimates.estimate_settings_updated',
})
return true
}
</script>
<template>
<h6 class="text-heading text-lg font-medium">
{{ $t('settings.customization.estimates.convert_estimate_setting') }}
</h6>
<p class="mt-1 text-sm text-muted">
{{ $t('settings.customization.estimates.convert_estimate_description') }}
</p>
<BaseInputGroup required>
<BaseRadio
v-for="option in convertEstimateOptions"
:id="option.value"
:key="option.value"
v-model="settingsForm.estimate_convert_action"
:label="$t(option.key)"
size="sm"
name="estimate_convert_action"
:value="option.value"
class="mt-2"
@update:modelValue="submitForm"
/>
</BaseInputGroup>
</template>

View File

@@ -0,0 +1,120 @@
<script setup lang="ts">
import { ref, reactive, inject } from 'vue'
import { useCompanyStore } from '@v2/stores/company.store'
interface Utils {
mergeSettings: (target: Record<string, unknown>, source: Record<string, unknown>) => void
}
const companyStore = useCompanyStore()
const utils = inject<Utils>('utils')!
const estimateMailFields = ref(['customer', 'company', 'estimate'])
const companyFields = ref(['company'])
const shippingFields = ref(['shipping', 'customer'])
const billingFields = ref(['billing', 'customer'])
const isSaving = ref(false)
const formatSettings = reactive<{
estimate_mail_body: string | null
estimate_company_address_format: string | null
estimate_shipping_address_format: string | null
estimate_billing_address_format: string | null
}>({
estimate_mail_body: null,
estimate_company_address_format: null,
estimate_shipping_address_format: null,
estimate_billing_address_format: null,
})
utils.mergeSettings(
formatSettings as unknown as Record<string, unknown>,
{ ...companyStore.selectedCompanySettings }
)
async function submitForm() {
isSaving.value = true
const data = {
settings: {
...formatSettings,
},
}
await companyStore.updateCompanySettings({
data,
message: 'settings.customization.estimates.estimate_settings_updated',
})
isSaving.value = false
return true
}
</script>
<template>
<form @submit.prevent="submitForm">
<h6 class="text-heading text-lg font-medium">
{{ $t('settings.customization.estimates.default_formats') }}
</h6>
<p class="mt-1 text-sm text-muted mb-2">
{{ $t('settings.customization.estimates.default_formats_description') }}
</p>
<BaseInputGroup
:label="
$t('settings.customization.estimates.default_estimate_email_body')
"
class="mt-6 mb-4"
>
<BaseCustomInput
v-model="formatSettings.estimate_mail_body"
:fields="estimateMailFields"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.customization.estimates.company_address_format')"
class="mt-6 mb-4"
>
<BaseCustomInput
v-model="formatSettings.estimate_company_address_format"
:fields="companyFields"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.customization.estimates.shipping_address_format')"
class="mt-6 mb-4"
>
<BaseCustomInput
v-model="formatSettings.estimate_shipping_address_format"
:fields="shippingFields"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.customization.estimates.billing_address_format')"
class="mt-6 mb-4"
>
<BaseCustomInput
v-model="formatSettings.estimate_billing_address_format"
:fields="billingFields"
/>
</BaseInputGroup>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
variant="primary"
type="submit"
class="mt-4"
>
<template #left="slotProps">
<BaseIcon v-if="!isSaving" :class="slotProps.class" name="ArrowDownOnSquareIcon" />
</template>
{{ $t('settings.customization.save') }}
</BaseButton>
</form>
</template>

View File

@@ -0,0 +1,139 @@
<script setup lang="ts">
import { ref, computed, reactive, inject } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCompanyStore } from '@v2/stores/company.store'
import { numeric, helpers, requiredIf } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
interface Utils {
mergeSettings: (target: Record<string, unknown>, source: Record<string, unknown>) => void
}
const { t } = useI18n()
const companyStore = useCompanyStore()
const utils = inject<Utils>('utils')!
const isSaving = ref(false)
const expiryDateSettings = reactive<{
estimate_set_expiry_date_automatically: string | null
estimate_expiry_date_days: string | null
}>({
estimate_set_expiry_date_automatically: null,
estimate_expiry_date_days: null,
})
utils.mergeSettings(
expiryDateSettings as unknown as Record<string, unknown>,
{ ...companyStore.selectedCompanySettings }
)
const expiryDateAutoField = computed<boolean>({
get: () => expiryDateSettings.estimate_set_expiry_date_automatically === 'YES',
set: (newValue: boolean) => {
const value = newValue ? 'YES' : 'NO'
expiryDateSettings.estimate_set_expiry_date_automatically = value
},
})
const rules = computed(() => {
return {
expiryDateSettings: {
estimate_expiry_date_days: {
required: helpers.withMessage(
t('validation.required'),
requiredIf(expiryDateAutoField.value)
),
numeric: helpers.withMessage(t('validation.numbers_only'), numeric),
},
},
}
})
const v$ = useVuelidate(rules, { expiryDateSettings })
async function submitForm() {
v$.value.expiryDateSettings.$touch()
if (v$.value.expiryDateSettings.$invalid) {
return false
}
isSaving.value = true
const data = {
settings: {
...expiryDateSettings,
},
}
// Don't pass expiry_date_days if setting is not enabled
if (!expiryDateAutoField.value) {
delete data.settings.estimate_expiry_date_days
}
await companyStore.updateCompanySettings({
data,
message: 'settings.customization.estimates.estimate_settings_updated',
})
isSaving.value = false
return true
}
</script>
<template>
<form @submit.prevent="submitForm">
<h6 class="text-heading text-lg font-medium">
{{ $t('settings.customization.estimates.expiry_date_setting') }}
</h6>
<p class="mt-1 text-sm text-muted mb-2">
{{ $t('settings.customization.estimates.expiry_date_description') }}
</p>
<BaseSwitchSection
v-model="expiryDateAutoField"
:title="
$t('settings.customization.estimates.set_expiry_date_automatically')
"
:description="
$t(
'settings.customization.estimates.set_expiry_date_automatically_description'
)
"
/>
<BaseInputGroup
v-if="expiryDateAutoField"
:label="$t('settings.customization.estimates.expiry_date_days')"
:error="
v$.expiryDateSettings.estimate_expiry_date_days.$error &&
v$.expiryDateSettings.estimate_expiry_date_days.$errors[0].$message
"
class="mt-2 mb-4"
>
<div class="w-full sm:w-1/2 md:w-1/4 lg:w-1/5">
<BaseInput
v-model="expiryDateSettings.estimate_expiry_date_days"
:invalid="v$.expiryDateSettings.estimate_expiry_date_days.$error"
type="number"
@input="v$.expiryDateSettings.estimate_expiry_date_days.$touch()"
/>
</div>
</BaseInputGroup>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
variant="primary"
type="submit"
class="mt-4"
>
<template #left="slotProps">
<BaseIcon v-if="!isSaving" :class="slotProps.class" name="ArrowDownOnSquareIcon" />
</template>
{{ $t('settings.customization.save') }}
</BaseButton>
</form>
</template>

View File

@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
import useVuelidate from '@vuelidate/core'
import { required, helpers, requiredIf, url } from '@vuelidate/validators'
import { useModalStore } from '@v2/stores/modal.store'
import { useNotificationStore } from '@v2/stores/notification.store'
import { exchangeRateService } from '@v2/api/services/exchange-rate.service'
import { useDebounceFn } from '@vueuse/core'
@@ -31,6 +32,7 @@ interface CurrencyConverterForm {
const { t } = useI18n()
const modalStore = useModalStore()
const notificationStore = useNotificationStore()
const isSaving = ref<boolean>(false)
const isFetchingInitialData = ref<boolean>(false)
@@ -243,10 +245,18 @@ async function submitExchangeRate(): Promise<void> {
currentExchangeRate.value.id,
data as Parameters<typeof exchangeRateService.updateProvider>[1]
)
notificationStore.showNotification({
type: 'success',
message: 'settings.exchange_rate.updated_message',
})
} else {
await exchangeRateService.createProvider(
data as Parameters<typeof exchangeRateService.createProvider>[0]
)
notificationStore.showNotification({
type: 'success',
message: 'settings.exchange_rate.created_message',
})
}
if (modalStore.refreshData) {

View File

@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router'
import { useDialogStore } from '@v2/stores/dialog.store'
import { useUserStore } from '@v2/stores/user.store'
import { useModalStore } from '@v2/stores/modal.store'
import { useNotificationStore } from '@v2/stores/notification.store'
import { expenseService } from '@v2/api/services/expense.service'
const ABILITIES = {
@@ -24,6 +25,7 @@ const props = defineProps<{
}>()
const dialogStore = useDialogStore()
const notificationStore = useNotificationStore()
const { t } = useI18n()
const route = useRoute()
const userStore = useUserStore()
@@ -54,6 +56,10 @@ function removeExpenseCategory(id: number): void {
if (res) {
const response = await expenseService.deleteCategory(id)
if (response.success) {
notificationStore.showNotification({
type: 'success',
message: 'settings.expense_category.deleted_message',
})
props.loadData?.()
}
}
@@ -65,7 +71,7 @@ function removeExpenseCategory(id: number): void {
<BaseDropdown>
<template #activator>
<BaseButton
v-if="route.name === 'expenseCategorys.view'"
v-if="route.name === 'settings.expense-categories'"
variant="primary"
>
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-white" />

View File

@@ -1,7 +1,11 @@
<script setup lang="ts">
import { computed, reactive, inject } from 'vue'
import { useCompanyStore } from '@v2/stores/company.store'
import { useInvoiceStore } from '@v2/features/company/invoices/store'
import NumberCustomizer from './NumberCustomizer.vue'
import InvoicesTabDueDate from './InvoicesTabDueDate.vue'
import InvoicesTabRetrospective from './InvoicesTabRetrospective.vue'
import InvoicesTabDefaultFormats from './InvoicesTabDefaultFormats.vue'
interface Utils {
mergeSettings: (target: Record<string, unknown>, source: Record<string, unknown>) => void
@@ -9,6 +13,7 @@ interface Utils {
const utils = inject<Utils>('utils')!
const companyStore = useCompanyStore()
const invoiceStore = useInvoiceStore()
const invoiceSettings = reactive<{ invoice_email_attachment: string | null }>({
invoice_email_attachment: null,
@@ -41,7 +46,19 @@ const sendAsAttachmentField = computed<boolean>({
</script>
<template>
<NumberCustomizer type="invoice" :type-store="companyStore" />
<NumberCustomizer type="invoice" :type-store="invoiceStore" />
<BaseDivider class="mt-6 mb-2" />
<InvoicesTabDueDate />
<BaseDivider class="mt-6 mb-2" />
<InvoicesTabRetrospective />
<BaseDivider class="mt-6 mb-2" />
<InvoicesTabDefaultFormats />
<BaseDivider class="mt-6 mb-2" />

View File

@@ -0,0 +1,117 @@
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useCompanyStore } from '@v2/stores/company.store'
const companyStore = useCompanyStore()
const isSaving = ref<boolean>(false)
const settingsForm = reactive<{
invoice_mail_body: string
invoice_company_address_format: string
invoice_shipping_address_format: string
invoice_billing_address_format: string
}>({
invoice_mail_body:
companyStore.selectedCompanySettings.invoice_mail_body ?? '',
invoice_company_address_format:
companyStore.selectedCompanySettings.invoice_company_address_format ?? '',
invoice_shipping_address_format:
companyStore.selectedCompanySettings.invoice_shipping_address_format ?? '',
invoice_billing_address_format:
companyStore.selectedCompanySettings.invoice_billing_address_format ?? '',
})
async function submitForm(): Promise<void> {
isSaving.value = true
const data = {
settings: {
...settingsForm,
},
}
await companyStore.updateCompanySettings({
data,
message: 'settings.customization.invoices.invoice_settings_updated',
})
isSaving.value = false
}
</script>
<template>
<form @submit.prevent="submitForm">
<BaseSettingCard
:title="$t('settings.customization.invoices.default_formats')"
:description="
$t('settings.customization.invoices.default_formats_description')
"
>
<BaseInputGroup
:label="
$t('settings.customization.invoices.default_invoice_email_body')
"
class="mt-6 mb-4"
>
<BaseCustomInput
v-model="settingsForm.invoice_mail_body"
:fields="['customer', 'company', 'invoice']"
/>
</BaseInputGroup>
<BaseInputGroup
:label="
$t('settings.customization.invoices.company_address_format')
"
class="mt-6 mb-4"
>
<BaseCustomInput
v-model="settingsForm.invoice_company_address_format"
:fields="['company']"
/>
</BaseInputGroup>
<BaseInputGroup
:label="
$t('settings.customization.invoices.shipping_address_format')
"
class="mt-6 mb-4"
>
<BaseCustomInput
v-model="settingsForm.invoice_shipping_address_format"
:fields="['shipping', 'customer']"
/>
</BaseInputGroup>
<BaseInputGroup
:label="
$t('settings.customization.invoices.billing_address_format')
"
class="mt-6 mb-4"
>
<BaseCustomInput
v-model="settingsForm.invoice_billing_address_format"
:fields="['billing', 'customer']"
/>
</BaseInputGroup>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
variant="primary"
type="submit"
class="mt-4"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
:class="slotProps.class"
name="ArrowDownOnSquareIcon"
/>
</template>
{{ $t('settings.customization.save') }}
</BaseButton>
</BaseSettingCard>
</form>
</template>

View File

@@ -0,0 +1,134 @@
<script setup lang="ts">
import { ref, computed, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { numeric, helpers, requiredIf } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useCompanyStore } from '@v2/stores/company.store'
const { t } = useI18n()
const companyStore = useCompanyStore()
const isSaving = ref<boolean>(false)
const dueDateSettings = reactive<{
invoice_set_due_date_automatically: string
invoice_due_date_days: string
}>({
invoice_set_due_date_automatically:
companyStore.selectedCompanySettings.invoice_set_due_date_automatically ??
'NO',
invoice_due_date_days:
companyStore.selectedCompanySettings.invoice_due_date_days ?? '',
})
const dueDateAutoField = computed<boolean>({
get: () => dueDateSettings.invoice_set_due_date_automatically === 'YES',
set: (newValue: boolean) => {
dueDateSettings.invoice_set_due_date_automatically = newValue
? 'YES'
: 'NO'
},
})
const rules = computed(() => ({
dueDateSettings: {
invoice_due_date_days: {
required: helpers.withMessage(
t('validation.required'),
requiredIf(dueDateAutoField.value)
),
numeric: helpers.withMessage(t('validation.numbers_only'), numeric),
},
},
}))
const v$ = useVuelidate(rules, { dueDateSettings })
async function submitForm(): Promise<void> {
v$.value.dueDateSettings.$touch()
if (v$.value.dueDateSettings.$invalid) {
return
}
isSaving.value = true
const data: { settings: Record<string, string> } = {
settings: {
invoice_set_due_date_automatically:
dueDateSettings.invoice_set_due_date_automatically,
},
}
if (dueDateAutoField.value) {
data.settings.invoice_due_date_days =
dueDateSettings.invoice_due_date_days
}
await companyStore.updateCompanySettings({
data,
message: 'settings.customization.invoices.invoice_settings_updated',
})
isSaving.value = false
}
</script>
<template>
<form @submit.prevent="submitForm">
<BaseSettingCard
:title="$t('settings.customization.invoices.due_date')"
:description="
$t('settings.customization.invoices.due_date_description')
"
>
<BaseSwitchSection
v-model="dueDateAutoField"
:title="
$t('settings.customization.invoices.set_due_date_automatically')
"
:description="
$t(
'settings.customization.invoices.set_due_date_automatically_description'
)
"
/>
<BaseInputGroup
v-if="dueDateAutoField"
:label="$t('settings.customization.invoices.due_date_days')"
:error="
v$.dueDateSettings.invoice_due_date_days.$error &&
v$.dueDateSettings.invoice_due_date_days.$errors[0].$message
"
class="mt-2 mb-4"
>
<div class="w-full sm:w-1/2 md:w-1/4 lg:w-1/5">
<BaseInput
v-model="dueDateSettings.invoice_due_date_days"
:invalid="v$.dueDateSettings.invoice_due_date_days.$error"
type="number"
@input="v$.dueDateSettings.invoice_due_date_days.$touch()"
/>
</div>
</BaseInputGroup>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
variant="primary"
type="submit"
class="mt-4"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
:class="slotProps.class"
name="ArrowDownOnSquareIcon"
/>
</template>
{{ $t('settings.customization.save') }}
</BaseButton>
</BaseSettingCard>
</form>
</template>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import { reactive, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCompanyStore } from '@v2/stores/company.store'
import { useGlobalStore } from '@v2/stores/global.store'
const { t } = useI18n()
const companyStore = useCompanyStore()
const globalStore = useGlobalStore()
const settingsForm = reactive<{ retrospective_edits: string | null }>({
retrospective_edits:
companyStore.selectedCompanySettings.retrospective_edits ?? null,
})
const retrospectiveEditOptions = [
{ key: 'settings.customization.invoices.allow', value: 'allow' },
{ key: 'settings.customization.invoices.disable_on_invoice_partial_paid', value: 'disable_on_invoice_partial_paid' },
{ key: 'settings.customization.invoices.disable_on_invoice_paid', value: 'disable_on_invoice_paid' },
{ key: 'settings.customization.invoices.disable_on_invoice_sent', value: 'disable_on_invoice_sent' },
]
async function submitForm(): Promise<void> {
const data = {
settings: {
...settingsForm,
},
}
await companyStore.updateCompanySettings({
data,
message: 'settings.customization.invoices.invoice_settings_updated',
})
}
</script>
<template>
<BaseSettingCard
:title="$t('settings.customization.invoices.retrospective_edits')"
:description="
$t('settings.customization.invoices.retrospective_edits_description')
"
>
<BaseInputGroup required>
<BaseRadio
v-for="option in retrospectiveEditOptions"
:id="option.value"
:key="option.value"
v-model="settingsForm.retrospective_edits"
:label="$t(option.key)"
size="sm"
name="retrospective_edits"
:value="option.value"
class="mt-2"
@update:modelValue="submitForm"
/>
</BaseInputGroup>
</BaseSettingCard>
</template>

View File

@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
import { required, minLength, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useModalStore } from '@v2/stores/modal.store'
import { itemService } from '@v2/api/services/item.service'
import { useItemStore } from '@v2/features/company/items/store'
interface ItemUnitForm {
id: number | null
@@ -12,6 +12,7 @@ interface ItemUnitForm {
}
const modalStore = useModalStore()
const itemStore = useItemStore()
const { t } = useI18n()
const isSaving = ref<boolean>(false)
@@ -41,12 +42,10 @@ const v$ = useVuelidate(rules, currentItemUnit)
async function setInitialData(): Promise<void> {
if (modalStore.data && typeof modalStore.data === 'number') {
isEdit.value = true
const response = await itemService.getUnit(modalStore.data)
if (response.data) {
currentItemUnit.value = {
id: response.data.id,
name: response.data.name,
}
await itemStore.fetchItemUnit(modalStore.data)
currentItemUnit.value = {
id: itemStore.currentItemUnit.id ?? null,
name: itemStore.currentItemUnit.name,
}
} else {
isEdit.value = false
@@ -63,18 +62,20 @@ async function submitItemUnit(): Promise<void> {
isSaving.value = true
try {
let res
if (isEdit.value && currentItemUnit.value.id) {
await itemService.updateUnit(currentItemUnit.value.id, {
res = await itemStore.updateItemUnit({
id: currentItemUnit.value.id,
name: currentItemUnit.value.name,
})
} else {
await itemService.createUnit({
res = await itemStore.addItemUnit({
name: currentItemUnit.value.name,
})
}
if (modalStore.refreshData) {
modalStore.refreshData()
if (modalStore.refreshData && res?.data) {
modalStore.refreshData(res.data)
}
closeItemUnitModal()
} catch {

View File

@@ -15,7 +15,7 @@ interface MailTestForm {
const props = withDefaults(
defineProps<{
storeType?: string
storeType?: 'company' | 'global'
}>(),
{
storeType: 'global',
@@ -74,7 +74,18 @@ async function onTestMailSend(): Promise<void> {
isSaving.value = true
try {
await mailService.testEmail({ to: formData.to })
const payload = {
to: formData.to,
subject: formData.subject,
message: formData.message,
}
if (props.storeType === 'company') {
await companyService.testMailConfig(payload)
} else {
await mailService.testEmail(payload)
}
closeTestModal()
} finally {
isSaving.value = false

View File

@@ -6,10 +6,7 @@ import { useNotificationStore } from '@v2/stores/notification.store'
import { useUserStore } from '@v2/stores/user.store'
import { useModalStore } from '@v2/stores/modal.store'
import { noteService } from '@v2/api/services/note.service'
const ABILITIES = {
MANAGE_NOTE: 'manage-note',
} as const
import { ABILITIES } from '@v2/config/abilities'
interface NoteRow {
id: number
@@ -51,7 +48,11 @@ function removeNote(id: number): void {
hideNoButton: false,
size: 'lg',
})
.then(async () => {
.then(async (confirmed: boolean) => {
if (!confirmed) {
return
}
const response = await noteService.delete(id)
if (response.success) {
notificationStore.showNotification({
@@ -72,7 +73,7 @@ function removeNote(id: number): void {
<template>
<BaseDropdown>
<template #activator>
<BaseButton v-if="route.name === 'notes.view'" variant="primary">
<BaseButton v-if="route.name === 'settings.notes'" variant="primary">
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-white" />
</BaseButton>
<BaseIcon v-else name="EllipsisHorizontalIcon" class="h-5 text-muted" />

View File

@@ -157,12 +157,12 @@ async function setInitialFields(): Promise<void> {
const res = await globalStore.fetchPlaceholders(data as { key: string })
res.placeholders.forEach((placeholder) => {
const found = allFields.value.find((field) => field.name === placeholder.value)
const found = allFields.value.find((field) => field.name === placeholder.name)
if (!found) return
selectedFields.value.push({
...found,
value: placeholder.value ?? '',
value: placeholder.value ?? found.value,
id: Guid.raw(),
})
})

View File

@@ -55,7 +55,7 @@ function removePaymentMode(id: number): void {
<template>
<BaseDropdown>
<template #activator>
<BaseButton v-if="route.name === 'paymentModes.view'" variant="primary">
<BaseButton v-if="route.name === 'settings.payment-modes'" variant="primary">
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-white" />
</BaseButton>
<BaseIcon v-else name="EllipsisHorizontalIcon" class="h-5 text-muted" />

View File

@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
import { required, minLength, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useModalStore } from '@v2/stores/modal.store'
import { useNotificationStore } from '@v2/stores/notification.store'
import { paymentService } from '@v2/api/services/payment.service'
interface PaymentModeForm {
@@ -12,6 +13,7 @@ interface PaymentModeForm {
}
const modalStore = useModalStore()
const notificationStore = useNotificationStore()
const { t } = useI18n()
const isSaving = ref<boolean>(false)
@@ -63,10 +65,18 @@ async function submitPaymentMode(): Promise<void> {
await paymentService.updateMethod(currentPaymentMode.value.id, {
name: currentPaymentMode.value.name,
})
notificationStore.showNotification({
type: 'success',
message: 'settings.payment_modes.updated_message',
})
} else {
await paymentService.createMethod({
name: currentPaymentMode.value.name,
})
notificationStore.showNotification({
type: 'success',
message: 'settings.payment_modes.created_message',
})
}
isSaving.value = false

View File

@@ -1,7 +1,9 @@
<script setup lang="ts">
import { computed, reactive, inject } from 'vue'
import { useCompanyStore } from '@v2/stores/company.store'
import { usePaymentStore } from '@v2/features/company/payments/store'
import NumberCustomizer from './NumberCustomizer.vue'
import PaymentsTabDefaultFormats from './PaymentsTabDefaultFormats.vue'
interface Utils {
mergeSettings: (target: Record<string, unknown>, source: Record<string, unknown>) => void
@@ -9,6 +11,7 @@ interface Utils {
const utils = inject<Utils>('utils')!
const companyStore = useCompanyStore()
const paymentStore = usePaymentStore()
const paymentSettings = reactive<{ payment_email_attachment: string | null }>({
payment_email_attachment: null,
@@ -41,8 +44,10 @@ const sendAsAttachmentField = computed<boolean>({
</script>
<template>
<NumberCustomizer type="payment" :type-store="companyStore" />
<NumberCustomizer type="payment" :type-store="paymentStore" />
<BaseDivider class="mt-6 mb-2" />
<PaymentsTabDefaultFormats />
<BaseDivider class="mt-6 mb-2" />
<ul class="divide-y divide-line-default">

View File

@@ -0,0 +1,107 @@
<script setup lang="ts">
import { ref, reactive, inject } from 'vue'
import { useCompanyStore } from '@v2/stores/company.store'
interface Utils {
mergeSettings: (target: Record<string, unknown>, source: Record<string, unknown>) => void
}
const companyStore = useCompanyStore()
const utils = inject<Utils>('utils')!
const mailFields = ref(['customer', 'company', 'payment'])
const companyFields = ref(['company'])
const customerAddressFields = ref(['billing', 'customer'])
const isSaving = ref(false)
const formatSettings = reactive<{
payment_mail_body: string | null
payment_company_address_format: string | null
payment_from_customer_address_format: string | null
}>({
payment_mail_body: null,
payment_company_address_format: null,
payment_from_customer_address_format: null,
})
utils.mergeSettings(
formatSettings as unknown as Record<string, unknown>,
{ ...companyStore.selectedCompanySettings }
)
async function submitForm() {
isSaving.value = true
const data = {
settings: {
...formatSettings,
},
}
await companyStore.updateCompanySettings({
data,
message: 'settings.customization.payments.payment_settings_updated',
})
isSaving.value = false
return true
}
</script>
<template>
<form @submit.prevent="submitForm">
<h6 class="text-heading text-lg font-medium">
{{ $t('settings.customization.payments.default_formats') }}
</h6>
<p class="mt-1 text-sm text-muted mb-2">
{{ $t('settings.customization.payments.default_formats_description') }}
</p>
<BaseInputGroup
:label="$t('settings.customization.payments.default_payment_email_body')"
class="mt-6 mb-4"
>
<BaseCustomInput
v-model="formatSettings.payment_mail_body"
:fields="mailFields"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.customization.payments.company_address_format')"
class="mt-6 mb-4"
>
<BaseCustomInput
v-model="formatSettings.payment_company_address_format"
:fields="companyFields"
/>
</BaseInputGroup>
<BaseInputGroup
:label="
$t('settings.customization.payments.from_customer_address_format')
"
class="mt-6 mb-4"
>
<BaseCustomInput
v-model="formatSettings.payment_from_customer_address_format"
:fields="customerAddressFields"
/>
</BaseInputGroup>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
variant="primary"
type="submit"
class="mt-4"
>
<template #left="slotProps">
<BaseIcon v-if="!isSaving" :class="slotProps.class" name="ArrowDownOnSquareIcon" />
</template>
{{ $t('settings.customization.save') }}
</BaseButton>
</form>
</template>

View File

@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router'
import { useDialogStore } from '@v2/stores/dialog.store'
import { useUserStore } from '@v2/stores/user.store'
import { useModalStore } from '@v2/stores/modal.store'
import { useNotificationStore } from '@v2/stores/notification.store'
import { roleService } from '@v2/api/services/role.service'
interface RoleRow {
@@ -19,12 +20,16 @@ const props = defineProps<{
}>()
const dialogStore = useDialogStore()
const notificationStore = useNotificationStore()
const { t } = useI18n()
const route = useRoute()
const userStore = useUserStore()
const modalStore = useModalStore()
const PROTECTED_ROLES = ['owner', 'super admin']
async function editRole(id: number): Promise<void> {
if (PROTECTED_ROLES.includes(props.row.name)) return
modalStore.openModal({
title: t('settings.roles.edit_role'),
componentName: 'RolesModal',
@@ -35,6 +40,7 @@ async function editRole(id: number): Promise<void> {
}
async function removeRole(id: number): Promise<void> {
if (PROTECTED_ROLES.includes(props.row.name)) return
dialogStore
.openDialog({
title: t('general.are_you_sure'),
@@ -48,6 +54,10 @@ async function removeRole(id: number): Promise<void> {
.then(async (res: boolean) => {
if (res) {
await roleService.delete(id)
notificationStore.showNotification({
type: 'success',
message: 'settings.roles.deleted_message',
})
props.loadData?.()
}
})
@@ -57,7 +67,7 @@ async function removeRole(id: number): Promise<void> {
<template>
<BaseDropdown>
<template #activator>
<BaseButton v-if="route.name === 'roles.view'" variant="primary">
<BaseButton v-if="route.name === 'settings.roles'" variant="primary">
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-white" />
</BaseButton>
<BaseIcon v-else name="EllipsisHorizontalIcon" class="h-5 text-muted" />

View File

@@ -4,14 +4,17 @@ import { useI18n } from 'vue-i18n'
import { required, minLength, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useModalStore } from '@v2/stores/modal.store'
import { useNotificationStore } from '@v2/stores/notification.store'
import { roleService } from '@v2/api/services/role.service'
import type { CreateRolePayload } from '@v2/api/services/role.service'
import type { Ability } from '@v2/types/domain/role'
interface AbilityItem extends Ability {
interface AbilityItem {
name: string
ability: string
disabled: boolean
depends_on?: string[]
model?: string
}
interface AbilitiesList {
@@ -25,6 +28,7 @@ interface RoleForm {
}
const modalStore = useModalStore()
const notificationStore = useNotificationStore()
const { t } = useI18n()
const isSaving = ref<boolean>(false)
@@ -67,15 +71,20 @@ async function setInitialData(): Promise<void> {
const abilitiesRes = await roleService.getAbilities()
if (abilitiesRes.abilities) {
const grouped: AbilitiesList = {}
abilitiesRes.abilities.forEach((a) => {
const group = a.title || 'General'
if (!grouped[group]) grouped[group] = []
grouped[group].push({
...a,
ability: a.name,
abilitiesRes.abilities.forEach((a: Record<string, unknown>) => {
// Extract model name from PHP class path (e.g., "App\Models\Customer" → "Customer")
const modelPath = (a.model as string) ?? ''
const modelName = modelPath
? modelPath.substring(modelPath.lastIndexOf('\\') + 1)
: 'Common'
if (!grouped[modelName]) grouped[modelName] = []
grouped[modelName].push({
name: a.name as string,
ability: a.ability as string,
disabled: false,
depends_on: [],
})
depends_on: (a.depends_on as string[]) ?? [],
} as AbilityItem)
})
abilitiesList.value = grouped
}
@@ -87,13 +96,33 @@ async function setInitialData(): Promise<void> {
currentRole.value = {
id: response.data.id,
name: response.data.name,
abilities: response.data.abilities.map((a) => ({
...a,
ability: a.name,
disabled: false,
depends_on: [],
})),
abilities: [],
}
// Match role's abilities with the full ability objects from abilitiesList
const roleAbilities = (response.data.abilities ?? []) as Array<Record<string, unknown>>
roleAbilities.forEach((ra) => {
Object.keys(abilitiesList.value).forEach((group) => {
abilitiesList.value[group].forEach((_p) => {
if (_p.ability === ra.name) {
currentRole.value.abilities.push(_p)
}
})
})
})
// Set disabled state for dependent abilities
currentRole.value.abilities.forEach((ab) => {
ab.depends_on?.forEach((_d) => {
Object.keys(abilitiesList.value).forEach((group) => {
abilitiesList.value[group].forEach((_a) => {
if (_d === _a.ability) {
_a.disabled = true
}
})
})
})
})
}
} else {
isEdit.value = false
@@ -121,8 +150,16 @@ async function submitRoleData(): Promise<void> {
if (isEdit.value && currentRole.value.id) {
await roleService.update(currentRole.value.id, payload)
notificationStore.showNotification({
type: 'success',
message: 'settings.roles.updated_message',
})
} else {
await roleService.create(payload)
notificationStore.showNotification({
type: 'success',
message: 'settings.roles.created_message',
})
}
isSaving.value = false

View File

@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router'
import { useDialogStore } from '@v2/stores/dialog.store'
import { useUserStore } from '@v2/stores/user.store'
import { useModalStore } from '@v2/stores/modal.store'
import { useNotificationStore } from '@v2/stores/notification.store'
import { taxTypeService } from '@v2/api/services/tax-type.service'
const ABILITIES = {
@@ -24,6 +25,7 @@ const props = defineProps<{
}>()
const dialogStore = useDialogStore()
const notificationStore = useNotificationStore()
const { t } = useI18n()
const route = useRoute()
const userStore = useUserStore()
@@ -54,6 +56,10 @@ function removeTaxType(id: number): void {
if (res) {
const response = await taxTypeService.delete(id)
if (response.success) {
notificationStore.showNotification({
type: 'success',
message: 'settings.tax_types.deleted_message',
})
props.loadData?.()
}
}
@@ -64,7 +70,7 @@ function removeTaxType(id: number): void {
<template>
<BaseDropdown>
<template #activator>
<BaseButton v-if="route.name === 'tax-types.view'" variant="primary">
<BaseButton v-if="route.name === 'settings.tax-types'" variant="primary">
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-white" />
</BaseButton>
<BaseIcon v-else name="EllipsisHorizontalIcon" class="h-5 text-muted" />

View File

@@ -11,6 +11,7 @@ import {
import useVuelidate from '@vuelidate/core'
import { useModalStore } from '@v2/stores/modal.store'
import { useCompanyStore } from '@v2/stores/company.store'
import { useNotificationStore } from '@v2/stores/notification.store'
import { taxTypeService } from '@v2/api/services/tax-type.service'
import type { CreateTaxTypePayload } from '@v2/api/services/tax-type.service'
@@ -25,6 +26,7 @@ interface TaxTypeForm {
const modalStore = useModalStore()
const companyStore = useCompanyStore()
const notificationStore = useNotificationStore()
const { t } = useI18n()
const isSaving = ref<boolean>(false)
@@ -122,8 +124,16 @@ async function submitTaxTypeData(): Promise<void> {
if (isEdit.value && currentTaxType.value.id) {
await taxTypeService.update(currentTaxType.value.id, payload)
notificationStore.showNotification({
type: 'success',
message: 'settings.tax_types.updated_message',
})
} else {
await taxTypeService.create(payload)
notificationStore.showNotification({
type: 'success',
message: 'settings.tax_types.created_message',
})
}
isSaving.value = false

View File

@@ -1,81 +1,235 @@
import type { RouteRecordRaw } from 'vue-router'
import { ABILITIES } from '@v2/config/abilities'
const settingsRoutes: RouteRecordRaw[] = [
// User account settings — standalone page with sidebar tabs (General, Profile Photo, Security)
{
path: 'account-settings',
component: () => import('./views/UserSettingsLayoutView.vue'),
meta: {
requiresAuth: true,
},
children: [
{
path: '',
name: 'settings.account',
redirect: { name: 'settings.account.general' },
},
{
path: 'general',
name: 'settings.account.general',
component: () => import('./views/UserGeneralView.vue'),
},
{
path: 'profile-photo',
name: 'settings.account.profile-photo',
component: () => import('./views/UserProfilePhotoView.vue'),
},
{
path: 'security',
name: 'settings.account.security',
component: () => import('./views/UserSecurityView.vue'),
},
],
},
{
path: 'settings',
component: () => import('./views/SettingsLayoutView.vue'),
children: [
{
path: '',
redirect: 'company-info',
path: 'roles-settings',
redirect: { name: 'settings.roles' },
},
{
path: 'account-settings',
name: 'settings.account',
component: () => import('./views/AccountSettingsView.vue'),
path: 'exchange-rate-provider',
redirect: { name: 'settings.exchange-rate' },
},
{
path: 'payment-mode',
redirect: { name: 'settings.payment-modes' },
},
{
path: 'expense-category',
redirect: { name: 'settings.expense-categories' },
},
{
path: 'mail-configuration',
redirect: { name: 'settings.mail-config' },
},
{
path: 'company-info',
name: 'settings.company-info',
meta: {
requiresAuth: true,
isOwner: true,
},
component: () => import('./views/CompanyInfoView.vue'),
},
{
path: 'preferences',
name: 'settings.preferences',
meta: {
requiresAuth: true,
isOwner: true,
},
component: () => import('./views/PreferencesView.vue'),
},
{
path: 'customization',
name: 'settings.customization',
meta: {
requiresAuth: true,
isOwner: true,
},
component: () => import('./views/CustomizationView.vue'),
},
{
path: 'tax-types',
name: 'settings.tax-types',
meta: {
requiresAuth: true,
ability: ABILITIES.VIEW_TAX_TYPE,
},
component: () => import('./views/TaxTypesView.vue'),
},
{
path: 'payment-modes',
name: 'settings.payment-modes',
meta: {
requiresAuth: true,
},
component: () => import('./views/PaymentModesView.vue'),
},
{
path: 'custom-fields',
name: 'settings.custom-fields',
meta: {
requiresAuth: true,
ability: ABILITIES.VIEW_CUSTOM_FIELDS,
},
component: () => import('./views/CustomFieldsView.vue'),
},
{
path: 'notes',
name: 'settings.notes',
meta: {
requiresAuth: true,
ability: ABILITIES.VIEW_NOTE,
},
component: () => import('./views/NotesView.vue'),
},
{
path: 'notifications',
name: 'settings.notifications',
meta: {
requiresAuth: true,
isOwner: true,
},
component: () => import('./views/NotificationsView.vue'),
},
{
path: 'expense-categories',
name: 'settings.expense-categories',
meta: {
requiresAuth: true,
ability: ABILITIES.VIEW_EXPENSE,
},
component: () => import('./views/ExpenseCategoriesView.vue'),
},
{
path: 'exchange-rate',
name: 'settings.exchange-rate',
meta: {
requiresAuth: true,
ability: ABILITIES.VIEW_EXCHANGE_RATE,
},
component: () => import('./views/ExchangeRateView.vue'),
},
{
path: 'mail-config',
name: 'settings.mail-config',
meta: {
requiresAuth: true,
isOwner: true,
},
component: () => import('./views/MailConfigView.vue'),
},
{
path: 'roles',
name: 'settings.roles',
meta: {
requiresAuth: true,
isOwner: true,
},
component: () => import('./views/RolesView.vue'),
},
{
path: 'danger-zone',
name: 'settings.danger-zone',
meta: {
requiresAuth: true,
isOwner: true,
},
component: () => import('./views/DangerZoneView.vue'),
},
],
},
{
path: 'user-settings/:tab?',
redirect: { name: 'settings.account' },
},
{
path: 'settings/account-settings',
redirect: { name: 'settings.account' },
},
{
path: 'company-info',
redirect: { name: 'settings.company-info' },
},
{
path: 'preferences',
redirect: { name: 'settings.preferences' },
},
{
path: 'customization',
redirect: { name: 'settings.customization' },
},
{
path: 'notifications',
redirect: { name: 'settings.notifications' },
},
{
path: 'roles-settings',
redirect: { name: 'settings.roles' },
},
{
path: 'exchange-rate-provider',
redirect: { name: 'settings.exchange-rate' },
},
{
path: 'tax-types',
redirect: { name: 'settings.tax-types' },
},
{
path: 'payment-mode',
redirect: { name: 'settings.payment-modes' },
},
{
path: 'custom-fields',
redirect: { name: 'settings.custom-fields' },
},
{
path: 'notes',
redirect: { name: 'settings.notes' },
},
{
path: 'expense-category',
redirect: { name: 'settings.expense-categories' },
},
{
path: 'mail-configuration',
redirect: { name: 'settings.mail-config' },
},
]
export default settingsRoutes

View File

@@ -53,11 +53,22 @@ async function updateAccount(): Promise<void> {
</script>
<template>
<form @submit.prevent="updateAccount">
<BaseSettingCard
:title="$t('settings.account_settings.account_settings')"
:description="$t('settings.account_settings.section_description')"
>
<BasePage>
<BasePageHeader :title="$t('settings.account_settings.account_settings')">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="/admin/dashboard" />
<BaseBreadcrumbItem
:title="$t('settings.account_settings.account_settings')"
to="#"
active
/>
</BaseBreadcrumb>
</BasePageHeader>
<form @submit.prevent="updateAccount">
<BaseSettingCard
:description="$t('settings.account_settings.section_description')"
>
<BaseInputGrid class="mt-5">
<BaseInputGroup
:label="$t('settings.account_settings.name')"
@@ -128,5 +139,6 @@ async function updateAccount(): Promise<void> {
{{ $t('settings.account_settings.save') }}
</BaseButton>
</BaseSettingCard>
</form>
</form>
</BasePage>
</template>

View File

@@ -131,14 +131,6 @@ async function updateCompanyData(): Promise<void> {
isSaving.value = false
}
function removeCompany(): void {
modalStore.openModal({
title: t('settings.company_info.are_you_absolutely_sure'),
componentName: 'DeleteCompanyModal',
size: 'sm',
})
}
</script>
<template>
@@ -245,24 +237,6 @@ function removeCompany(): void {
{{ $t('settings.company_info.save') }}
</BaseButton>
<div v-if="companyStore.companies.length !== 1" class="py-5">
<BaseDivider class="my-4" />
<h3 class="text-lg leading-6 font-medium text-heading">
{{ $t('settings.company_info.delete_company') }}
</h3>
<div class="mt-2 max-w-xl text-sm text-muted">
<p>{{ $t('settings.company_info.delete_company_description') }}</p>
</div>
<div class="mt-5">
<button
type="button"
class="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-red-700 bg-red-100 hover:bg-red-200 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm"
@click="removeCompany"
>
{{ $t('general.delete') }}
</button>
</div>
</div>
</BaseSettingCard>
</form>
</template>

View File

@@ -1,8 +1,19 @@
<script setup lang="ts">
import { provide } from 'vue'
import InvoicesTab from '@v2/features/company/settings/components/InvoicesTab.vue'
import EstimatesTab from '@v2/features/company/settings/components/EstimatesTab.vue'
import PaymentsTab from '@v2/features/company/settings/components/PaymentsTab.vue'
import ItemsTab from '@v2/features/company/settings/components/ItemsTab.vue'
provide('utils', {
mergeSettings(target: Record<string, unknown>, source: Record<string, unknown>): void {
Object.keys(source).forEach((key) => {
if (key in target) {
target[key] = source[key]
}
})
},
})
</script>
<template>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useCompanyStore } from '../../../../stores/company.store'
import { useModalStore } from '../../../../stores/modal.store'
import DeleteCompanyModal from '../components/DeleteCompanyModal.vue'
const { t } = useI18n()
const companyStore = useCompanyStore()
const modalStore = useModalStore()
function removeCompany(): void {
modalStore.openModal({
title: t('settings.company_info.are_you_absolutely_sure'),
componentName: 'DeleteCompanyModal',
size: 'sm',
})
}
</script>
<template>
<BaseSettingCard
:title="$t('settings.company_info.danger_zone')"
:description="$t('settings.company_info.delete_company_description')"
>
<div v-if="companyStore.companies.length > 1" class="mt-6">
<BaseButton
variant="danger"
type="button"
@click="removeCompany"
>
<template #left="slotProps">
<BaseIcon name="TrashIcon" :class="slotProps.class" />
</template>
{{ $t('settings.company_info.delete_company') }}
</BaseButton>
</div>
</BaseSettingCard>
<DeleteCompanyModal />
</template>

View File

@@ -6,6 +6,7 @@ import { useUserStore } from '../../../../stores/user.store'
import { noteService } from '../../../../api/services/note.service'
import NoteDropdown from '@v2/features/company/settings/components/NoteDropdown.vue'
import NoteModal from '@v2/features/company/settings/components/NoteModal.vue'
import { ABILITIES } from '@v2/config/abilities'
interface TableColumn {
key: string
@@ -31,10 +32,6 @@ interface FetchResult {
}
}
const ABILITIES = {
MANAGE_NOTE: 'manage-note',
} as const
const { t } = useI18n()
const modalStore = useModalStore()
const userStore = useUserStore()

View File

@@ -57,8 +57,14 @@ watch(
const invoiceUseTimeField = computed<boolean>({
get: () => settingsForm.invoice_use_time === 'YES',
set: (newValue: boolean) => {
settingsForm.invoice_use_time = newValue ? 'YES' : 'NO'
set: async (newValue: boolean) => {
const value = newValue ? 'YES' : 'NO'
settingsForm.invoice_use_time = value
await companyStore.updateCompanySettings({
data: { settings: { invoice_use_time: value } },
message: 'general.setting_updated',
})
},
})
@@ -303,12 +309,6 @@ async function submitData(): Promise<void> {
</BaseInputGroup>
</BaseInputGrid>
<BaseSwitchSection
v-model="invoiceUseTimeField"
:title="$t('settings.preferences.invoice_use_time')"
:description="$t('settings.preferences.invoice_use_time_description')"
/>
<BaseButton
:content-loading="isFetchingInitialData"
:disabled="isSaving"
@@ -362,6 +362,12 @@ async function submitData(): Promise<void> {
<BaseDivider class="mt-6 mb-2" />
<BaseSwitchSection
v-model="invoiceUseTimeField"
:title="$t('settings.preferences.invoice_use_time')"
:description="$t('settings.preferences.invoice_use_time_description')"
/>
<BaseSwitchSection
v-model="discountPerItemField"
:title="$t('settings.preferences.discount_per_item')"

View File

@@ -112,12 +112,20 @@ async function openRoleModal(): Promise<void> {
<template #cell-actions="{ row }">
<RoleDropdown
v-if="
userStore.currentUser?.is_owner && row.data.name !== 'super admin'
userStore.currentUser?.is_owner &&
row.data.name !== 'super admin' &&
row.data.name !== 'owner'
"
:row="row.data"
:table="table"
:load-data="refreshTable"
/>
<span
v-else-if="row.data.name === 'owner' || row.data.name === 'super admin'"
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium bg-gray-100 text-gray-600 ring-1 ring-inset ring-gray-300/50"
>
{{ $t('settings.roles.system_role') }}
</span>
</template>
</BaseTable>
</BaseSettingCard>

View File

@@ -3,6 +3,8 @@ import { ref, computed, watchEffect } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useGlobalStore } from '../../../../stores/global.store'
import { useCompanyStore } from '../../../../stores/company.store'
import { useUserStore } from '../../../../stores/user.store'
interface SettingMenuItem {
title: string
@@ -16,21 +18,42 @@ interface DropdownMenuItem extends SettingMenuItem {
const { t } = useI18n()
const globalStore = useGlobalStore()
const companyStore = useCompanyStore()
const userStore = useUserStore()
const route = useRoute()
const router = useRouter()
const showDangerZone = computed<boolean>(() => {
return (
userStore.currentUser?.is_owner === true &&
companyStore.companies.length > 1
)
})
const currentSetting = ref<DropdownMenuItem | undefined>(undefined)
const dropdownMenuItems = computed<DropdownMenuItem[]>(() => {
return (globalStore.settingMenu as SettingMenuItem[]).map((item) => ({
const items = (globalStore.settingMenu as SettingMenuItem[]).map((item) => ({
...item,
title: t(item.title),
}))
if (showDangerZone.value) {
items.push({
title: t('settings.company_info.danger_zone'),
link: '/admin/settings/danger-zone',
icon: 'ExclamationTriangleIcon',
})
}
return items
})
watchEffect(() => {
if (route.path === '/admin/settings') {
router.push('/admin/settings/company-info')
// Redirect to first available setting menu item, or account settings as fallback
const firstItem = globalStore.settingMenu?.[0]
router.push(firstItem?.link ?? '/admin/settings/account-settings')
}
const item = dropdownMenuItems.value.find((item) => item.link === route.path)
@@ -44,6 +67,7 @@ function hasActiveUrl(url: string): boolean {
function navigateToSetting(setting: DropdownMenuItem): void {
router.push(setting.link)
}
</script>
<template>
@@ -53,7 +77,7 @@ function navigateToSetting(setting: DropdownMenuItem): void {
<BaseBreadcrumbItem :title="$t('general.home')" to="/admin/dashboard" />
<BaseBreadcrumbItem
:title="$t('settings.setting', 2)"
to="/admin/settings/company-info"
to="/admin/settings"
active
/>
</BaseBreadcrumb>
@@ -89,6 +113,22 @@ function navigateToSetting(setting: DropdownMenuItem): void {
</template>
</BaseListItem>
</BaseList>
<router-link
v-if="showDangerZone"
to="/admin/settings/danger-zone"
:class="[
'cursor-pointer px-3 py-2 mt-1 text-sm font-medium leading-5 flex items-center rounded-lg transition-colors',
hasActiveUrl('/admin/settings/danger-zone')
? 'text-red-600 bg-red-50 font-semibold'
: 'text-red-500 hover:bg-red-50 hover:text-red-600',
]"
>
<span class="mr-3">
<BaseIcon name="ExclamationTriangleIcon" />
</span>
<span>{{ $t('settings.company_info.danger_zone') }}</span>
</router-link>
</div>
<div class="w-full overflow-hidden">

View File

@@ -0,0 +1,98 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { required, minLength, email, helpers } from '@vuelidate/validators'
import { useVuelidate } from '@vuelidate/core'
import { useUserStore } from '../../../../stores/user.store'
import { useGlobalStore } from '../../../../stores/global.store'
const userStore = useUserStore()
const globalStore = useGlobalStore()
const { t } = useI18n()
const isSaving = ref<boolean>(false)
const userForm = computed(() => userStore.userForm)
const rules = computed(() => ({
name: {
required: helpers.withMessage(t('validation.required'), required),
minLength: helpers.withMessage(t('validation.name_min_length'), minLength(3)),
},
email: {
required: helpers.withMessage(t('validation.required'), required),
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
}))
const v$ = useVuelidate(rules, userForm)
async function updateGeneral(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) return
isSaving.value = true
try {
await userStore.updateCurrentUser({
name: userForm.value.name,
email: userForm.value.email,
language: userForm.value.language || undefined,
})
} finally {
isSaving.value = false
}
}
</script>
<template>
<form @submit.prevent="updateGeneral">
<BaseSettingCard
:title="$t('settings.account_settings.general')"
:description="$t('settings.account_settings.section_description')"
>
<BaseInputGrid class="mt-5">
<BaseInputGroup
:label="$t('settings.account_settings.name')"
:error="v$.name.$error && v$.name.$errors[0]?.$message"
required
>
<BaseInput
v-model="userForm.name"
:invalid="v$.name.$error"
@blur="v$.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.account_settings.email')"
:error="v$.email.$error && v$.email.$errors[0]?.$message"
required
>
<BaseInput
v-model="userForm.email"
type="email"
:invalid="v$.email.$error"
@blur="v$.email.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.language')">
<BaseMultiselect
v-model="userForm.language"
:options="(globalStore.config as Record<string, unknown>)?.languages as Array<{ name: string; code: string }> ?? []"
label="name"
value-prop="code"
track-by="name"
:searchable="true"
/>
</BaseInputGroup>
</BaseInputGrid>
<BaseButton :loading="isSaving" :disabled="isSaving" type="submit" class="mt-6">
<template #left="slotProps">
<BaseIcon v-if="!isSaving" name="ArrowDownOnSquareIcon" :class="slotProps.class" />
</template>
{{ $t('settings.company_info.save') }}
</BaseButton>
</BaseSettingCard>
</form>
</template>

View File

@@ -0,0 +1,79 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useUserStore } from '../../../../stores/user.store'
import { useNotificationStore } from '../../../../stores/notification.store'
import { useI18n } from 'vue-i18n'
interface AvatarFile {
image: string
}
const userStore = useUserStore()
const notificationStore = useNotificationStore()
const { t } = useI18n()
const isSaving = ref<boolean>(false)
const imgFiles = ref<AvatarFile[]>([])
const avatarFileBlob = ref<File | null>(null)
if (userStore.currentUser?.avatar) {
imgFiles.value.push({ image: userStore.currentUser.avatar as string })
}
function onFileInputChange(_fileName: string, file: File): void {
avatarFileBlob.value = file
}
function onFileInputRemove(): void {
avatarFileBlob.value = null
}
async function updateAvatar(): Promise<void> {
if (!avatarFileBlob.value) return
isSaving.value = true
try {
const formData = new FormData()
formData.append('admin_avatar', avatarFileBlob.value)
await userStore.uploadAvatar(formData)
notificationStore.showNotification({
type: 'success',
message: 'settings.account_settings.updated_message',
})
avatarFileBlob.value = null
} finally {
isSaving.value = false
}
}
</script>
<template>
<form @submit.prevent="updateAvatar">
<BaseSettingCard
:title="$t('settings.account_settings.profile_picture')"
:description="$t('settings.account_settings.profile_picture_description')"
>
<BaseInputGrid class="mt-5">
<BaseInputGroup :label="$t('settings.account_settings.profile_picture')">
<BaseFileUploader
v-model="imgFiles"
:avatar="true"
accept="image/*"
@change="onFileInputChange"
@remove="onFileInputRemove"
/>
</BaseInputGroup>
</BaseInputGrid>
<BaseButton :loading="isSaving" :disabled="isSaving" type="submit" class="mt-6">
<template #left="slotProps">
<BaseIcon v-if="!isSaving" name="ArrowDownOnSquareIcon" :class="slotProps.class" />
</template>
{{ $t('settings.company_info.save') }}
</BaseButton>
</BaseSettingCard>
</form>
</template>

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { minLength, sameAs, helpers } from '@vuelidate/validators'
import { useVuelidate } from '@vuelidate/core'
import { useUserStore } from '../../../../stores/user.store'
const userStore = useUserStore()
const { t } = useI18n()
const isSaving = ref<boolean>(false)
const form = reactive({
password: '',
confirm_password: '',
})
const rules = computed(() => ({
password: {
minLength: helpers.withMessage(
t('validation.password_min_length', { count: 8 }),
minLength(8),
),
},
confirm_password: {
sameAsPassword: helpers.withMessage(
t('validation.password_incorrect'),
sameAs(form.password),
),
},
}))
const v$ = useVuelidate(rules, form)
async function updatePassword(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) return
isSaving.value = true
try {
await userStore.updateCurrentUser({
name: userStore.currentUser?.name ?? '',
email: userStore.currentUser?.email ?? '',
password: form.password,
confirm_password: form.confirm_password,
})
form.password = ''
form.confirm_password = ''
v$.value.$reset()
} finally {
isSaving.value = false
}
}
</script>
<template>
<form @submit.prevent="updatePassword">
<BaseSettingCard
:title="$t('settings.account_settings.security')"
:description="$t('settings.account_settings.security_description')"
>
<BaseInputGrid class="mt-5">
<BaseInputGroup
:label="$t('settings.account_settings.password')"
:error="v$.password.$error && v$.password.$errors[0]?.$message"
>
<BaseInput
v-model="form.password"
type="password"
:invalid="v$.password.$error"
@blur="v$.password.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.account_settings.confirm_password')"
:error="v$.confirm_password.$error && v$.confirm_password.$errors[0]?.$message"
>
<BaseInput
v-model="form.confirm_password"
type="password"
:invalid="v$.confirm_password.$error"
@blur="v$.confirm_password.$touch()"
/>
</BaseInputGroup>
</BaseInputGrid>
<BaseButton :loading="isSaving" :disabled="isSaving" type="submit" class="mt-6">
<template #left="slotProps">
<BaseIcon v-if="!isSaving" name="ArrowDownOnSquareIcon" :class="slotProps.class" />
</template>
{{ $t('settings.company_info.save') }}
</BaseButton>
</BaseSettingCard>
</form>
</template>

View File

@@ -0,0 +1,107 @@
<script setup lang="ts">
import { ref, computed, watchEffect } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
interface MenuItem {
title: string
link: string
icon: string
}
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const currentSetting = ref<MenuItem | undefined>(undefined)
const menuItems = computed<MenuItem[]>(() => [
{
title: t('settings.account_settings.general'),
link: '/admin/account-settings/general',
icon: 'UserIcon',
},
{
title: t('settings.account_settings.profile_picture'),
link: '/admin/account-settings/profile-photo',
icon: 'PhotoIcon',
},
{
title: t('settings.account_settings.security'),
link: '/admin/account-settings/security',
icon: 'LockClosedIcon',
},
])
watchEffect(() => {
if (route.path === '/admin/account-settings') {
router.push('/admin/account-settings/general')
}
const item = menuItems.value.find((item) => item.link === route.path)
currentSetting.value = item
})
function hasActiveUrl(url: string): boolean {
return route.path.indexOf(url) > -1
}
function navigateToSetting(setting: MenuItem): void {
router.push(setting.link)
}
</script>
<template>
<BasePage>
<BasePageHeader
:title="$t('settings.account_settings.account_settings')"
class="mb-6"
>
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="/admin/dashboard" />
<BaseBreadcrumbItem
:title="$t('settings.account_settings.account_settings')"
to="#"
active
/>
</BaseBreadcrumb>
</BasePageHeader>
<div class="w-full mb-6 select-wrapper xl:hidden">
<BaseMultiselect
v-model="currentSetting"
:options="menuItems"
:can-deselect="false"
value-prop="title"
track-by="title"
label="title"
object
@update:model-value="navigateToSetting"
/>
</div>
<div class="flex gap-8">
<div class="hidden mt-1 xl:block min-w-[240px] sticky top-20 self-start">
<BaseList>
<BaseListItem
v-for="(menuItem, index) in menuItems"
:key="index"
:title="menuItem.title"
:to="menuItem.link"
:active="hasActiveUrl(menuItem.link)"
:index="index"
class="py-3"
>
<template #icon>
<BaseIcon :name="menuItem.icon" />
</template>
</BaseListItem>
</BaseList>
</div>
<div class="w-full overflow-hidden">
<RouterView />
</div>
</div>
</BasePage>
</template>