mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-05-10 21:34:58 +00:00
Finalize Typescript restructure
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
|
||||
@@ -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')"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user