mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-04-19 11:14:06 +00:00
Rename resources/scripts-v2 to resources/scripts and drop @v2 alias
Now that the legacy v1 frontend (commit 064bdf53) is gone, the v2 directory is the only frontend and the v2 suffix is just noise. Renames resources/scripts-v2 to resources/scripts via git mv (so git records the move as renames, preserving blame and log --follow), then bulk-rewrites the 152 files that imported via @v2/... to use @/scripts/... instead. The existing @ alias (resources/) covers the new path with no extra config needed.
Drops the now-unused @v2 alias from vite.config.js and points the laravel-vite-plugin entry at resources/scripts/main.ts. Updates the only blade reference (resources/views/app.blade.php) to match. The package.json test script (eslint ./resources/scripts) automatically targets the right place after the rename without any edit.
Verified: npm run build exits clean and the Vite warning lines now reference resources/scripts/plugins/i18n.ts, confirming every import resolved through the new path. git log --follow on any moved file walks back through its scripts-v2 history.
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, computed, reactive } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { required, email, helpers } from '@vuelidate/validators'
|
||||
import useVuelidate from '@vuelidate/core'
|
||||
|
||||
interface BasicMailConfig {
|
||||
mail_driver: string
|
||||
from_mail: string
|
||||
from_name: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
configData?: Record<string, unknown>
|
||||
isSaving?: boolean
|
||||
isFetchingInitialData?: boolean
|
||||
mailDrivers?: string[]
|
||||
}>(),
|
||||
{
|
||||
configData: () => ({}),
|
||||
isSaving: false,
|
||||
isFetchingInitialData: false,
|
||||
mailDrivers: () => [],
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'submit-data': [config: BasicMailConfig]
|
||||
'on-change-driver': [driver: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const basicMailConfig = reactive<BasicMailConfig>({
|
||||
mail_driver: 'sendmail',
|
||||
from_mail: '',
|
||||
from_name: '',
|
||||
})
|
||||
|
||||
const rules = computed(() => ({
|
||||
mail_driver: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
from_mail: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
email: helpers.withMessage(t('validation.email_incorrect'), email),
|
||||
},
|
||||
from_name: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
}))
|
||||
|
||||
const v$ = useVuelidate(rules, basicMailConfig)
|
||||
|
||||
onMounted(() => {
|
||||
for (const key in basicMailConfig) {
|
||||
if (Object.prototype.hasOwnProperty.call(props.configData, key)) {
|
||||
;(basicMailConfig as Record<string, unknown>)[key] = props.configData[key]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function saveEmailConfig(): Promise<void> {
|
||||
v$.value.$touch()
|
||||
if (!v$.value.$invalid) {
|
||||
emit('submit-data', { ...basicMailConfig })
|
||||
}
|
||||
}
|
||||
|
||||
function onChangeDriver(): void {
|
||||
v$.value.mail_driver.$touch()
|
||||
emit('on-change-driver', basicMailConfig.mail_driver)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="saveEmailConfig">
|
||||
<BaseInputGrid>
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.mail.driver')"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="
|
||||
v$.mail_driver.$error && v$.mail_driver.$errors[0].$message
|
||||
"
|
||||
required
|
||||
>
|
||||
<BaseMultiselect
|
||||
v-model="basicMailConfig.mail_driver"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:options="mailDrivers"
|
||||
:can-deselect="false"
|
||||
:invalid="v$.mail_driver.$error"
|
||||
@update:model-value="onChangeDriver"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.mail.from_mail')"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="
|
||||
v$.from_mail.$error && v$.from_mail.$errors[0].$message
|
||||
"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="basicMailConfig.from_mail"
|
||||
:content-loading="isFetchingInitialData"
|
||||
type="text"
|
||||
name="from_mail"
|
||||
:invalid="v$.from_mail.$error"
|
||||
@input="v$.from_mail.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.mail.from_name')"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="
|
||||
v$.from_name.$error && v$.from_name.$errors[0].$message
|
||||
"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="basicMailConfig.from_name"
|
||||
:content-loading="isFetchingInitialData"
|
||||
type="text"
|
||||
name="name"
|
||||
:invalid="v$.from_name.$error"
|
||||
@input="v$.from_name.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
</BaseInputGrid>
|
||||
<div class="flex mt-8">
|
||||
<BaseButton
|
||||
:content-loading="isFetchingInitialData"
|
||||
:disabled="isSaving"
|
||||
:loading="isSaving"
|
||||
variant="primary"
|
||||
type="submit"
|
||||
>
|
||||
<template #left="slotProps">
|
||||
<BaseIcon
|
||||
v-if="!isSaving"
|
||||
:class="slotProps.class"
|
||||
name="ArrowDownOnSquareIcon"
|
||||
/>
|
||||
</template>
|
||||
{{ $t('general.save') }}
|
||||
</BaseButton>
|
||||
<slot />
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,203 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { required, minLength, maxLength, helpers } from '@vuelidate/validators'
|
||||
import useVuelidate from '@vuelidate/core'
|
||||
import { useModalStore } from '@/scripts/stores/modal.store'
|
||||
import { useNotificationStore } from '@/scripts/stores/notification.store'
|
||||
import { expenseService } from '@/scripts/api/services/expense.service'
|
||||
|
||||
interface CategoryForm {
|
||||
id: number | null
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const modalStore = useModalStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isSaving = ref<boolean>(false)
|
||||
const isEdit = ref<boolean>(false)
|
||||
|
||||
const currentCategory = ref<CategoryForm>({
|
||||
id: null,
|
||||
name: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const modalActive = computed<boolean>(
|
||||
() => modalStore.active && modalStore.componentName === 'CategoryModal'
|
||||
)
|
||||
|
||||
const rules = computed(() => ({
|
||||
name: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
minLength: helpers.withMessage(
|
||||
t('validation.name_min_length', { count: 3 }),
|
||||
minLength(3)
|
||||
),
|
||||
},
|
||||
description: {
|
||||
maxLength: helpers.withMessage(
|
||||
t('validation.description_maxlength', { count: 255 }),
|
||||
maxLength(255)
|
||||
),
|
||||
},
|
||||
}))
|
||||
|
||||
const v$ = useVuelidate(rules, currentCategory)
|
||||
|
||||
async function setInitialData(): Promise<void> {
|
||||
if (modalStore.data && typeof modalStore.data === 'number') {
|
||||
isEdit.value = true
|
||||
const response = await expenseService.getCategory(modalStore.data)
|
||||
if (response.data) {
|
||||
currentCategory.value = {
|
||||
id: response.data.id,
|
||||
name: response.data.name,
|
||||
description: response.data.description ?? '',
|
||||
}
|
||||
}
|
||||
} else {
|
||||
isEdit.value = false
|
||||
resetForm()
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCategoryData(): Promise<void> {
|
||||
v$.value.$touch()
|
||||
if (v$.value.$invalid) {
|
||||
return
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
|
||||
try {
|
||||
if (isEdit.value && currentCategory.value.id) {
|
||||
await expenseService.updateCategory(currentCategory.value.id, {
|
||||
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
|
||||
if (modalStore.refreshData) {
|
||||
modalStore.refreshData()
|
||||
}
|
||||
closeCategoryModal()
|
||||
} catch {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm(): void {
|
||||
currentCategory.value = {
|
||||
id: null,
|
||||
name: '',
|
||||
description: '',
|
||||
}
|
||||
}
|
||||
|
||||
function closeCategoryModal(): void {
|
||||
modalStore.closeModal()
|
||||
setTimeout(() => {
|
||||
resetForm()
|
||||
v$.value.$reset()
|
||||
}, 300)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal
|
||||
:show="modalActive"
|
||||
@close="closeCategoryModal"
|
||||
@open="setInitialData"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex justify-between w-full">
|
||||
{{ modalStore.title }}
|
||||
<BaseIcon
|
||||
name="XMarkIcon"
|
||||
class="w-6 h-6 text-muted cursor-pointer"
|
||||
@click="closeCategoryModal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<form action="" @submit.prevent="submitCategoryData">
|
||||
<div class="p-8 sm:p-6">
|
||||
<BaseInputGrid layout="one-column">
|
||||
<BaseInputGroup
|
||||
:label="$t('expenses.category')"
|
||||
:error="v$.name.$error && v$.name.$errors[0].$message"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model="currentCategory.name"
|
||||
:invalid="v$.name.$error"
|
||||
type="text"
|
||||
@input="v$.name.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t('expenses.description')"
|
||||
:error="
|
||||
v$.description.$error && v$.description.$errors[0].$message
|
||||
"
|
||||
>
|
||||
<BaseTextarea
|
||||
v-model="currentCategory.description"
|
||||
rows="4"
|
||||
cols="50"
|
||||
@input="v$.description.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
</BaseInputGrid>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="z-0 flex justify-end p-4 border-t border-line-default border-solid"
|
||||
>
|
||||
<BaseButton
|
||||
type="button"
|
||||
variant="primary-outline"
|
||||
class="mr-3 text-sm"
|
||||
@click="closeCategoryModal"
|
||||
>
|
||||
{{ $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>
|
||||
{{ isEdit ? $t('general.update') : $t('general.save') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</form>
|
||||
</BaseModal>
|
||||
</template>
|
||||
@@ -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 '@/scripts/stores/modal.store'
|
||||
import { useCompanyStore } from '@/scripts/stores/company.store'
|
||||
import { useGlobalStore } from '@/scripts/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>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDialogStore } from '@/scripts/stores/dialog.store'
|
||||
import { useUserStore } from '@/scripts/stores/user.store'
|
||||
import { useModalStore } from '@/scripts/stores/modal.store'
|
||||
import { customFieldService } from '@/scripts/api/services/custom-field.service'
|
||||
|
||||
const ABILITIES = {
|
||||
EDIT_CUSTOM_FIELDS: 'edit-custom-field',
|
||||
DELETE_CUSTOM_FIELDS: 'delete-custom-field',
|
||||
} as const
|
||||
|
||||
interface CustomFieldRow {
|
||||
id: number
|
||||
name: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
row: CustomFieldRow
|
||||
table?: { refresh: () => void } | null
|
||||
loadData?: (() => void) | null
|
||||
}>()
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
const modalStore = useModalStore()
|
||||
|
||||
async function editCustomField(id: number): Promise<void> {
|
||||
modalStore.openModal({
|
||||
title: t('settings.custom_fields.edit_custom_field'),
|
||||
componentName: 'CustomFieldModal',
|
||||
size: 'sm',
|
||||
data: id,
|
||||
refreshData: props.loadData ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
async function removeCustomField(id: number): Promise<void> {
|
||||
dialogStore
|
||||
.openDialog({
|
||||
title: t('general.are_you_sure'),
|
||||
message: t('settings.custom_fields.custom_field_confirm_delete'),
|
||||
yesLabel: t('general.ok'),
|
||||
noLabel: t('general.cancel'),
|
||||
variant: 'danger',
|
||||
hideNoButton: false,
|
||||
size: 'lg',
|
||||
})
|
||||
.then(async (res: boolean) => {
|
||||
if (res) {
|
||||
await customFieldService.delete(id)
|
||||
props.loadData?.()
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseDropdown>
|
||||
<template #activator>
|
||||
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-muted" />
|
||||
</template>
|
||||
|
||||
<BaseDropdownItem
|
||||
v-if="userStore.hasAbilities(ABILITIES.EDIT_CUSTOM_FIELDS)"
|
||||
@click="editCustomField(row.id)"
|
||||
>
|
||||
<BaseIcon
|
||||
name="PencilIcon"
|
||||
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
|
||||
/>
|
||||
{{ $t('general.edit') }}
|
||||
</BaseDropdownItem>
|
||||
|
||||
<BaseDropdownItem
|
||||
v-if="userStore.hasAbilities(ABILITIES.DELETE_CUSTOM_FIELDS)"
|
||||
@click="removeCustomField(row.id)"
|
||||
>
|
||||
<BaseIcon
|
||||
name="TrashIcon"
|
||||
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
|
||||
/>
|
||||
{{ $t('general.delete') }}
|
||||
</BaseDropdownItem>
|
||||
</BaseDropdown>
|
||||
</template>
|
||||
@@ -0,0 +1,490 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, computed, defineAsyncComponent } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import useVuelidate from '@vuelidate/core'
|
||||
import { required, numeric, helpers } from '@vuelidate/validators'
|
||||
import { useModalStore } from '@/scripts/stores/modal.store'
|
||||
import { useNotificationStore } from '@/scripts/stores/notification.store'
|
||||
import { customFieldService } from '@/scripts/api/services/custom-field.service'
|
||||
import type { CreateCustomFieldPayload } from '@/scripts/api/services/custom-field.service'
|
||||
|
||||
interface FieldOption {
|
||||
name: string
|
||||
}
|
||||
|
||||
interface DataType {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface CustomFieldForm {
|
||||
id: number | null
|
||||
name: string
|
||||
label: string
|
||||
model_type: string
|
||||
type: string
|
||||
placeholder: string | null
|
||||
is_required: number
|
||||
options: FieldOption[]
|
||||
order: number | null
|
||||
default_answer: string | boolean | number | null
|
||||
dateTimeValue: string | null
|
||||
in_use: boolean
|
||||
}
|
||||
|
||||
const modalStore = useModalStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isSaving = ref<boolean>(false)
|
||||
const isEdit = ref<boolean>(false)
|
||||
|
||||
const currentCustomField = ref<CustomFieldForm>({
|
||||
id: null,
|
||||
name: '',
|
||||
label: '',
|
||||
model_type: 'Customer',
|
||||
type: 'Input',
|
||||
placeholder: null,
|
||||
is_required: 0,
|
||||
options: [],
|
||||
order: null,
|
||||
default_answer: null,
|
||||
dateTimeValue: null,
|
||||
in_use: false,
|
||||
})
|
||||
|
||||
const modelTypes = reactive([
|
||||
{ label: t('settings.custom_fields.model_type.customer'), value: 'Customer' },
|
||||
{ label: t('settings.custom_fields.model_type.invoice'), value: 'Invoice' },
|
||||
{ label: t('settings.custom_fields.model_type.estimate'), value: 'Estimate' },
|
||||
{ label: t('settings.custom_fields.model_type.expense'), value: 'Expense' },
|
||||
{ label: t('settings.custom_fields.model_type.payment'), value: 'Payment' },
|
||||
])
|
||||
|
||||
const dataTypes = reactive<DataType[]>([
|
||||
{ label: 'Text', value: 'Input' },
|
||||
{ label: 'Textarea', value: 'TextArea' },
|
||||
{ label: 'Phone', value: 'Phone' },
|
||||
{ label: 'URL', value: 'Url' },
|
||||
{ label: 'Number', value: 'Number' },
|
||||
{ label: 'Select Field', value: 'Dropdown' },
|
||||
{ label: 'Switch Toggle', value: 'Switch' },
|
||||
{ label: 'Date', value: 'Date' },
|
||||
{ label: 'Time', value: 'Time' },
|
||||
{ label: 'Date & Time', value: 'DateTime' },
|
||||
])
|
||||
|
||||
const selectedType = ref<DataType>(dataTypes[0])
|
||||
|
||||
const modalActive = computed<boolean>(
|
||||
() => modalStore.active && modalStore.componentName === 'CustomFieldModal'
|
||||
)
|
||||
|
||||
const isSwitchTypeSelected = computed<boolean>(
|
||||
() => selectedType.value?.label === 'Switch Toggle'
|
||||
)
|
||||
|
||||
const isDropdownSelected = computed<boolean>(
|
||||
() => selectedType.value?.label === 'Select Field'
|
||||
)
|
||||
|
||||
const defaultValueComponent = computed(() => {
|
||||
if (currentCustomField.value.type) {
|
||||
return defineAsyncComponent(
|
||||
() =>
|
||||
import(
|
||||
`@/scripts/admin/components/custom-fields/types/${currentCustomField.value.type}Type.vue`
|
||||
)
|
||||
)
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const isRequiredField = computed<boolean>({
|
||||
get: () => currentCustomField.value.is_required === 1,
|
||||
set: (value: boolean) => {
|
||||
currentCustomField.value.is_required = value ? 1 : 0
|
||||
},
|
||||
})
|
||||
|
||||
const rules = computed(() => ({
|
||||
name: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
label: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
model_type: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
order: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
numeric: helpers.withMessage(t('validation.numbers_only'), numeric),
|
||||
},
|
||||
type: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
}))
|
||||
|
||||
const v$ = useVuelidate(rules, currentCustomField)
|
||||
|
||||
function setData(): void {
|
||||
if (isEdit.value) {
|
||||
const found = dataTypes.find(
|
||||
(type) => type.value === currentCustomField.value.type
|
||||
)
|
||||
if (found) selectedType.value = found
|
||||
} else {
|
||||
currentCustomField.value.model_type = modelTypes[0].value
|
||||
currentCustomField.value.type = dataTypes[0].value
|
||||
selectedType.value = dataTypes[0]
|
||||
}
|
||||
}
|
||||
|
||||
async function setInitialData(): Promise<void> {
|
||||
if (modalStore.data && typeof modalStore.data === 'number') {
|
||||
isEdit.value = true
|
||||
const response = await customFieldService.get(modalStore.data)
|
||||
if (response.data) {
|
||||
const field = response.data
|
||||
currentCustomField.value = {
|
||||
id: field.id,
|
||||
name: field.name,
|
||||
label: field.label,
|
||||
model_type: field.model_type,
|
||||
type: field.type,
|
||||
placeholder: field.placeholder,
|
||||
is_required: field.is_required ? 1 : 0,
|
||||
options: field.options
|
||||
? field.options.map((o) => ({ name: typeof o === 'string' ? o : o }))
|
||||
: [],
|
||||
order: field.order,
|
||||
default_answer: field.default_answer,
|
||||
dateTimeValue: null,
|
||||
in_use: field.in_use,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
isEdit.value = false
|
||||
resetForm()
|
||||
}
|
||||
setData()
|
||||
}
|
||||
|
||||
async function submitCustomFieldData(): Promise<void> {
|
||||
v$.value.$touch()
|
||||
if (v$.value.$invalid) {
|
||||
return
|
||||
}
|
||||
|
||||
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,
|
||||
model_type: currentCustomField.value.model_type,
|
||||
type: currentCustomField.value.type,
|
||||
placeholder: currentCustomField.value.placeholder,
|
||||
is_required: currentCustomField.value.is_required === 1,
|
||||
options: currentCustomField.value.options.length
|
||||
? 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
|
||||
if (modalStore.refreshData) {
|
||||
modalStore.refreshData()
|
||||
}
|
||||
closeCustomFieldModal()
|
||||
} catch {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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 },
|
||||
...currentCustomField.value.options,
|
||||
]
|
||||
}
|
||||
|
||||
function removeOption(index: number): void {
|
||||
if (isEdit.value && currentCustomField.value.in_use) {
|
||||
return
|
||||
}
|
||||
|
||||
const option = currentCustomField.value.options[index]
|
||||
if (option.name === currentCustomField.value.default_answer) {
|
||||
currentCustomField.value.default_answer = null
|
||||
}
|
||||
|
||||
currentCustomField.value.options.splice(index, 1)
|
||||
}
|
||||
|
||||
function onSelectedTypeChange(data: DataType): void {
|
||||
currentCustomField.value.type = data.value
|
||||
}
|
||||
|
||||
function resetForm(): void {
|
||||
currentCustomField.value = {
|
||||
id: null,
|
||||
name: '',
|
||||
label: '',
|
||||
model_type: 'Customer',
|
||||
type: 'Input',
|
||||
placeholder: null,
|
||||
is_required: 0,
|
||||
options: [],
|
||||
order: null,
|
||||
default_answer: null,
|
||||
dateTimeValue: null,
|
||||
in_use: false,
|
||||
}
|
||||
selectedType.value = dataTypes[0]
|
||||
}
|
||||
|
||||
function closeCustomFieldModal(): void {
|
||||
modalStore.closeModal()
|
||||
setTimeout(() => {
|
||||
resetForm()
|
||||
v$.value.$reset()
|
||||
}, 300)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal :show="modalActive" @open="setInitialData">
|
||||
<template #header>
|
||||
<div class="flex justify-between w-full">
|
||||
{{ modalStore.title }}
|
||||
<BaseIcon
|
||||
name="XMarkIcon"
|
||||
class="w-6 h-6 text-muted cursor-pointer"
|
||||
@click="closeCustomFieldModal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<form action="" @submit.prevent="submitCustomFieldData">
|
||||
<div class="overflow-y-auto max-h-[550px]">
|
||||
<div class="px-4 md:px-8 py-8 overflow-y-auto sm:p-6">
|
||||
<BaseInputGrid layout="one-column">
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.custom_fields.name')"
|
||||
required
|
||||
:error="v$.name.$error && v$.name.$errors[0].$message"
|
||||
>
|
||||
<BaseInput
|
||||
v-model="currentCustomField.name"
|
||||
:invalid="v$.name.$error"
|
||||
@input="v$.name.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.custom_fields.model')"
|
||||
:error="
|
||||
v$.model_type.$error && v$.model_type.$errors[0].$message
|
||||
"
|
||||
:help-text="
|
||||
currentCustomField.in_use
|
||||
? $t('settings.custom_fields.model_in_use')
|
||||
: ''
|
||||
"
|
||||
required
|
||||
>
|
||||
<BaseMultiselect
|
||||
v-model="currentCustomField.model_type"
|
||||
:options="modelTypes"
|
||||
value-prop="value"
|
||||
:can-deselect="false"
|
||||
:invalid="v$.model_type.$error"
|
||||
:searchable="true"
|
||||
:disabled="currentCustomField.in_use"
|
||||
@input="v$.model_type.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
class="flex items-center space-x-4"
|
||||
:label="$t('settings.custom_fields.required')"
|
||||
>
|
||||
<BaseSwitch v-model="isRequiredField" />
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.custom_fields.type')"
|
||||
:error="v$.type.$error && v$.type.$errors[0].$message"
|
||||
:help-text="
|
||||
currentCustomField.in_use
|
||||
? $t('settings.custom_fields.type_in_use')
|
||||
: ''
|
||||
"
|
||||
required
|
||||
>
|
||||
<BaseMultiselect
|
||||
v-model="selectedType"
|
||||
:options="dataTypes"
|
||||
:invalid="v$.type.$error"
|
||||
:disabled="currentCustomField.in_use"
|
||||
:searchable="true"
|
||||
:can-deselect="false"
|
||||
object
|
||||
@update:model-value="onSelectedTypeChange"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.custom_fields.label')"
|
||||
required
|
||||
:error="v$.label.$error && v$.label.$errors[0].$message"
|
||||
>
|
||||
<BaseInput
|
||||
v-model="currentCustomField.label"
|
||||
:invalid="v$.label.$error"
|
||||
@input="v$.label.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
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"
|
||||
class="flex items-center mt-5"
|
||||
>
|
||||
<BaseInput v-model="option.name" class="w-64" />
|
||||
<BaseIcon
|
||||
name="MinusCircleIcon"
|
||||
class="ml-1 cursor-pointer"
|
||||
:class="
|
||||
currentCustomField.in_use
|
||||
? 'text-subtle'
|
||||
: 'text-red-300'
|
||||
"
|
||||
@click="removeOption(index)"
|
||||
/>
|
||||
</div>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.custom_fields.default_value')"
|
||||
class="relative"
|
||||
>
|
||||
<component
|
||||
:is="defaultValueComponent"
|
||||
v-if="defaultValueComponent"
|
||||
v-model="currentCustomField.default_answer"
|
||||
:options="currentCustomField.options"
|
||||
:default-date-time="currentCustomField.dateTimeValue"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
v-if="!isSwitchTypeSelected"
|
||||
:label="$t('settings.custom_fields.placeholder')"
|
||||
>
|
||||
<BaseInput v-model="currentCustomField.placeholder" />
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.custom_fields.order')"
|
||||
:error="v$.order.$error && v$.order.$errors[0].$message"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model="currentCustomField.order"
|
||||
type="number"
|
||||
:invalid="v$.order.$error"
|
||||
@input="v$.order.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
</BaseInputGrid>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="z-0 flex justify-end p-4 border-t border-solid border-line-default"
|
||||
>
|
||||
<BaseButton
|
||||
class="mr-3"
|
||||
type="button"
|
||||
variant="primary-outline"
|
||||
@click="closeCustomFieldModal"
|
||||
>
|
||||
{{ $t('general.cancel') }}
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
variant="primary"
|
||||
:loading="isSaving"
|
||||
:disabled="isSaving"
|
||||
type="submit"
|
||||
>
|
||||
<template #left="slotProps">
|
||||
<BaseIcon
|
||||
v-if="!isSaving"
|
||||
:class="slotProps.class"
|
||||
name="ArrowDownOnSquareIcon"
|
||||
/>
|
||||
</template>
|
||||
{{ isEdit ? $t('general.update') : $t('general.save') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</form>
|
||||
</BaseModal>
|
||||
</template>
|
||||
@@ -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 '@/scripts/stores/modal.store'
|
||||
import { useCompanyStore } from '@/scripts/stores/company.store'
|
||||
import { useGlobalStore } from '@/scripts/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>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, inject } from 'vue'
|
||||
import { useCompanyStore } from '@/scripts/stores/company.store'
|
||||
import { useEstimateStore } from '@/scripts/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
|
||||
}
|
||||
|
||||
const utils = inject<Utils>('utils')!
|
||||
const companyStore = useCompanyStore()
|
||||
const estimateStore = useEstimateStore()
|
||||
|
||||
const estimateSettings = reactive<{ estimate_email_attachment: string | null }>({
|
||||
estimate_email_attachment: null,
|
||||
})
|
||||
|
||||
utils.mergeSettings(
|
||||
estimateSettings as unknown as Record<string, unknown>,
|
||||
{ ...companyStore.selectedCompanySettings }
|
||||
)
|
||||
|
||||
const sendAsAttachmentField = computed<boolean>({
|
||||
get: () => estimateSettings.estimate_email_attachment === 'YES',
|
||||
set: async (newValue: boolean) => {
|
||||
const value = newValue ? 'YES' : 'NO'
|
||||
|
||||
const data = {
|
||||
settings: {
|
||||
estimate_email_attachment: value,
|
||||
},
|
||||
}
|
||||
|
||||
estimateSettings.estimate_email_attachment = value
|
||||
|
||||
await companyStore.updateCompanySettings({
|
||||
data,
|
||||
message: 'general.setting_updated',
|
||||
})
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<BaseSwitchSection
|
||||
v-model="sendAsAttachmentField"
|
||||
:title="$t('settings.customization.estimates.estimate_email_attachment')"
|
||||
:description="
|
||||
$t(
|
||||
'settings.customization.estimates.estimate_email_attachment_setting_description'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</ul>
|
||||
</template>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, inject } from 'vue'
|
||||
import { useCompanyStore } from '@/scripts/stores/company.store'
|
||||
import { useGlobalStore } from '@/scripts/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 '@/scripts/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 '@/scripts/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>
|
||||
@@ -0,0 +1,448 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import useVuelidate from '@vuelidate/core'
|
||||
import { required, helpers, requiredIf, url } from '@vuelidate/validators'
|
||||
import { useModalStore } from '@/scripts/stores/modal.store'
|
||||
import { useNotificationStore } from '@/scripts/stores/notification.store'
|
||||
import { exchangeRateService } from '@/scripts/api/services/exchange-rate.service'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
|
||||
interface DriverOption {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface ServerOption {
|
||||
value: string
|
||||
}
|
||||
|
||||
interface ExchangeRateForm {
|
||||
id: number | null
|
||||
driver: string
|
||||
key: string | null
|
||||
active: boolean
|
||||
currencies: string[]
|
||||
}
|
||||
|
||||
interface CurrencyConverterForm {
|
||||
type: string
|
||||
url: string
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const modalStore = useModalStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
const isSaving = ref<boolean>(false)
|
||||
const isFetchingInitialData = ref<boolean>(false)
|
||||
const isFetchingCurrencies = ref<boolean>(false)
|
||||
const isEdit = ref<boolean>(false)
|
||||
const currenciesAlreadyInUsed = ref<string[]>([])
|
||||
const supportedCurrencies = ref<string[]>([])
|
||||
const serverOptions = ref<ServerOption[]>([])
|
||||
const drivers = ref<DriverOption[]>([])
|
||||
|
||||
const currentExchangeRate = ref<ExchangeRateForm>({
|
||||
id: null,
|
||||
driver: 'currency_converter',
|
||||
key: null,
|
||||
active: true,
|
||||
currencies: [],
|
||||
})
|
||||
|
||||
const currencyConverter = ref<CurrencyConverterForm>({
|
||||
type: '',
|
||||
url: '',
|
||||
})
|
||||
|
||||
const modalActive = computed<boolean>(
|
||||
() =>
|
||||
modalStore.active &&
|
||||
modalStore.componentName === 'ExchangeRateProviderModal'
|
||||
)
|
||||
|
||||
const isCurrencyConverter = computed<boolean>(
|
||||
() => currentExchangeRate.value.driver === 'currency_converter'
|
||||
)
|
||||
|
||||
const isDedicatedServer = computed<boolean>(
|
||||
() => currencyConverter.value.type === 'DEDICATED'
|
||||
)
|
||||
|
||||
const driverSite = computed<string>(() => {
|
||||
switch (currentExchangeRate.value.driver) {
|
||||
case 'currency_converter':
|
||||
return 'https://www.currencyconverterapi.com'
|
||||
case 'currency_freak':
|
||||
return 'https://currencyfreaks.com'
|
||||
case 'currency_layer':
|
||||
return 'https://currencylayer.com'
|
||||
case 'open_exchange_rate':
|
||||
return 'https://openexchangerates.org'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
const driversLists = computed(() =>
|
||||
drivers.value.map((item) => ({
|
||||
...item,
|
||||
key: t(item.key),
|
||||
}))
|
||||
)
|
||||
|
||||
const rules = computed(() => ({
|
||||
driver: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
key: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
currencies: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
converterType: {
|
||||
required: helpers.withMessage(
|
||||
t('validation.required'),
|
||||
requiredIf(isCurrencyConverter)
|
||||
),
|
||||
},
|
||||
converterUrl: {
|
||||
required: helpers.withMessage(
|
||||
t('validation.required'),
|
||||
requiredIf(isDedicatedServer)
|
||||
),
|
||||
url: helpers.withMessage(t('validation.invalid_url'), url),
|
||||
},
|
||||
}))
|
||||
|
||||
const v$ = useVuelidate(rules, currentExchangeRate)
|
||||
|
||||
watch(isCurrencyConverter, (newVal) => {
|
||||
if (newVal) {
|
||||
fetchServers()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const fetchCurrenciesDebounced = useDebounceFn(() => {
|
||||
fetchCurrencies()
|
||||
}, 500)
|
||||
|
||||
watch(() => currentExchangeRate.value.key, (newVal) => {
|
||||
if (newVal) fetchCurrenciesDebounced()
|
||||
})
|
||||
|
||||
watch(() => currencyConverter.value.type, (newVal) => {
|
||||
if (newVal) fetchCurrenciesDebounced()
|
||||
})
|
||||
|
||||
function dismiss(): void {
|
||||
currenciesAlreadyInUsed.value = []
|
||||
}
|
||||
|
||||
function removeUsedSelectedCurrencies(): void {
|
||||
const { currencies } = currentExchangeRate.value
|
||||
currenciesAlreadyInUsed.value.forEach((uc) => {
|
||||
const idx = currencies.indexOf(uc)
|
||||
if (idx > -1) currencies.splice(idx, 1)
|
||||
})
|
||||
currenciesAlreadyInUsed.value = []
|
||||
}
|
||||
|
||||
function resetCurrency(): void {
|
||||
currentExchangeRate.value.key = null
|
||||
currentExchangeRate.value.currencies = []
|
||||
supportedCurrencies.value = []
|
||||
}
|
||||
|
||||
function resetModalData(): void {
|
||||
supportedCurrencies.value = []
|
||||
currentExchangeRate.value = {
|
||||
id: null,
|
||||
driver: 'currency_converter',
|
||||
key: null,
|
||||
active: true,
|
||||
currencies: [],
|
||||
}
|
||||
currencyConverter.value = { type: '', url: '' }
|
||||
currenciesAlreadyInUsed.value = []
|
||||
isEdit.value = false
|
||||
}
|
||||
|
||||
async function fetchInitialData(): Promise<void> {
|
||||
isFetchingInitialData.value = true
|
||||
|
||||
const driversRes = await exchangeRateService.getDrivers()
|
||||
if (driversRes.exchange_rate_drivers) {
|
||||
drivers.value = (driversRes.exchange_rate_drivers as unknown as DriverOption[])
|
||||
}
|
||||
|
||||
if (modalStore.data && typeof modalStore.data === 'number') {
|
||||
isEdit.value = true
|
||||
const response = await exchangeRateService.getProvider(modalStore.data)
|
||||
if (response.data) {
|
||||
const provider = response.data
|
||||
currentExchangeRate.value = {
|
||||
id: provider.id,
|
||||
driver: provider.driver,
|
||||
key: provider.key,
|
||||
active: provider.active,
|
||||
currencies: provider.currencies ?? [],
|
||||
}
|
||||
}
|
||||
} else {
|
||||
currentExchangeRate.value.driver = 'currency_converter'
|
||||
}
|
||||
|
||||
isFetchingInitialData.value = false
|
||||
}
|
||||
|
||||
async function fetchServers(): Promise<void> {
|
||||
const res = await exchangeRateService.getCurrencyConverterServers()
|
||||
serverOptions.value = (res as Record<string, ServerOption[]>).currency_converter_servers ?? []
|
||||
currencyConverter.value.type = 'FREE'
|
||||
}
|
||||
|
||||
async function fetchCurrencies(): Promise<void> {
|
||||
const { driver, key } = currentExchangeRate.value
|
||||
if (!driver || !key) return
|
||||
|
||||
if (isCurrencyConverter.value && !currencyConverter.value.type) return
|
||||
|
||||
isFetchingCurrencies.value = true
|
||||
try {
|
||||
const driverConfig: Record<string, string> = {}
|
||||
if (currencyConverter.value.type) {
|
||||
driverConfig.type = currencyConverter.value.type
|
||||
}
|
||||
if (currencyConverter.value.url) {
|
||||
driverConfig.url = currencyConverter.value.url
|
||||
}
|
||||
const res = await exchangeRateService.getSupportedCurrencies({
|
||||
driver,
|
||||
key,
|
||||
driver_config: Object.keys(driverConfig).length ? driverConfig : undefined,
|
||||
})
|
||||
supportedCurrencies.value = res.supportedCurrencies ?? []
|
||||
} finally {
|
||||
isFetchingCurrencies.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitExchangeRate(): Promise<void> {
|
||||
v$.value.$touch()
|
||||
if (v$.value.$invalid) return
|
||||
|
||||
const data: Record<string, unknown> = {
|
||||
...currentExchangeRate.value,
|
||||
}
|
||||
|
||||
if (isCurrencyConverter.value) {
|
||||
data.driver_config = { ...currencyConverter.value }
|
||||
if (!isDedicatedServer.value) {
|
||||
(data.driver_config as CurrencyConverterForm).url = ''
|
||||
}
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
if (isEdit.value && currentExchangeRate.value.id) {
|
||||
await exchangeRateService.updateProvider(
|
||||
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) {
|
||||
modalStore.refreshData()
|
||||
}
|
||||
closeExchangeRateModal()
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeExchangeRateModal(): void {
|
||||
modalStore.closeModal()
|
||||
setTimeout(() => {
|
||||
resetModalData()
|
||||
v$.value.$reset()
|
||||
}, 300)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal
|
||||
:show="modalActive"
|
||||
@close="closeExchangeRateModal"
|
||||
@open="fetchInitialData"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex justify-between w-full">
|
||||
{{ modalStore.title }}
|
||||
<BaseIcon
|
||||
name="XMarkIcon"
|
||||
class="w-6 h-6 text-muted cursor-pointer"
|
||||
@click="closeExchangeRateModal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<form @submit.prevent="submitExchangeRate">
|
||||
<div class="px-4 md:px-8 py-8 overflow-y-auto sm:p-6">
|
||||
<BaseInputGrid layout="one-column">
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.exchange_rate.driver')"
|
||||
:content-loading="isFetchingInitialData"
|
||||
required
|
||||
:error="v$.driver.$error && v$.driver.$errors[0].$message"
|
||||
:help-text="driverSite"
|
||||
>
|
||||
<BaseMultiselect
|
||||
v-model="currentExchangeRate.driver"
|
||||
:options="driversLists"
|
||||
:content-loading="isFetchingInitialData"
|
||||
value-prop="value"
|
||||
:can-deselect="true"
|
||||
label="key"
|
||||
:searchable="true"
|
||||
:invalid="v$.driver.$error"
|
||||
track-by="key"
|
||||
@update:model-value="resetCurrency"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
v-if="isCurrencyConverter"
|
||||
required
|
||||
:label="$t('settings.exchange_rate.server')"
|
||||
:content-loading="isFetchingInitialData"
|
||||
>
|
||||
<BaseMultiselect
|
||||
v-model="currencyConverter.type"
|
||||
:content-loading="isFetchingInitialData"
|
||||
value-prop="value"
|
||||
searchable
|
||||
:options="serverOptions"
|
||||
label="value"
|
||||
track-by="value"
|
||||
@update:model-value="resetCurrency"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.exchange_rate.key')"
|
||||
required
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="v$.key.$error && v$.key.$errors[0].$message"
|
||||
>
|
||||
<BaseInput
|
||||
v-model="currentExchangeRate.key"
|
||||
:content-loading="isFetchingInitialData"
|
||||
type="text"
|
||||
name="key"
|
||||
:loading="isFetchingCurrencies"
|
||||
loading-position="right"
|
||||
:invalid="v$.key.$error"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
v-if="supportedCurrencies.length"
|
||||
:label="$t('settings.exchange_rate.currency')"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="
|
||||
v$.currencies.$error && v$.currencies.$errors[0].$message
|
||||
"
|
||||
:help-text="$t('settings.exchange_rate.currency_help_text')"
|
||||
>
|
||||
<BaseMultiselect
|
||||
v-model="currentExchangeRate.currencies"
|
||||
:content-loading="isFetchingInitialData"
|
||||
value-prop="code"
|
||||
mode="tags"
|
||||
searchable
|
||||
:options="supportedCurrencies"
|
||||
:invalid="v$.currencies.$error"
|
||||
label="code"
|
||||
track-by="code"
|
||||
open-direction="top"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
v-if="isDedicatedServer"
|
||||
:label="$t('settings.exchange_rate.url')"
|
||||
:content-loading="isFetchingInitialData"
|
||||
>
|
||||
<BaseInput
|
||||
v-model="currencyConverter.url"
|
||||
:content-loading="isFetchingInitialData"
|
||||
type="url"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseSwitch
|
||||
v-model="currentExchangeRate.active"
|
||||
class="flex"
|
||||
:label-right="$t('settings.exchange_rate.active')"
|
||||
/>
|
||||
</BaseInputGrid>
|
||||
|
||||
<BaseInfoAlert
|
||||
v-if="
|
||||
currenciesAlreadyInUsed.length && currentExchangeRate.active
|
||||
"
|
||||
class="mt-5"
|
||||
:title="$t('settings.exchange_rate.currency_in_used')"
|
||||
:lists="[currenciesAlreadyInUsed.toString()]"
|
||||
:actions="['Remove']"
|
||||
@hide="dismiss"
|
||||
@Remove="removeUsedSelectedCurrencies"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="z-0 flex justify-end p-4 border-t border-line-default border-solid"
|
||||
>
|
||||
<BaseButton
|
||||
class="mr-3"
|
||||
variant="primary-outline"
|
||||
type="button"
|
||||
:disabled="isSaving"
|
||||
@click="closeExchangeRateModal"
|
||||
>
|
||||
{{ $t('general.cancel') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
:loading="isSaving"
|
||||
:disabled="isSaving || isFetchingCurrencies"
|
||||
variant="primary"
|
||||
type="submit"
|
||||
>
|
||||
<template #left="slotProps">
|
||||
<BaseIcon
|
||||
v-if="!isSaving"
|
||||
name="ArrowDownOnSquareIcon"
|
||||
:class="slotProps.class"
|
||||
/>
|
||||
</template>
|
||||
{{ isEdit ? $t('general.update') : $t('general.save') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</form>
|
||||
</BaseModal>
|
||||
</template>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useDialogStore } from '@/scripts/stores/dialog.store'
|
||||
import { useUserStore } from '@/scripts/stores/user.store'
|
||||
import { useModalStore } from '@/scripts/stores/modal.store'
|
||||
import { useNotificationStore } from '@/scripts/stores/notification.store'
|
||||
import { expenseService } from '@/scripts/api/services/expense.service'
|
||||
|
||||
const ABILITIES = {
|
||||
EDIT_EXPENSE: 'edit-expense',
|
||||
DELETE_EXPENSE: 'delete-expense',
|
||||
} as const
|
||||
|
||||
interface ExpenseCategoryRow {
|
||||
id: number
|
||||
name: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
row: ExpenseCategoryRow
|
||||
table?: { refresh: () => void } | null
|
||||
loadData?: (() => void) | null
|
||||
}>()
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
const modalStore = useModalStore()
|
||||
|
||||
function editExpenseCategory(id: number): void {
|
||||
modalStore.openModal({
|
||||
title: t('settings.expense_category.edit_category'),
|
||||
componentName: 'CategoryModal',
|
||||
data: id,
|
||||
refreshData: props.loadData ?? undefined,
|
||||
size: 'sm',
|
||||
})
|
||||
}
|
||||
|
||||
function removeExpenseCategory(id: number): void {
|
||||
dialogStore
|
||||
.openDialog({
|
||||
title: t('general.are_you_sure'),
|
||||
message: t('settings.expense_category.confirm_delete'),
|
||||
yesLabel: t('general.ok'),
|
||||
noLabel: t('general.cancel'),
|
||||
variant: 'danger',
|
||||
hideNoButton: false,
|
||||
size: 'lg',
|
||||
})
|
||||
.then(async (res: boolean) => {
|
||||
if (res) {
|
||||
const response = await expenseService.deleteCategory(id)
|
||||
if (response.success) {
|
||||
notificationStore.showNotification({
|
||||
type: 'success',
|
||||
message: 'settings.expense_category.deleted_message',
|
||||
})
|
||||
props.loadData?.()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseDropdown>
|
||||
<template #activator>
|
||||
<BaseButton
|
||||
v-if="route.name === 'settings.expense-categories'"
|
||||
variant="primary"
|
||||
>
|
||||
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-white" />
|
||||
</BaseButton>
|
||||
<BaseIcon v-else name="EllipsisHorizontalIcon" class="h-5 text-muted" />
|
||||
</template>
|
||||
|
||||
<BaseDropdownItem
|
||||
v-if="userStore.hasAbilities(ABILITIES.EDIT_EXPENSE)"
|
||||
@click="editExpenseCategory(row.id)"
|
||||
>
|
||||
<BaseIcon
|
||||
name="PencilIcon"
|
||||
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
|
||||
/>
|
||||
{{ $t('general.edit') }}
|
||||
</BaseDropdownItem>
|
||||
|
||||
<BaseDropdownItem
|
||||
v-if="userStore.hasAbilities(ABILITIES.DELETE_EXPENSE)"
|
||||
@click="removeExpenseCategory(row.id)"
|
||||
>
|
||||
<BaseIcon
|
||||
name="TrashIcon"
|
||||
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
|
||||
/>
|
||||
{{ $t('general.delete') }}
|
||||
</BaseDropdownItem>
|
||||
</BaseDropdown>
|
||||
</template>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, inject } from 'vue'
|
||||
import { useCompanyStore } from '@/scripts/stores/company.store'
|
||||
import { useInvoiceStore } from '@/scripts/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
|
||||
}
|
||||
|
||||
const utils = inject<Utils>('utils')!
|
||||
const companyStore = useCompanyStore()
|
||||
const invoiceStore = useInvoiceStore()
|
||||
|
||||
const invoiceSettings = reactive<{ invoice_email_attachment: string | null }>({
|
||||
invoice_email_attachment: null,
|
||||
})
|
||||
|
||||
utils.mergeSettings(
|
||||
invoiceSettings as unknown as Record<string, unknown>,
|
||||
{ ...companyStore.selectedCompanySettings }
|
||||
)
|
||||
|
||||
const sendAsAttachmentField = computed<boolean>({
|
||||
get: () => invoiceSettings.invoice_email_attachment === 'YES',
|
||||
set: async (newValue: boolean) => {
|
||||
const value = newValue ? 'YES' : 'NO'
|
||||
|
||||
const data = {
|
||||
settings: {
|
||||
invoice_email_attachment: value,
|
||||
},
|
||||
}
|
||||
|
||||
invoiceSettings.invoice_email_attachment = value
|
||||
|
||||
await companyStore.updateCompanySettings({
|
||||
data,
|
||||
message: 'general.setting_updated',
|
||||
})
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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" />
|
||||
|
||||
<ul class="divide-y divide-line-default">
|
||||
<BaseSwitchSection
|
||||
v-model="sendAsAttachmentField"
|
||||
:title="$t('settings.customization.invoices.invoice_email_attachment')"
|
||||
:description="
|
||||
$t(
|
||||
'settings.customization.invoices.invoice_email_attachment_setting_description'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</ul>
|
||||
</template>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useCompanyStore } from '@/scripts/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 '@/scripts/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 '@/scripts/stores/company.store'
|
||||
import { useGlobalStore } from '@/scripts/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>
|
||||
@@ -0,0 +1,166 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { required, minLength, helpers } from '@vuelidate/validators'
|
||||
import useVuelidate from '@vuelidate/core'
|
||||
import { useModalStore } from '@/scripts/stores/modal.store'
|
||||
import { useItemStore } from '@/scripts/features/company/items/store'
|
||||
|
||||
interface ItemUnitForm {
|
||||
id: number | null
|
||||
name: string
|
||||
}
|
||||
|
||||
const modalStore = useModalStore()
|
||||
const itemStore = useItemStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isSaving = ref<boolean>(false)
|
||||
const isEdit = ref<boolean>(false)
|
||||
|
||||
const currentItemUnit = ref<ItemUnitForm>({
|
||||
id: null,
|
||||
name: '',
|
||||
})
|
||||
|
||||
const modalActive = computed<boolean>(
|
||||
() => modalStore.active && modalStore.componentName === 'ItemUnitModal'
|
||||
)
|
||||
|
||||
const rules = computed(() => ({
|
||||
name: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
minLength: helpers.withMessage(
|
||||
t('validation.name_min_length', { count: 2 }),
|
||||
minLength(2)
|
||||
),
|
||||
},
|
||||
}))
|
||||
|
||||
const v$ = useVuelidate(rules, currentItemUnit)
|
||||
|
||||
async function setInitialData(): Promise<void> {
|
||||
if (modalStore.data && typeof modalStore.data === 'number') {
|
||||
isEdit.value = true
|
||||
await itemStore.fetchItemUnit(modalStore.data)
|
||||
currentItemUnit.value = {
|
||||
id: itemStore.currentItemUnit.id ?? null,
|
||||
name: itemStore.currentItemUnit.name,
|
||||
}
|
||||
} else {
|
||||
isEdit.value = false
|
||||
resetForm()
|
||||
}
|
||||
}
|
||||
|
||||
async function submitItemUnit(): Promise<void> {
|
||||
v$.value.$touch()
|
||||
|
||||
if (v$.value.$invalid) {
|
||||
return
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
let res
|
||||
if (isEdit.value && currentItemUnit.value.id) {
|
||||
res = await itemStore.updateItemUnit({
|
||||
id: currentItemUnit.value.id,
|
||||
name: currentItemUnit.value.name,
|
||||
})
|
||||
} else {
|
||||
res = await itemStore.addItemUnit({
|
||||
name: currentItemUnit.value.name,
|
||||
})
|
||||
}
|
||||
|
||||
if (modalStore.refreshData && res?.data) {
|
||||
modalStore.refreshData(res.data)
|
||||
}
|
||||
closeItemUnitModal()
|
||||
} catch {
|
||||
// handled
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm(): void {
|
||||
currentItemUnit.value = { id: null, name: '' }
|
||||
}
|
||||
|
||||
function closeItemUnitModal(): void {
|
||||
modalStore.closeModal()
|
||||
setTimeout(() => {
|
||||
resetForm()
|
||||
isEdit.value = false
|
||||
v$.value.$reset()
|
||||
}, 300)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal
|
||||
:show="modalActive"
|
||||
@close="closeItemUnitModal"
|
||||
@open="setInitialData"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex justify-between w-full">
|
||||
{{ modalStore.title }}
|
||||
<BaseIcon
|
||||
name="XMarkIcon"
|
||||
class="w-6 h-6 text-muted cursor-pointer"
|
||||
@click="closeItemUnitModal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<form action="" @submit.prevent="submitItemUnit">
|
||||
<div class="p-8 sm:p-6">
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.customization.items.unit_name')"
|
||||
:error="v$.name.$error && v$.name.$errors[0].$message"
|
||||
variant="horizontal"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model="currentItemUnit.name"
|
||||
:invalid="v$.name.$error"
|
||||
type="text"
|
||||
@input="v$.name.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="z-0 flex justify-end p-4 border-t border-line-default border-solid"
|
||||
>
|
||||
<BaseButton
|
||||
type="button"
|
||||
variant="primary-outline"
|
||||
class="mr-3 text-sm"
|
||||
@click="closeItemUnitModal"
|
||||
>
|
||||
{{ $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>
|
||||
{{ isEdit ? $t('general.update') : $t('general.save') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</form>
|
||||
</BaseModal>
|
||||
</template>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useModalStore } from '@/scripts/stores/modal.store'
|
||||
import { useDialogStore } from '@/scripts/stores/dialog.store'
|
||||
import { itemService } from '@/scripts/api/services/item.service'
|
||||
import ItemUnitModal from './ItemUnitModal.vue'
|
||||
|
||||
interface TableColumn {
|
||||
key: string
|
||||
label?: string
|
||||
thClass?: string
|
||||
tdClass?: string
|
||||
sortable?: boolean
|
||||
}
|
||||
|
||||
interface FetchParams {
|
||||
page: number
|
||||
filter: Record<string, unknown>
|
||||
sort: { fieldName: string; order: string }
|
||||
}
|
||||
|
||||
interface FetchResult {
|
||||
data: unknown[]
|
||||
pagination: {
|
||||
totalPages: number
|
||||
currentPage: number
|
||||
totalCount: number
|
||||
limit: number
|
||||
}
|
||||
}
|
||||
|
||||
interface RowData {
|
||||
data: {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const table = ref<{ refresh: () => void } | null>(null)
|
||||
|
||||
const modalStore = useModalStore()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
const columns = computed<TableColumn[]>(() => [
|
||||
{
|
||||
key: 'name',
|
||||
label: t('settings.customization.items.unit_name'),
|
||||
thClass: 'extra',
|
||||
tdClass: 'font-medium text-heading',
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: '',
|
||||
tdClass: 'text-right text-sm font-medium',
|
||||
sortable: false,
|
||||
},
|
||||
])
|
||||
|
||||
async function fetchData({ page, sort }: FetchParams): Promise<FetchResult> {
|
||||
const data = {
|
||||
orderByField: sort.fieldName || 'created_at',
|
||||
orderBy: sort.order || 'desc',
|
||||
page,
|
||||
}
|
||||
|
||||
const response = await itemService.listUnits(data)
|
||||
|
||||
return {
|
||||
data: (response as Record<string, unknown>).data as unknown[],
|
||||
pagination: {
|
||||
totalPages: ((response as Record<string, unknown>).meta as Record<string, number>).last_page,
|
||||
currentPage: page,
|
||||
totalCount: ((response as Record<string, unknown>).meta as Record<string, number>).total,
|
||||
limit: 5,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function addItemUnit(): void {
|
||||
modalStore.openModal({
|
||||
title: t('settings.customization.items.add_item_unit'),
|
||||
componentName: 'ItemUnitModal',
|
||||
refreshData: table.value?.refresh,
|
||||
size: 'sm',
|
||||
})
|
||||
}
|
||||
|
||||
function editItemUnit(row: RowData): void {
|
||||
modalStore.openModal({
|
||||
title: t('settings.customization.items.edit_item_unit'),
|
||||
componentName: 'ItemUnitModal',
|
||||
data: row.data.id,
|
||||
refreshData: table.value?.refresh,
|
||||
})
|
||||
}
|
||||
|
||||
function removeItemUnit(row: RowData): void {
|
||||
dialogStore
|
||||
.openDialog({
|
||||
title: t('general.are_you_sure'),
|
||||
message: t('settings.customization.items.item_unit_confirm_delete'),
|
||||
yesLabel: t('general.yes'),
|
||||
noLabel: t('general.no'),
|
||||
variant: 'danger',
|
||||
hideNoButton: false,
|
||||
size: 'lg',
|
||||
})
|
||||
.then(async (res: boolean) => {
|
||||
if (res) {
|
||||
await itemService.deleteUnit(row.data.id)
|
||||
table.value?.refresh()
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ItemUnitModal />
|
||||
|
||||
<div class="flex flex-wrap justify-end mt-2 lg:flex-nowrap">
|
||||
<BaseButton variant="primary-outline" @click="addItemUnit">
|
||||
<template #left="slotProps">
|
||||
<BaseIcon :class="slotProps.class" name="PlusIcon" />
|
||||
</template>
|
||||
{{ $t('settings.customization.items.add_item_unit') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<BaseTable ref="table" class="mt-10" :data="fetchData" :columns="columns">
|
||||
<template #cell-actions="{ row }">
|
||||
<BaseDropdown>
|
||||
<template #activator>
|
||||
<div class="inline-block">
|
||||
<BaseIcon name="EllipsisHorizontalIcon" class="text-muted" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<BaseDropdownItem @click="editItemUnit(row)">
|
||||
<BaseIcon
|
||||
name="PencilIcon"
|
||||
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
|
||||
/>
|
||||
{{ $t('general.edit') }}
|
||||
</BaseDropdownItem>
|
||||
<BaseDropdownItem @click="removeItemUnit(row)">
|
||||
<BaseIcon
|
||||
name="TrashIcon"
|
||||
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
|
||||
/>
|
||||
{{ $t('general.delete') }}
|
||||
</BaseDropdownItem>
|
||||
</BaseDropdown>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</template>
|
||||
@@ -0,0 +1,193 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { required, email, maxLength, helpers } from '@vuelidate/validators'
|
||||
import useVuelidate from '@vuelidate/core'
|
||||
import { useModalStore } from '@/scripts/stores/modal.store'
|
||||
import { mailService } from '@/scripts/api/services/mail.service'
|
||||
import { companyService } from '@/scripts/api/services/company.service'
|
||||
|
||||
interface MailTestForm {
|
||||
to: string
|
||||
subject: string
|
||||
message: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
storeType?: 'company' | 'global'
|
||||
}>(),
|
||||
{
|
||||
storeType: 'global',
|
||||
}
|
||||
)
|
||||
|
||||
const modalStore = useModalStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isSaving = ref<boolean>(false)
|
||||
const formData = reactive<MailTestForm>({
|
||||
to: '',
|
||||
subject: '',
|
||||
message: '',
|
||||
})
|
||||
|
||||
const modalActive = computed<boolean>(
|
||||
() => modalStore.active && modalStore.componentName === 'MailTestModal'
|
||||
)
|
||||
|
||||
const rules = {
|
||||
to: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
email: helpers.withMessage(t('validation.email_incorrect'), email),
|
||||
},
|
||||
subject: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
maxLength: helpers.withMessage(
|
||||
t('validation.subject_maxlength'),
|
||||
maxLength(100)
|
||||
),
|
||||
},
|
||||
message: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
maxLength: helpers.withMessage(
|
||||
t('validation.message_maxlength'),
|
||||
maxLength(255)
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
const v$ = useVuelidate({ formData: rules }, { formData })
|
||||
|
||||
function resetFormData(): void {
|
||||
formData.to = ''
|
||||
formData.subject = ''
|
||||
formData.message = ''
|
||||
v$.value.$reset()
|
||||
}
|
||||
|
||||
async function onTestMailSend(): Promise<void> {
|
||||
v$.value.formData.$touch()
|
||||
if (v$.value.$invalid) {
|
||||
return
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
function closeTestModal(): void {
|
||||
modalStore.closeModal()
|
||||
setTimeout(() => {
|
||||
resetFormData()
|
||||
}, 300)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal :show="modalActive" @close="closeTestModal">
|
||||
<template #header>
|
||||
<div class="flex justify-between w-full">
|
||||
{{ modalStore.title }}
|
||||
<BaseIcon
|
||||
name="XMarkIcon"
|
||||
class="w-6 h-6 text-muted cursor-pointer"
|
||||
@click="closeTestModal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<form action="" @submit.prevent="onTestMailSend">
|
||||
<div class="p-4 md:p-8">
|
||||
<BaseInputGrid layout="one-column">
|
||||
<BaseInputGroup
|
||||
:label="$t('general.to')"
|
||||
:error="
|
||||
v$.formData.to.$error && v$.formData.to.$errors[0].$message
|
||||
"
|
||||
variant="horizontal"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model="formData.to"
|
||||
type="text"
|
||||
:invalid="v$.formData.to.$error"
|
||||
@input="v$.formData.to.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
<BaseInputGroup
|
||||
:label="$t('general.subject')"
|
||||
:error="
|
||||
v$.formData.subject.$error &&
|
||||
v$.formData.subject.$errors[0].$message
|
||||
"
|
||||
variant="horizontal"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model="formData.subject"
|
||||
type="text"
|
||||
:invalid="v$.formData.subject.$error"
|
||||
@input="v$.formData.subject.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
<BaseInputGroup
|
||||
:label="$t('general.message')"
|
||||
:error="
|
||||
v$.formData.message.$error &&
|
||||
v$.formData.message.$errors[0].$message
|
||||
"
|
||||
variant="horizontal"
|
||||
required
|
||||
>
|
||||
<BaseTextarea
|
||||
v-model="formData.message"
|
||||
rows="4"
|
||||
cols="50"
|
||||
:invalid="v$.formData.message.$error"
|
||||
@input="v$.formData.message.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
</BaseInputGrid>
|
||||
</div>
|
||||
<div
|
||||
class="z-0 flex justify-end p-4 border-t border-line-default border-solid"
|
||||
>
|
||||
<BaseButton
|
||||
variant="primary-outline"
|
||||
type="button"
|
||||
class="mr-3"
|
||||
@click="closeTestModal()"
|
||||
>
|
||||
{{ $t('general.cancel') }}
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton :loading="isSaving" variant="primary" type="submit">
|
||||
<template #left="slotProps">
|
||||
<BaseIcon
|
||||
v-if="!isSaving"
|
||||
name="PaperAirplaneIcon"
|
||||
:class="slotProps.class"
|
||||
/>
|
||||
</template>
|
||||
{{ $t('general.send') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</form>
|
||||
</BaseModal>
|
||||
</template>
|
||||
@@ -0,0 +1,241 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, computed, reactive } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { required, email, helpers } from '@vuelidate/validators'
|
||||
import useVuelidate from '@vuelidate/core'
|
||||
|
||||
interface MailgunConfig {
|
||||
mail_driver: string
|
||||
mail_mailgun_domain: string
|
||||
mail_mailgun_secret: string
|
||||
mail_mailgun_endpoint: string
|
||||
from_mail: string
|
||||
from_name: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
configData?: Record<string, unknown>
|
||||
isSaving?: boolean
|
||||
isFetchingInitialData?: boolean
|
||||
mailDrivers?: string[]
|
||||
}>(),
|
||||
{
|
||||
configData: () => ({}),
|
||||
isSaving: false,
|
||||
isFetchingInitialData: false,
|
||||
mailDrivers: () => [],
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'submit-data': [config: MailgunConfig]
|
||||
'on-change-driver': [driver: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const isShowPassword = ref<boolean>(false)
|
||||
|
||||
const mailgunConfig = reactive<MailgunConfig>({
|
||||
mail_driver: 'mailgun',
|
||||
mail_mailgun_domain: '',
|
||||
mail_mailgun_secret: '',
|
||||
mail_mailgun_endpoint: '',
|
||||
from_mail: '',
|
||||
from_name: '',
|
||||
})
|
||||
|
||||
const getInputType = computed<string>(() =>
|
||||
isShowPassword.value ? 'text' : 'password'
|
||||
)
|
||||
|
||||
const rules = computed(() => ({
|
||||
mail_driver: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
mail_mailgun_domain: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
mail_mailgun_endpoint: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
mail_mailgun_secret: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
from_mail: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
email: helpers.withMessage(t('validation.email_incorrect'), email),
|
||||
},
|
||||
from_name: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
}))
|
||||
|
||||
const v$ = useVuelidate(rules, mailgunConfig)
|
||||
|
||||
onMounted(() => {
|
||||
for (const key in mailgunConfig) {
|
||||
if (Object.prototype.hasOwnProperty.call(props.configData, key)) {
|
||||
;(mailgunConfig as Record<string, unknown>)[key] = props.configData[key]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function saveEmailConfig(): Promise<void> {
|
||||
v$.value.$touch()
|
||||
if (!v$.value.$invalid) {
|
||||
emit('submit-data', { ...mailgunConfig })
|
||||
}
|
||||
}
|
||||
|
||||
function onChangeDriver(): void {
|
||||
v$.value.mail_driver.$touch()
|
||||
emit('on-change-driver', mailgunConfig.mail_driver)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="saveEmailConfig">
|
||||
<BaseInputGrid>
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.mail.driver')"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="
|
||||
v$.mail_driver.$error && v$.mail_driver.$errors[0].$message
|
||||
"
|
||||
required
|
||||
>
|
||||
<BaseMultiselect
|
||||
v-model="mailgunConfig.mail_driver"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:options="mailDrivers"
|
||||
:can-deselect="false"
|
||||
:invalid="v$.mail_driver.$error"
|
||||
@update:model-value="onChangeDriver"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.mail.mailgun_domain')"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="
|
||||
v$.mail_mailgun_domain.$error &&
|
||||
v$.mail_mailgun_domain.$errors[0].$message
|
||||
"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="mailgunConfig.mail_mailgun_domain"
|
||||
:content-loading="isFetchingInitialData"
|
||||
type="text"
|
||||
name="mailgun_domain"
|
||||
:invalid="v$.mail_mailgun_domain.$error"
|
||||
@input="v$.mail_mailgun_domain.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.mail.mailgun_secret')"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="
|
||||
v$.mail_mailgun_secret.$error &&
|
||||
v$.mail_mailgun_secret.$errors[0].$message
|
||||
"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="mailgunConfig.mail_mailgun_secret"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:type="getInputType"
|
||||
name="mailgun_secret"
|
||||
autocomplete="off"
|
||||
:invalid="v$.mail_mailgun_secret.$error"
|
||||
@input="v$.mail_mailgun_secret.$touch()"
|
||||
>
|
||||
<template #right>
|
||||
<BaseIcon
|
||||
:name="isShowPassword ? 'EyeIcon' : 'EyeSlashIcon'"
|
||||
class="mr-1 text-muted cursor-pointer"
|
||||
@click="isShowPassword = !isShowPassword"
|
||||
/>
|
||||
</template>
|
||||
</BaseInput>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.mail.mailgun_endpoint')"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="
|
||||
v$.mail_mailgun_endpoint.$error &&
|
||||
v$.mail_mailgun_endpoint.$errors[0].$message
|
||||
"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="mailgunConfig.mail_mailgun_endpoint"
|
||||
:content-loading="isFetchingInitialData"
|
||||
type="text"
|
||||
name="mailgun_endpoint"
|
||||
:invalid="v$.mail_mailgun_endpoint.$error"
|
||||
@input="v$.mail_mailgun_endpoint.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.mail.from_mail')"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="
|
||||
v$.from_mail.$error && v$.from_mail.$errors[0].$message
|
||||
"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="mailgunConfig.from_mail"
|
||||
:content-loading="isFetchingInitialData"
|
||||
type="text"
|
||||
name="from_mail"
|
||||
:invalid="v$.from_mail.$error"
|
||||
@input="v$.from_mail.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.mail.from_name')"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="
|
||||
v$.from_name.$error && v$.from_name.$errors[0].$message
|
||||
"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="mailgunConfig.from_name"
|
||||
:content-loading="isFetchingInitialData"
|
||||
type="text"
|
||||
name="from_name"
|
||||
:invalid="v$.from_name.$error"
|
||||
@input="v$.from_name.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
</BaseInputGrid>
|
||||
<div class="flex my-10">
|
||||
<BaseButton
|
||||
:disabled="isSaving"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:loading="isSaving"
|
||||
variant="primary"
|
||||
type="submit"
|
||||
>
|
||||
<template #left="slotProps">
|
||||
<BaseIcon
|
||||
v-if="!isSaving"
|
||||
name="ArrowDownOnSquareIcon"
|
||||
:class="slotProps.class"
|
||||
/>
|
||||
</template>
|
||||
{{ $t('general.save') }}
|
||||
</BaseButton>
|
||||
<slot />
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useDialogStore } from '@/scripts/stores/dialog.store'
|
||||
import { useNotificationStore } from '@/scripts/stores/notification.store'
|
||||
import { useUserStore } from '@/scripts/stores/user.store'
|
||||
import { useModalStore } from '@/scripts/stores/modal.store'
|
||||
import { noteService } from '@/scripts/api/services/note.service'
|
||||
import { ABILITIES } from '@/scripts/config/abilities'
|
||||
|
||||
interface NoteRow {
|
||||
id: number
|
||||
name: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
row: NoteRow
|
||||
table?: { refresh: () => void } | null
|
||||
loadData?: (() => void) | null
|
||||
}>()
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
const modalStore = useModalStore()
|
||||
|
||||
function editNote(id: number): void {
|
||||
modalStore.openModal({
|
||||
title: t('settings.customization.notes.edit_note'),
|
||||
componentName: 'NoteModal',
|
||||
size: 'md',
|
||||
data: id,
|
||||
refreshData: props.loadData ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function removeNote(id: number): void {
|
||||
dialogStore
|
||||
.openDialog({
|
||||
title: t('general.are_you_sure'),
|
||||
message: t('settings.customization.notes.note_confirm_delete'),
|
||||
yesLabel: t('general.yes'),
|
||||
noLabel: t('general.no'),
|
||||
variant: 'danger',
|
||||
hideNoButton: false,
|
||||
size: 'lg',
|
||||
})
|
||||
.then(async (confirmed: boolean) => {
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
|
||||
const response = await noteService.delete(id)
|
||||
if (response.success) {
|
||||
notificationStore.showNotification({
|
||||
type: 'success',
|
||||
message: t('settings.customization.notes.deleted_message'),
|
||||
})
|
||||
} else {
|
||||
notificationStore.showNotification({
|
||||
type: 'error',
|
||||
message: t('settings.customization.notes.already_in_use'),
|
||||
})
|
||||
}
|
||||
props.loadData?.()
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseDropdown>
|
||||
<template #activator>
|
||||
<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" />
|
||||
</template>
|
||||
|
||||
<BaseDropdownItem
|
||||
v-if="userStore.hasAbilities(ABILITIES.MANAGE_NOTE)"
|
||||
@click="editNote(row.id)"
|
||||
>
|
||||
<BaseIcon
|
||||
name="PencilIcon"
|
||||
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
|
||||
/>
|
||||
{{ $t('general.edit') }}
|
||||
</BaseDropdownItem>
|
||||
|
||||
<BaseDropdownItem
|
||||
v-if="userStore.hasAbilities(ABILITIES.MANAGE_NOTE)"
|
||||
@click="removeNote(row.id)"
|
||||
>
|
||||
<BaseIcon
|
||||
name="TrashIcon"
|
||||
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
|
||||
/>
|
||||
{{ $t('general.delete') }}
|
||||
</BaseDropdownItem>
|
||||
</BaseDropdown>
|
||||
</template>
|
||||
@@ -0,0 +1,287 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { required, minLength, helpers } from '@vuelidate/validators'
|
||||
import useVuelidate from '@vuelidate/core'
|
||||
import { useModalStore } from '@/scripts/stores/modal.store'
|
||||
import { useNotificationStore } from '@/scripts/stores/notification.store'
|
||||
import { noteService } from '@/scripts/api/services/note.service'
|
||||
import type { CreateNotePayload } from '@/scripts/api/services/note.service'
|
||||
|
||||
interface NoteForm {
|
||||
id: number | null
|
||||
name: string
|
||||
type: string
|
||||
notes: string
|
||||
is_default: boolean
|
||||
}
|
||||
|
||||
const modalStore = useModalStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isSaving = ref<boolean>(false)
|
||||
const isEdit = ref<boolean>(false)
|
||||
|
||||
const currentNote = ref<NoteForm>({
|
||||
id: null,
|
||||
name: '',
|
||||
type: 'Invoice',
|
||||
notes: '',
|
||||
is_default: false,
|
||||
})
|
||||
|
||||
const types = reactive([
|
||||
{ label: t('settings.customization.notes.types.invoice'), value: 'Invoice' },
|
||||
{ label: t('settings.customization.notes.types.estimate'), value: 'Estimate' },
|
||||
{ label: t('settings.customization.notes.types.payment'), value: 'Payment' },
|
||||
])
|
||||
|
||||
const fields = ref<string[]>(['customer', 'customerCustom'])
|
||||
|
||||
const modalActive = computed<boolean>(
|
||||
() => modalStore.active && modalStore.componentName === 'NoteModal'
|
||||
)
|
||||
|
||||
const rules = computed(() => ({
|
||||
name: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
minLength: helpers.withMessage(
|
||||
t('validation.name_min_length', { count: 3 }),
|
||||
minLength(3)
|
||||
),
|
||||
},
|
||||
notes: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
type: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
}))
|
||||
|
||||
const v$ = useVuelidate(rules, currentNote)
|
||||
|
||||
watch(
|
||||
() => currentNote.value.type,
|
||||
() => {
|
||||
setFields()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (route.name === 'estimates.create') {
|
||||
currentNote.value.type = 'Estimate'
|
||||
} else if (
|
||||
route.name === 'invoices.create' ||
|
||||
route.name === 'recurring-invoices.create'
|
||||
) {
|
||||
currentNote.value.type = 'Invoice'
|
||||
} else {
|
||||
currentNote.value.type = 'Payment'
|
||||
}
|
||||
})
|
||||
|
||||
function setFields(): void {
|
||||
fields.value = ['customer', 'customerCustom']
|
||||
|
||||
if (currentNote.value.type === 'Invoice') {
|
||||
fields.value.push('invoice', 'invoiceCustom')
|
||||
}
|
||||
if (currentNote.value.type === 'Estimate') {
|
||||
fields.value.push('estimate', 'estimateCustom')
|
||||
}
|
||||
if (currentNote.value.type === 'Payment') {
|
||||
fields.value.push('payment', 'paymentCustom')
|
||||
}
|
||||
}
|
||||
|
||||
async function setInitialData(): Promise<void> {
|
||||
if (modalStore.data && typeof modalStore.data === 'number') {
|
||||
isEdit.value = true
|
||||
const response = await noteService.get(modalStore.data)
|
||||
if (response.data) {
|
||||
const note = response.data
|
||||
currentNote.value = {
|
||||
id: note.id,
|
||||
name: note.name,
|
||||
type: note.type,
|
||||
notes: note.notes,
|
||||
is_default: note.is_default ?? false,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
isEdit.value = false
|
||||
resetForm()
|
||||
}
|
||||
setFields()
|
||||
}
|
||||
|
||||
async function submitNote(): Promise<void> {
|
||||
v$.value.$touch()
|
||||
if (v$.value.$invalid) {
|
||||
return
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
|
||||
const payload: CreateNotePayload = {
|
||||
name: currentNote.value.name,
|
||||
type: currentNote.value.type,
|
||||
notes: currentNote.value.notes,
|
||||
is_default: currentNote.value.is_default,
|
||||
}
|
||||
|
||||
try {
|
||||
if (isEdit.value && currentNote.value.id) {
|
||||
const res = await noteService.update(currentNote.value.id, payload)
|
||||
if (res.data) {
|
||||
notificationStore.showNotification({
|
||||
type: 'success',
|
||||
message: t('settings.customization.notes.note_updated'),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const res = await noteService.create(payload)
|
||||
if (res) {
|
||||
notificationStore.showNotification({
|
||||
type: 'success',
|
||||
message: t('settings.customization.notes.note_added'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
isSaving.value = false
|
||||
if (modalStore.refreshData) {
|
||||
modalStore.refreshData()
|
||||
}
|
||||
closeNoteModal()
|
||||
} catch {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm(): void {
|
||||
currentNote.value = {
|
||||
id: null,
|
||||
name: '',
|
||||
type: 'Invoice',
|
||||
notes: '',
|
||||
is_default: false,
|
||||
}
|
||||
}
|
||||
|
||||
function closeNoteModal(): void {
|
||||
modalStore.closeModal()
|
||||
setTimeout(() => {
|
||||
resetForm()
|
||||
v$.value.$reset()
|
||||
}, 300)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal
|
||||
:show="modalActive"
|
||||
@close="closeNoteModal"
|
||||
@open="setInitialData"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex justify-between w-full">
|
||||
{{ modalStore.title }}
|
||||
<BaseIcon
|
||||
name="XMarkIcon"
|
||||
class="h-6 w-6 text-muted cursor-pointer"
|
||||
@click="closeNoteModal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<form action="" @submit.prevent="submitNote">
|
||||
<div class="px-8 py-8 sm:p-6">
|
||||
<BaseInputGrid layout="one-column">
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.customization.notes.name')"
|
||||
variant="vertical"
|
||||
:error="v$.name.$error && v$.name.$errors[0].$message"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model="currentNote.name"
|
||||
:invalid="v$.name.$error"
|
||||
type="text"
|
||||
@input="v$.name.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.customization.notes.type')"
|
||||
:error="v$.type.$error && v$.type.$errors[0].$message"
|
||||
required
|
||||
>
|
||||
<BaseMultiselect
|
||||
v-model="currentNote.type"
|
||||
:options="types"
|
||||
value-prop="value"
|
||||
class="mt-2"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseSwitchSection
|
||||
v-model="currentNote.is_default"
|
||||
:title="$t('settings.customization.notes.is_default')"
|
||||
:description="
|
||||
$t('settings.customization.notes.is_default_description')
|
||||
"
|
||||
/>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.customization.notes.notes')"
|
||||
:error="v$.notes.$error && v$.notes.$errors[0].$message"
|
||||
required
|
||||
>
|
||||
<BaseCustomInput
|
||||
v-model="currentNote.notes"
|
||||
:invalid="v$.notes.$error"
|
||||
:fields="fields"
|
||||
@input="v$.notes.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
</BaseInputGrid>
|
||||
</div>
|
||||
<div
|
||||
class="z-0 flex justify-end px-4 py-4 border-t border-solid border-line-default"
|
||||
>
|
||||
<BaseButton
|
||||
class="mr-2"
|
||||
variant="primary-outline"
|
||||
type="button"
|
||||
@click="closeNoteModal"
|
||||
>
|
||||
{{ $t('general.cancel') }}
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
:loading="isSaving"
|
||||
:disabled="isSaving"
|
||||
variant="primary"
|
||||
type="submit"
|
||||
>
|
||||
<template #left="slotProps">
|
||||
<BaseIcon name="ArrowDownOnSquareIcon" :class="slotProps.class" />
|
||||
</template>
|
||||
{{ isEdit ? $t('general.update') : $t('general.save') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</form>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.note-modal {
|
||||
.header-editior .editor-menu-bar {
|
||||
margin-left: 0.5px;
|
||||
margin-right: 0px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,398 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import draggable from 'vuedraggable'
|
||||
import Guid from 'guid'
|
||||
import { useCompanyStore } from '../../../../stores/company.store'
|
||||
import { useGlobalStore } from '../../../../stores/global.store'
|
||||
import DragIcon from '@/scripts/components/icons/DragIcon.vue'
|
||||
|
||||
interface NumberField {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
name: string
|
||||
paramLabel: string
|
||||
value: string
|
||||
inputDisabled: boolean
|
||||
inputType: string
|
||||
allowMultiple: boolean
|
||||
}
|
||||
|
||||
interface TypeStore {
|
||||
getNextNumber: (data: {
|
||||
key: string
|
||||
format: string
|
||||
}) => Promise<{ data?: { nextNumber: string } }>
|
||||
}
|
||||
|
||||
interface Props {
|
||||
type: string
|
||||
typeStore: TypeStore
|
||||
defaultSeries?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
defaultSeries: 'INV',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const companyStore = useCompanyStore()
|
||||
const globalStore = useGlobalStore()
|
||||
|
||||
const selectedFields = ref<NumberField[]>([])
|
||||
const isSaving = ref<boolean>(false)
|
||||
const nextNumber = ref<string>('')
|
||||
const isFetchingNextNumber = ref<boolean>(false)
|
||||
const isLoadingPlaceholders = ref<boolean>(false)
|
||||
|
||||
const allFields = ref<Omit<NumberField, 'id'>[]>([
|
||||
{
|
||||
label: t('settings.customization.series'),
|
||||
description: t('settings.customization.series_description'),
|
||||
name: 'SERIES',
|
||||
paramLabel: t('settings.customization.series_param_label'),
|
||||
value: props.defaultSeries,
|
||||
inputDisabled: false,
|
||||
inputType: 'text',
|
||||
allowMultiple: false,
|
||||
},
|
||||
{
|
||||
label: t('settings.customization.sequence'),
|
||||
description: t('settings.customization.sequence_description'),
|
||||
name: 'SEQUENCE',
|
||||
paramLabel: t('settings.customization.sequence_param_label'),
|
||||
value: '6',
|
||||
inputDisabled: false,
|
||||
inputType: 'number',
|
||||
allowMultiple: false,
|
||||
},
|
||||
{
|
||||
label: t('settings.customization.delimiter'),
|
||||
description: t('settings.customization.delimiter_description'),
|
||||
name: 'DELIMITER',
|
||||
paramLabel: t('settings.customization.delimiter_param_label'),
|
||||
value: '-',
|
||||
inputDisabled: false,
|
||||
inputType: 'text',
|
||||
allowMultiple: true,
|
||||
},
|
||||
{
|
||||
label: t('settings.customization.customer_series'),
|
||||
description: t('settings.customization.customer_series_description'),
|
||||
name: 'CUSTOMER_SERIES',
|
||||
paramLabel: '',
|
||||
value: '',
|
||||
inputDisabled: true,
|
||||
inputType: 'text',
|
||||
allowMultiple: false,
|
||||
},
|
||||
{
|
||||
label: t('settings.customization.customer_sequence'),
|
||||
description: t('settings.customization.customer_sequence_description'),
|
||||
name: 'CUSTOMER_SEQUENCE',
|
||||
paramLabel: t('settings.customization.customer_sequence_param_label'),
|
||||
value: '6',
|
||||
inputDisabled: false,
|
||||
inputType: 'number',
|
||||
allowMultiple: false,
|
||||
},
|
||||
{
|
||||
label: t('settings.customization.date_format'),
|
||||
description: t('settings.customization.date_format_description'),
|
||||
name: 'DATE_FORMAT',
|
||||
paramLabel: t('settings.customization.date_format_param_label'),
|
||||
value: 'Y',
|
||||
inputDisabled: false,
|
||||
inputType: 'text',
|
||||
allowMultiple: true,
|
||||
},
|
||||
{
|
||||
label: t('settings.customization.random_sequence'),
|
||||
description: t('settings.customization.random_sequence_description'),
|
||||
name: 'RANDOM_SEQUENCE',
|
||||
paramLabel: t('settings.customization.random_sequence_param_label'),
|
||||
value: '6',
|
||||
inputDisabled: false,
|
||||
inputType: 'number',
|
||||
allowMultiple: false,
|
||||
},
|
||||
])
|
||||
|
||||
const computedFields = computed<Omit<NumberField, 'id'>[]>(() => {
|
||||
return allFields.value.filter((obj) => {
|
||||
return !selectedFields.value.some((obj2) => {
|
||||
if (obj.allowMultiple) return false
|
||||
return obj.name === obj2.name
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const getNumberFormat = computed<string>(() => {
|
||||
let format = ''
|
||||
selectedFields.value.forEach((field) => {
|
||||
let fieldString = `{{${field.name}`
|
||||
if (field.value) {
|
||||
fieldString += `:${field.value}`
|
||||
}
|
||||
format += `${fieldString}}}`
|
||||
})
|
||||
return format
|
||||
})
|
||||
|
||||
watch(selectedFields, () => {
|
||||
fetchNextNumber()
|
||||
})
|
||||
|
||||
setInitialFields()
|
||||
|
||||
async function setInitialFields(): Promise<void> {
|
||||
const data = {
|
||||
format: companyStore.selectedCompanySettings[`${props.type}_number_format`],
|
||||
}
|
||||
|
||||
isLoadingPlaceholders.value = true
|
||||
|
||||
const res = await globalStore.fetchPlaceholders(data as { key: string })
|
||||
|
||||
res.placeholders.forEach((placeholder) => {
|
||||
const found = allFields.value.find((field) => field.name === placeholder.name)
|
||||
if (!found) return
|
||||
|
||||
selectedFields.value.push({
|
||||
...found,
|
||||
value: placeholder.value ?? found.value,
|
||||
id: Guid.raw(),
|
||||
})
|
||||
})
|
||||
|
||||
isLoadingPlaceholders.value = false
|
||||
fetchNextNumber()
|
||||
}
|
||||
|
||||
function isFieldAdded(field: Omit<NumberField, 'id'>): boolean {
|
||||
return !!selectedFields.value.find((v) => v.name === field.name)
|
||||
}
|
||||
|
||||
function onSelectField(field: Omit<NumberField, 'id'>): void {
|
||||
if (isFieldAdded(field) && !field.allowMultiple) return
|
||||
|
||||
selectedFields.value.push({ ...field, id: Guid.raw() })
|
||||
fetchNextNumber()
|
||||
}
|
||||
|
||||
function removeComponent(component: NumberField): void {
|
||||
selectedFields.value = selectedFields.value.filter((el) => component.id !== el.id)
|
||||
}
|
||||
|
||||
function onUpdate(val: string, element: NumberField): void {
|
||||
switch (element.name) {
|
||||
case 'SERIES':
|
||||
if (val.length >= 6) val = val.substring(0, 6)
|
||||
break
|
||||
case 'DELIMITER':
|
||||
if (val.length >= 1) val = val.substring(0, 1)
|
||||
break
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
element.value = val
|
||||
fetchNextNumber()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const fetchNextNumber = useDebounceFn(() => {
|
||||
getNextNumber()
|
||||
}, 500)
|
||||
|
||||
async function getNextNumber(): Promise<void> {
|
||||
if (!getNumberFormat.value) {
|
||||
nextNumber.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const data = {
|
||||
key: props.type,
|
||||
format: getNumberFormat.value,
|
||||
}
|
||||
|
||||
isFetchingNextNumber.value = true
|
||||
|
||||
const res = await props.typeStore.getNextNumber(data)
|
||||
|
||||
isFetchingNextNumber.value = false
|
||||
|
||||
if (res.data) {
|
||||
nextNumber.value = (res.data as Record<string, string>).nextNumber
|
||||
}
|
||||
}
|
||||
|
||||
async function submitForm(): Promise<boolean> {
|
||||
if (isFetchingNextNumber.value || isLoadingPlaceholders.value) return false
|
||||
|
||||
isSaving.value = true
|
||||
|
||||
const data: { settings: Record<string, string> } = { settings: {} }
|
||||
data.settings[props.type + '_number_format'] = getNumberFormat.value
|
||||
|
||||
await companyStore.updateCompanySettings({
|
||||
data,
|
||||
message: `settings.customization.${props.type}s.${props.type}_settings_updated`,
|
||||
})
|
||||
|
||||
isSaving.value = false
|
||||
return true
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h6 class="text-heading text-lg font-medium">
|
||||
{{ $t(`settings.customization.${type}s.${type}_number_format`) }}
|
||||
</h6>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
{{ $t(`settings.customization.${type}s.${type}_number_format_description`) }}
|
||||
</p>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full mt-6 table-fixed">
|
||||
<colgroup>
|
||||
<col style="width: 4%" />
|
||||
<col style="width: 45%" />
|
||||
<col style="width: 27%" />
|
||||
<col style="width: 24%" />
|
||||
</colgroup>
|
||||
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
class="px-5 py-3 text-sm not-italic font-medium leading-5 text-left text-body border-t border-b border-line-default border-solid"
|
||||
/>
|
||||
<th
|
||||
class="px-5 py-3 text-sm not-italic font-medium leading-5 text-left text-body border-t border-b border-line-default border-solid"
|
||||
>
|
||||
{{ $t('settings.customization.component') }}
|
||||
</th>
|
||||
<th
|
||||
class="px-5 py-3 text-sm not-italic font-medium leading-5 text-left text-body border-t border-b border-line-default border-solid"
|
||||
>
|
||||
{{ $t('settings.customization.Parameter') }}
|
||||
</th>
|
||||
<th
|
||||
class="px-5 py-3 text-sm not-italic font-medium leading-5 text-left text-body border-t border-b border-line-default border-solid"
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<draggable
|
||||
v-model="selectedFields"
|
||||
class="divide-y divide-line-default"
|
||||
item-key="id"
|
||||
tag="tbody"
|
||||
handle=".handle"
|
||||
filter=".ignore-element"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<tr class="relative">
|
||||
<td class="text-subtle cursor-move handle align-middle">
|
||||
<DragIcon />
|
||||
</td>
|
||||
<td class="px-5 py-4">
|
||||
<label
|
||||
class="block text-sm not-italic font-medium text-primary-500 whitespace-nowrap mr-2 min-w-[200px]"
|
||||
>
|
||||
{{ element.label }}
|
||||
</label>
|
||||
<p class="text-xs text-muted mt-1">
|
||||
{{ element.description }}
|
||||
</p>
|
||||
</td>
|
||||
<td class="px-5 py-4 text-left align-middle">
|
||||
<BaseInputGroup
|
||||
:label="element.paramLabel"
|
||||
class="lg:col-span-3"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model="element.value"
|
||||
:disabled="element.inputDisabled"
|
||||
:type="element.inputType"
|
||||
@update:model-value="onUpdate($event, element)"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
</td>
|
||||
<td class="px-5 py-4 text-right align-middle pt-10">
|
||||
<BaseButton
|
||||
variant="white"
|
||||
@click.prevent="removeComponent(element)"
|
||||
>
|
||||
{{ $t('general.remove') }}
|
||||
<template #left="slotProps">
|
||||
<BaseIcon
|
||||
name="XMarkIcon"
|
||||
class="!sm:m-0"
|
||||
:class="slotProps.class"
|
||||
/>
|
||||
</template>
|
||||
</BaseButton>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<tr>
|
||||
<td colspan="2" class="px-5 py-4">
|
||||
<BaseInputGroup
|
||||
:label="$t(`settings.customization.${type}s.preview_${type}_number`)"
|
||||
>
|
||||
<BaseInput
|
||||
v-model="nextNumber"
|
||||
disabled
|
||||
:loading="isFetchingNextNumber"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
</td>
|
||||
<td class="px-5 py-4 text-right align-middle" colspan="2">
|
||||
<BaseDropdown wrapper-class="flex items-center justify-end mt-5">
|
||||
<template #activator>
|
||||
<BaseButton variant="primary-outline">
|
||||
<template #left="slotProps">
|
||||
<BaseIcon :class="slotProps.class" name="PlusIcon" />
|
||||
</template>
|
||||
{{ $t('settings.customization.add_new_component') }}
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<BaseDropdownItem
|
||||
v-for="field in computedFields"
|
||||
:key="field.label"
|
||||
@click.prevent="onSelectField(field)"
|
||||
>
|
||||
{{ field.label }}
|
||||
</BaseDropdownItem>
|
||||
</BaseDropdown>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</draggable>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<BaseButton
|
||||
:loading="isSaving"
|
||||
:disabled="isSaving"
|
||||
variant="primary"
|
||||
type="submit"
|
||||
class="mt-4"
|
||||
@click="submitForm"
|
||||
>
|
||||
<template #left="slotProps">
|
||||
<BaseIcon
|
||||
v-if="!isSaving"
|
||||
:class="slotProps.class"
|
||||
name="ArrowDownOnSquareIcon"
|
||||
/>
|
||||
</template>
|
||||
{{ $t('settings.customization.save') }}
|
||||
</BaseButton>
|
||||
</template>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useDialogStore } from '@/scripts/stores/dialog.store'
|
||||
import { useModalStore } from '@/scripts/stores/modal.store'
|
||||
import { paymentService } from '@/scripts/api/services/payment.service'
|
||||
|
||||
interface PaymentModeRow {
|
||||
id: number
|
||||
name: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
row: PaymentModeRow
|
||||
table?: { refresh: () => void } | null
|
||||
loadData?: (() => void) | null
|
||||
}>()
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const modalStore = useModalStore()
|
||||
|
||||
function editPaymentMode(id: number): void {
|
||||
modalStore.openModal({
|
||||
title: t('settings.payment_modes.edit_payment_mode'),
|
||||
componentName: 'PaymentModeModal',
|
||||
data: id,
|
||||
refreshData: props.loadData ?? undefined,
|
||||
size: 'sm',
|
||||
})
|
||||
}
|
||||
|
||||
function removePaymentMode(id: number): void {
|
||||
dialogStore
|
||||
.openDialog({
|
||||
title: t('general.are_you_sure'),
|
||||
message: t('settings.payment_modes.payment_mode_confirm_delete'),
|
||||
yesLabel: t('general.ok'),
|
||||
noLabel: t('general.cancel'),
|
||||
variant: 'danger',
|
||||
hideNoButton: false,
|
||||
size: 'lg',
|
||||
})
|
||||
.then(async (res: boolean) => {
|
||||
if (res) {
|
||||
await paymentService.deleteMethod(id)
|
||||
props.loadData?.()
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseDropdown>
|
||||
<template #activator>
|
||||
<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" />
|
||||
</template>
|
||||
|
||||
<BaseDropdownItem @click="editPaymentMode(row.id)">
|
||||
<BaseIcon
|
||||
name="PencilIcon"
|
||||
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
|
||||
/>
|
||||
{{ $t('general.edit') }}
|
||||
</BaseDropdownItem>
|
||||
|
||||
<BaseDropdownItem @click="removePaymentMode(row.id)">
|
||||
<BaseIcon
|
||||
name="TrashIcon"
|
||||
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
|
||||
/>
|
||||
{{ $t('general.delete') }}
|
||||
</BaseDropdownItem>
|
||||
</BaseDropdown>
|
||||
</template>
|
||||
@@ -0,0 +1,170 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { required, minLength, helpers } from '@vuelidate/validators'
|
||||
import useVuelidate from '@vuelidate/core'
|
||||
import { useModalStore } from '@/scripts/stores/modal.store'
|
||||
import { useNotificationStore } from '@/scripts/stores/notification.store'
|
||||
import { paymentService } from '@/scripts/api/services/payment.service'
|
||||
|
||||
interface PaymentModeForm {
|
||||
id: number | null
|
||||
name: string
|
||||
}
|
||||
|
||||
const modalStore = useModalStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isSaving = ref<boolean>(false)
|
||||
const currentPaymentMode = ref<PaymentModeForm>({
|
||||
id: null,
|
||||
name: '',
|
||||
})
|
||||
|
||||
const modalActive = computed<boolean>(
|
||||
() => modalStore.active && modalStore.componentName === 'PaymentModeModal'
|
||||
)
|
||||
|
||||
const rules = computed(() => ({
|
||||
name: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
minLength: helpers.withMessage(
|
||||
t('validation.name_min_length', { count: 3 }),
|
||||
minLength(3)
|
||||
),
|
||||
},
|
||||
}))
|
||||
|
||||
const v$ = useVuelidate(rules, currentPaymentMode)
|
||||
|
||||
async function setInitialData(): Promise<void> {
|
||||
if (modalStore.data && typeof modalStore.data === 'number') {
|
||||
const response = await paymentService.getMethod(modalStore.data)
|
||||
if (response.data) {
|
||||
currentPaymentMode.value = {
|
||||
id: response.data.id,
|
||||
name: response.data.name,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
resetForm()
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPaymentMode(): Promise<void> {
|
||||
v$.value.$touch()
|
||||
|
||||
if (v$.value.$invalid) {
|
||||
return
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
if (currentPaymentMode.value.id) {
|
||||
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
|
||||
if (modalStore.refreshData) {
|
||||
modalStore.refreshData()
|
||||
}
|
||||
closePaymentModeModal()
|
||||
} catch {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm(): void {
|
||||
currentPaymentMode.value = {
|
||||
id: null,
|
||||
name: '',
|
||||
}
|
||||
}
|
||||
|
||||
function closePaymentModeModal(): void {
|
||||
modalStore.closeModal()
|
||||
setTimeout(() => {
|
||||
resetForm()
|
||||
v$.value.$reset()
|
||||
}, 300)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal
|
||||
:show="modalActive"
|
||||
@close="closePaymentModeModal"
|
||||
@open="setInitialData"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex justify-between w-full">
|
||||
{{ modalStore.title }}
|
||||
<BaseIcon
|
||||
name="XMarkIcon"
|
||||
class="w-6 h-6 text-muted cursor-pointer"
|
||||
@click="closePaymentModeModal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<form action="" @submit.prevent="submitPaymentMode">
|
||||
<div class="p-4 sm:p-6">
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.payment_modes.mode_name')"
|
||||
:error="v$.name.$error && v$.name.$errors[0].$message"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model="currentPaymentMode.name"
|
||||
:invalid="v$.name.$error"
|
||||
@input="v$.name.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="z-0 flex justify-end p-4 border-t border-line-default border-solid"
|
||||
>
|
||||
<BaseButton
|
||||
variant="primary-outline"
|
||||
class="mr-3"
|
||||
type="button"
|
||||
@click="closePaymentModeModal"
|
||||
>
|
||||
{{ $t('general.cancel') }}
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
:loading="isSaving"
|
||||
:disabled="isSaving"
|
||||
variant="primary"
|
||||
type="submit"
|
||||
>
|
||||
<template #left="slotProps">
|
||||
<BaseIcon name="ArrowDownOnSquareIcon" :class="slotProps.class" />
|
||||
</template>
|
||||
{{
|
||||
currentPaymentMode.id
|
||||
? $t('general.update')
|
||||
: $t('general.save')
|
||||
}}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</form>
|
||||
</BaseModal>
|
||||
</template>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, inject } from 'vue'
|
||||
import { useCompanyStore } from '@/scripts/stores/company.store'
|
||||
import { usePaymentStore } from '@/scripts/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
|
||||
}
|
||||
|
||||
const utils = inject<Utils>('utils')!
|
||||
const companyStore = useCompanyStore()
|
||||
const paymentStore = usePaymentStore()
|
||||
|
||||
const paymentSettings = reactive<{ payment_email_attachment: string | null }>({
|
||||
payment_email_attachment: null,
|
||||
})
|
||||
|
||||
utils.mergeSettings(
|
||||
paymentSettings as unknown as Record<string, unknown>,
|
||||
{ ...companyStore.selectedCompanySettings }
|
||||
)
|
||||
|
||||
const sendAsAttachmentField = computed<boolean>({
|
||||
get: () => paymentSettings.payment_email_attachment === 'YES',
|
||||
set: async (newValue: boolean) => {
|
||||
const value = newValue ? 'YES' : 'NO'
|
||||
|
||||
const data = {
|
||||
settings: {
|
||||
payment_email_attachment: value,
|
||||
},
|
||||
}
|
||||
|
||||
paymentSettings.payment_email_attachment = value
|
||||
|
||||
await companyStore.updateCompanySettings({
|
||||
data,
|
||||
message: 'general.setting_updated',
|
||||
})
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<BaseSwitchSection
|
||||
v-model="sendAsAttachmentField"
|
||||
:title="$t('settings.customization.payments.payment_email_attachment')"
|
||||
:description="
|
||||
$t(
|
||||
'settings.customization.payments.payment_email_attachment_setting_description'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</ul>
|
||||
</template>
|
||||
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, inject } from 'vue'
|
||||
import { useCompanyStore } from '@/scripts/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>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useDialogStore } from '@/scripts/stores/dialog.store'
|
||||
import { useUserStore } from '@/scripts/stores/user.store'
|
||||
import { useModalStore } from '@/scripts/stores/modal.store'
|
||||
import { useNotificationStore } from '@/scripts/stores/notification.store'
|
||||
import { roleService } from '@/scripts/api/services/role.service'
|
||||
|
||||
interface RoleRow {
|
||||
id: number
|
||||
name: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
row: RoleRow
|
||||
table?: { refresh: () => void } | null
|
||||
loadData?: (() => void) | null
|
||||
}>()
|
||||
|
||||
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',
|
||||
size: 'lg',
|
||||
data: id,
|
||||
refreshData: props.loadData ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
async function removeRole(id: number): Promise<void> {
|
||||
if (PROTECTED_ROLES.includes(props.row.name)) return
|
||||
dialogStore
|
||||
.openDialog({
|
||||
title: t('general.are_you_sure'),
|
||||
message: t('settings.roles.confirm_delete'),
|
||||
yesLabel: t('general.ok'),
|
||||
noLabel: t('general.cancel'),
|
||||
variant: 'danger',
|
||||
hideNoButton: false,
|
||||
size: 'lg',
|
||||
})
|
||||
.then(async (res: boolean) => {
|
||||
if (res) {
|
||||
await roleService.delete(id)
|
||||
notificationStore.showNotification({
|
||||
type: 'success',
|
||||
message: 'settings.roles.deleted_message',
|
||||
})
|
||||
props.loadData?.()
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseDropdown>
|
||||
<template #activator>
|
||||
<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" />
|
||||
</template>
|
||||
|
||||
<BaseDropdownItem
|
||||
v-if="userStore.currentUser?.is_owner"
|
||||
@click="editRole(row.id)"
|
||||
>
|
||||
<BaseIcon
|
||||
name="PencilIcon"
|
||||
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
|
||||
/>
|
||||
{{ $t('general.edit') }}
|
||||
</BaseDropdownItem>
|
||||
|
||||
<BaseDropdownItem
|
||||
v-if="userStore.currentUser?.is_owner"
|
||||
@click="removeRole(row.id)"
|
||||
>
|
||||
<BaseIcon
|
||||
name="TrashIcon"
|
||||
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
|
||||
/>
|
||||
{{ $t('general.delete') }}
|
||||
</BaseDropdownItem>
|
||||
</BaseDropdown>
|
||||
</template>
|
||||
@@ -0,0 +1,384 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { required, minLength, helpers } from '@vuelidate/validators'
|
||||
import useVuelidate from '@vuelidate/core'
|
||||
import { useModalStore } from '@/scripts/stores/modal.store'
|
||||
import { useNotificationStore } from '@/scripts/stores/notification.store'
|
||||
import { roleService } from '@/scripts/api/services/role.service'
|
||||
import type { CreateRolePayload } from '@/scripts/api/services/role.service'
|
||||
import type { Ability } from '@/scripts/types/domain/role'
|
||||
|
||||
interface AbilityItem {
|
||||
name: string
|
||||
ability: string
|
||||
disabled: boolean
|
||||
depends_on?: string[]
|
||||
model?: string
|
||||
}
|
||||
|
||||
interface AbilitiesList {
|
||||
[group: string]: AbilityItem[]
|
||||
}
|
||||
|
||||
interface RoleForm {
|
||||
id: number | null
|
||||
name: string
|
||||
abilities: AbilityItem[]
|
||||
}
|
||||
|
||||
const modalStore = useModalStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isSaving = ref<boolean>(false)
|
||||
const isFetchingInitialData = ref<boolean>(false)
|
||||
const isEdit = ref<boolean>(false)
|
||||
|
||||
const currentRole = ref<RoleForm>({
|
||||
id: null,
|
||||
name: '',
|
||||
abilities: [],
|
||||
})
|
||||
|
||||
const abilitiesList = ref<AbilitiesList>({})
|
||||
|
||||
const modalActive = computed<boolean>(
|
||||
() => modalStore.active && modalStore.componentName === 'RolesModal'
|
||||
)
|
||||
|
||||
const rules = computed(() => ({
|
||||
name: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
minLength: helpers.withMessage(
|
||||
t('validation.name_min_length', { count: 3 }),
|
||||
minLength(3)
|
||||
),
|
||||
},
|
||||
abilities: {
|
||||
required: helpers.withMessage(
|
||||
t('validation.at_least_one_ability'),
|
||||
required
|
||||
),
|
||||
},
|
||||
}))
|
||||
|
||||
const v$ = useVuelidate(rules, currentRole)
|
||||
|
||||
async function setInitialData(): Promise<void> {
|
||||
isFetchingInitialData.value = true
|
||||
|
||||
const abilitiesRes = await roleService.getAbilities()
|
||||
if (abilitiesRes.abilities) {
|
||||
const grouped: AbilitiesList = {}
|
||||
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: (a.depends_on as string[]) ?? [],
|
||||
} as AbilityItem)
|
||||
})
|
||||
abilitiesList.value = grouped
|
||||
}
|
||||
|
||||
if (modalStore.data && typeof modalStore.data === 'number') {
|
||||
isEdit.value = true
|
||||
const response = await roleService.get(modalStore.data)
|
||||
if (response.data) {
|
||||
currentRole.value = {
|
||||
id: response.data.id,
|
||||
name: response.data.name,
|
||||
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
|
||||
currentRole.value = { id: null, name: '', abilities: [] }
|
||||
}
|
||||
|
||||
isFetchingInitialData.value = false
|
||||
}
|
||||
|
||||
async function submitRoleData(): Promise<void> {
|
||||
v$.value.$touch()
|
||||
|
||||
if (v$.value.$invalid) {
|
||||
return
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
const payload: CreateRolePayload = {
|
||||
name: currentRole.value.name,
|
||||
abilities: currentRole.value.abilities.map((a) => ({
|
||||
ability: a.ability,
|
||||
})),
|
||||
}
|
||||
|
||||
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
|
||||
if (modalStore.refreshData) {
|
||||
modalStore.refreshData()
|
||||
}
|
||||
closeRolesModal()
|
||||
} catch {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onUpdateAbility(currentAbility: AbilityItem): void {
|
||||
const fd = currentRole.value.abilities.find(
|
||||
(_abl) => _abl.ability === currentAbility.ability
|
||||
)
|
||||
|
||||
if (!fd && currentAbility.depends_on?.length) {
|
||||
enableAbilities(currentAbility)
|
||||
return
|
||||
}
|
||||
|
||||
currentAbility.depends_on?.forEach((_d) => {
|
||||
Object.keys(abilitiesList.value).forEach((group) => {
|
||||
abilitiesList.value[group].forEach((_a) => {
|
||||
if (_d === _a.ability) {
|
||||
_a.disabled = true
|
||||
const found = currentRole.value.abilities.find(
|
||||
(_af) => _af.ability === _d
|
||||
)
|
||||
if (!found) {
|
||||
currentRole.value.abilities.push(_a)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function setSelectAll(checked: boolean): void {
|
||||
const dependList: string[] = []
|
||||
Object.keys(abilitiesList.value).forEach((group) => {
|
||||
abilitiesList.value[group].forEach((_a) => {
|
||||
if (_a.depends_on) {
|
||||
dependList.push(..._a.depends_on)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Object.keys(abilitiesList.value).forEach((group) => {
|
||||
abilitiesList.value[group].forEach((_a) => {
|
||||
if (dependList.includes(_a.ability)) {
|
||||
_a.disabled = checked
|
||||
}
|
||||
currentRole.value.abilities.push(_a)
|
||||
})
|
||||
})
|
||||
|
||||
if (!checked) {
|
||||
currentRole.value.abilities = []
|
||||
}
|
||||
}
|
||||
|
||||
function enableAbilities(ability: AbilityItem): void {
|
||||
ability.depends_on?.forEach((_d) => {
|
||||
Object.keys(abilitiesList.value).forEach((group) => {
|
||||
abilitiesList.value[group].forEach((_a) => {
|
||||
const found = currentRole.value.abilities.find((_r) =>
|
||||
_r.depends_on?.includes(_a.ability)
|
||||
)
|
||||
if (_d === _a.ability && !found) {
|
||||
_a.disabled = false
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function closeRolesModal(): void {
|
||||
modalStore.closeModal()
|
||||
setTimeout(() => {
|
||||
currentRole.value = { id: null, name: '', abilities: [] }
|
||||
isEdit.value = false
|
||||
|
||||
Object.keys(abilitiesList.value).forEach((group) => {
|
||||
abilitiesList.value[group].forEach((_a) => {
|
||||
_a.disabled = false
|
||||
})
|
||||
})
|
||||
|
||||
v$.value.$reset()
|
||||
}, 300)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal
|
||||
:show="modalActive"
|
||||
@close="closeRolesModal"
|
||||
@open="setInitialData"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex justify-between w-full">
|
||||
{{ modalStore.title }}
|
||||
<BaseIcon
|
||||
name="XMarkIcon"
|
||||
class="w-6 h-6 text-muted cursor-pointer"
|
||||
@click="closeRolesModal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<form @submit.prevent="submitRoleData">
|
||||
<div class="px-4 md:px-8 py-4 md:py-6">
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.roles.name')"
|
||||
class="mt-3"
|
||||
:error="v$.name.$error && v$.name.$errors[0].$message"
|
||||
required
|
||||
:content-loading="isFetchingInitialData"
|
||||
>
|
||||
<BaseInput
|
||||
v-model="currentRole.name"
|
||||
:invalid="v$.name.$error"
|
||||
type="text"
|
||||
:content-loading="isFetchingInitialData"
|
||||
@input="v$.name.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<h6
|
||||
class="text-sm not-italic font-medium text-heading px-4 md:px-8 py-1.5"
|
||||
>
|
||||
{{ $t('settings.roles.permission', 2) }}
|
||||
<span class="text-sm text-red-500"> *</span>
|
||||
</h6>
|
||||
<div
|
||||
class="text-sm not-italic font-medium text-subtle px-4 md:px-8 py-1.5"
|
||||
>
|
||||
<a
|
||||
class="cursor-pointer text-primary-400"
|
||||
@click="setSelectAll(true)"
|
||||
>
|
||||
{{ $t('settings.roles.select_all') }}
|
||||
</a>
|
||||
/
|
||||
<a
|
||||
class="cursor-pointer text-primary-400"
|
||||
@click="setSelectAll(false)"
|
||||
>
|
||||
{{ $t('settings.roles.none') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-line-default py-3">
|
||||
<div
|
||||
class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 px-8 sm:px-8"
|
||||
>
|
||||
<div
|
||||
v-for="(abilityGroup, gIndex) in abilitiesList"
|
||||
:key="gIndex"
|
||||
class="flex flex-col space-y-1"
|
||||
>
|
||||
<p
|
||||
class="text-sm text-muted border-b border-line-default pb-1 mb-2"
|
||||
>
|
||||
{{ gIndex }}
|
||||
</p>
|
||||
<div
|
||||
v-for="(ability, index) in abilityGroup"
|
||||
:key="index"
|
||||
class="flex"
|
||||
>
|
||||
<BaseCheckbox
|
||||
v-model="currentRole.abilities"
|
||||
:set-initial-value="true"
|
||||
variant="primary"
|
||||
:disabled="ability.disabled"
|
||||
:label="ability.name"
|
||||
:value="ability"
|
||||
@update:model-value="onUpdateAbility(ability)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="v$.abilities.$error"
|
||||
class="block mt-0.5 text-sm text-red-500"
|
||||
>
|
||||
{{ v$.abilities.$errors[0].$message }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="z-0 flex justify-end p-4 border-t border-solid border-line-default"
|
||||
>
|
||||
<BaseButton
|
||||
class="mr-3 text-sm"
|
||||
variant="primary-outline"
|
||||
type="button"
|
||||
@click="closeRolesModal"
|
||||
>
|
||||
{{ $t('general.cancel') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
:loading="isSaving"
|
||||
:disabled="isSaving"
|
||||
variant="primary"
|
||||
type="submit"
|
||||
>
|
||||
<template #left="slotProps">
|
||||
<BaseIcon name="ArrowDownOnSquareIcon" :class="slotProps.class" />
|
||||
</template>
|
||||
{{ isEdit ? $t('general.update') : $t('general.save') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</form>
|
||||
</BaseModal>
|
||||
</template>
|
||||
@@ -0,0 +1,313 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { required, email, numeric, helpers } from '@vuelidate/validators'
|
||||
import useVuelidate from '@vuelidate/core'
|
||||
|
||||
interface SesConfig {
|
||||
mail_driver: string
|
||||
mail_host: string
|
||||
mail_port: string
|
||||
mail_encryption: string
|
||||
mail_ses_key: string
|
||||
mail_ses_secret: string
|
||||
mail_ses_region: string
|
||||
from_mail: string
|
||||
from_name: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
configData?: Record<string, unknown>
|
||||
isSaving?: boolean
|
||||
isFetchingInitialData?: boolean
|
||||
mailDrivers?: string[]
|
||||
}>(),
|
||||
{
|
||||
configData: () => ({}),
|
||||
isSaving: false,
|
||||
isFetchingInitialData: false,
|
||||
mailDrivers: () => [],
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'submit-data': [config: SesConfig]
|
||||
'on-change-driver': [driver: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const isShowPassword = ref<boolean>(false)
|
||||
const encryptions = reactive<string[]>(['tls', 'ssl', 'starttls'])
|
||||
|
||||
const sesConfig = reactive<SesConfig>({
|
||||
mail_driver: 'ses',
|
||||
mail_host: '',
|
||||
mail_port: '',
|
||||
mail_encryption: '',
|
||||
mail_ses_key: '',
|
||||
mail_ses_secret: '',
|
||||
mail_ses_region: '',
|
||||
from_mail: '',
|
||||
from_name: '',
|
||||
})
|
||||
|
||||
const getInputType = computed<string>(() =>
|
||||
isShowPassword.value ? 'text' : 'password'
|
||||
)
|
||||
|
||||
const rules = computed(() => ({
|
||||
mail_driver: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
mail_host: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
mail_port: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
numeric: helpers.withMessage(t('validation.numbers_only'), numeric),
|
||||
},
|
||||
mail_ses_key: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
mail_ses_secret: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
mail_ses_region: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
mail_encryption: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
from_mail: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
email: helpers.withMessage(t('validation.email_incorrect'), email),
|
||||
},
|
||||
from_name: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
}))
|
||||
|
||||
const v$ = useVuelidate(rules, sesConfig)
|
||||
|
||||
onMounted(() => {
|
||||
for (const key in sesConfig) {
|
||||
if (Object.prototype.hasOwnProperty.call(props.configData, key)) {
|
||||
;(sesConfig as Record<string, unknown>)[key] = props.configData[key]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function saveEmailConfig(): Promise<void> {
|
||||
v$.value.$touch()
|
||||
if (!v$.value.$invalid) {
|
||||
emit('submit-data', { ...sesConfig })
|
||||
}
|
||||
}
|
||||
|
||||
function onChangeDriver(): void {
|
||||
v$.value.mail_driver.$touch()
|
||||
emit('on-change-driver', sesConfig.mail_driver)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="saveEmailConfig">
|
||||
<BaseInputGrid>
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.mail.driver')"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="
|
||||
v$.mail_driver.$error && v$.mail_driver.$errors[0].$message
|
||||
"
|
||||
required
|
||||
>
|
||||
<BaseMultiselect
|
||||
v-model="sesConfig.mail_driver"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:options="mailDrivers"
|
||||
:can-deselect="false"
|
||||
:invalid="v$.mail_driver.$error"
|
||||
@update:model-value="onChangeDriver"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.mail.host')"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="
|
||||
v$.mail_host.$error && v$.mail_host.$errors[0].$message
|
||||
"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="sesConfig.mail_host"
|
||||
:content-loading="isFetchingInitialData"
|
||||
type="text"
|
||||
name="mail_host"
|
||||
:invalid="v$.mail_host.$error"
|
||||
@input="v$.mail_host.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.mail.port')"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="
|
||||
v$.mail_port.$error && v$.mail_port.$errors[0].$message
|
||||
"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="sesConfig.mail_port"
|
||||
:content-loading="isFetchingInitialData"
|
||||
type="text"
|
||||
name="mail_port"
|
||||
:invalid="v$.mail_port.$error"
|
||||
@input="v$.mail_port.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.mail.encryption')"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="
|
||||
v$.mail_encryption.$error &&
|
||||
v$.mail_encryption.$errors[0].$message
|
||||
"
|
||||
required
|
||||
>
|
||||
<BaseMultiselect
|
||||
v-model.trim="sesConfig.mail_encryption"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:options="encryptions"
|
||||
:invalid="v$.mail_encryption.$error"
|
||||
placeholder="Select option"
|
||||
@input="v$.mail_encryption.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.mail.from_mail')"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="
|
||||
v$.from_mail.$error && v$.from_mail.$errors[0].$message
|
||||
"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="sesConfig.from_mail"
|
||||
:content-loading="isFetchingInitialData"
|
||||
type="text"
|
||||
name="from_mail"
|
||||
:invalid="v$.from_mail.$error"
|
||||
@input="v$.from_mail.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.mail.from_name')"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="
|
||||
v$.from_name.$error && v$.from_name.$errors[0].$message
|
||||
"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="sesConfig.from_name"
|
||||
:content-loading="isFetchingInitialData"
|
||||
type="text"
|
||||
name="name"
|
||||
:invalid="v$.from_name.$error"
|
||||
@input="v$.from_name.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.mail.ses_key')"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="
|
||||
v$.mail_ses_key.$error && v$.mail_ses_key.$errors[0].$message
|
||||
"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="sesConfig.mail_ses_key"
|
||||
:content-loading="isFetchingInitialData"
|
||||
type="text"
|
||||
name="mail_ses_key"
|
||||
:invalid="v$.mail_ses_key.$error"
|
||||
@input="v$.mail_ses_key.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.mail.ses_secret')"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="
|
||||
v$.mail_ses_secret.$error &&
|
||||
v$.mail_ses_secret.$errors[0].$message
|
||||
"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="sesConfig.mail_ses_secret"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:type="getInputType"
|
||||
name="mail_ses_secret"
|
||||
autocomplete="off"
|
||||
:invalid="v$.mail_ses_secret.$error"
|
||||
@input="v$.mail_ses_secret.$touch()"
|
||||
>
|
||||
<template #right>
|
||||
<BaseIcon
|
||||
:name="isShowPassword ? 'EyeIcon' : 'EyeSlashIcon'"
|
||||
class="mr-1 text-muted cursor-pointer"
|
||||
@click="isShowPassword = !isShowPassword"
|
||||
/>
|
||||
</template>
|
||||
</BaseInput>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.mail.ses_region')"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="
|
||||
v$.mail_ses_region.$error &&
|
||||
v$.mail_ses_region.$errors[0].$message
|
||||
"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="sesConfig.mail_ses_region"
|
||||
:content-loading="isFetchingInitialData"
|
||||
type="text"
|
||||
name="mail_ses_region"
|
||||
:invalid="v$.mail_ses_region.$error"
|
||||
@input="v$.mail_ses_region.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
</BaseInputGrid>
|
||||
|
||||
<div class="flex my-10">
|
||||
<BaseButton
|
||||
:disabled="isSaving"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:loading="isSaving"
|
||||
variant="primary"
|
||||
type="submit"
|
||||
>
|
||||
<template #left="slotProps">
|
||||
<BaseIcon
|
||||
v-if="!isSaving"
|
||||
name="ArrowDownOnSquareIcon"
|
||||
:class="slotProps.class"
|
||||
/>
|
||||
</template>
|
||||
{{ $t('general.save') }}
|
||||
</BaseButton>
|
||||
<slot />
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,261 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, onMounted, ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { required, email, numeric, helpers } from '@vuelidate/validators'
|
||||
import useVuelidate from '@vuelidate/core'
|
||||
|
||||
interface SmtpConfig {
|
||||
mail_driver: string
|
||||
mail_host: string
|
||||
mail_port: string
|
||||
mail_username: string
|
||||
mail_password: string
|
||||
mail_encryption: string
|
||||
from_mail: string
|
||||
from_name: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
configData?: Record<string, unknown>
|
||||
isSaving?: boolean
|
||||
isFetchingInitialData?: boolean
|
||||
mailDrivers?: string[]
|
||||
}>(),
|
||||
{
|
||||
configData: () => ({}),
|
||||
isSaving: false,
|
||||
isFetchingInitialData: false,
|
||||
mailDrivers: () => [],
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'submit-data': [config: SmtpConfig]
|
||||
'on-change-driver': [driver: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const isShowPassword = ref<boolean>(false)
|
||||
const schemes = reactive<string[]>(['smtp', 'smtps', 'none'])
|
||||
|
||||
const smtpConfig = reactive<SmtpConfig>({
|
||||
mail_driver: 'smtp',
|
||||
mail_host: '',
|
||||
mail_port: '',
|
||||
mail_username: '',
|
||||
mail_password: '',
|
||||
mail_encryption: '',
|
||||
from_mail: '',
|
||||
from_name: '',
|
||||
})
|
||||
|
||||
const getInputType = computed<string>(() =>
|
||||
isShowPassword.value ? 'text' : 'password'
|
||||
)
|
||||
|
||||
const rules = computed(() => ({
|
||||
mail_driver: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
mail_host: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
mail_port: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
numeric: helpers.withMessage(t('validation.numbers_only'), numeric),
|
||||
},
|
||||
from_mail: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
email: helpers.withMessage(t('validation.email_incorrect'), email),
|
||||
},
|
||||
from_name: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
}))
|
||||
|
||||
const v$ = useVuelidate(rules, smtpConfig)
|
||||
|
||||
onMounted(() => {
|
||||
for (const key in smtpConfig) {
|
||||
if (Object.prototype.hasOwnProperty.call(props.configData, key)) {
|
||||
;(smtpConfig as Record<string, unknown>)[key] = props.configData[key]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function saveEmailConfig(): Promise<void> {
|
||||
v$.value.$touch()
|
||||
if (!v$.value.$invalid) {
|
||||
emit('submit-data', { ...smtpConfig })
|
||||
}
|
||||
}
|
||||
|
||||
function onChangeDriver(): void {
|
||||
v$.value.mail_driver.$touch()
|
||||
emit('on-change-driver', smtpConfig.mail_driver)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="saveEmailConfig">
|
||||
<BaseInputGrid>
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.mail.driver')"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="
|
||||
v$.mail_driver.$error && v$.mail_driver.$errors[0].$message
|
||||
"
|
||||
required
|
||||
>
|
||||
<BaseMultiselect
|
||||
v-model="smtpConfig.mail_driver"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:options="mailDrivers"
|
||||
:can-deselect="false"
|
||||
:invalid="v$.mail_driver.$error"
|
||||
@update:model-value="onChangeDriver"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.mail.host')"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="
|
||||
v$.mail_host.$error && v$.mail_host.$errors[0].$message
|
||||
"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="smtpConfig.mail_host"
|
||||
:content-loading="isFetchingInitialData"
|
||||
type="text"
|
||||
name="mail_host"
|
||||
:invalid="v$.mail_host.$error"
|
||||
@input="v$.mail_host.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:content-loading="isFetchingInitialData"
|
||||
:label="$t('settings.mail.username')"
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="smtpConfig.mail_username"
|
||||
:content-loading="isFetchingInitialData"
|
||||
type="text"
|
||||
name="db_name"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:content-loading="isFetchingInitialData"
|
||||
:label="$t('settings.mail.password')"
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="smtpConfig.mail_password"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:type="getInputType"
|
||||
name="password"
|
||||
>
|
||||
<template #right>
|
||||
<BaseIcon
|
||||
:name="isShowPassword ? 'EyeIcon' : 'EyeSlashIcon'"
|
||||
class="mr-1 text-muted cursor-pointer"
|
||||
@click="isShowPassword = !isShowPassword"
|
||||
/>
|
||||
</template>
|
||||
</BaseInput>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.mail.port')"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="
|
||||
v$.mail_port.$error && v$.mail_port.$errors[0].$message
|
||||
"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="smtpConfig.mail_port"
|
||||
:content-loading="isFetchingInitialData"
|
||||
type="text"
|
||||
name="mail_port"
|
||||
:invalid="v$.mail_port.$error"
|
||||
@input="v$.mail_port.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.mail.encryption')"
|
||||
:content-loading="isFetchingInitialData"
|
||||
>
|
||||
<BaseMultiselect
|
||||
v-model.trim="smtpConfig.mail_encryption"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:options="schemes"
|
||||
:searchable="true"
|
||||
:show-labels="false"
|
||||
placeholder="Select option"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.mail.from_mail')"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="
|
||||
v$.from_mail.$error && v$.from_mail.$errors[0].$message
|
||||
"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="smtpConfig.from_mail"
|
||||
:content-loading="isFetchingInitialData"
|
||||
type="text"
|
||||
name="from_mail"
|
||||
:invalid="v$.from_mail.$error"
|
||||
@input="v$.from_mail.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.mail.from_name')"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="
|
||||
v$.from_name.$error && v$.from_name.$errors[0].$message
|
||||
"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="smtpConfig.from_name"
|
||||
:content-loading="isFetchingInitialData"
|
||||
type="text"
|
||||
name="from_name"
|
||||
:invalid="v$.from_name.$error"
|
||||
@input="v$.from_name.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
</BaseInputGrid>
|
||||
|
||||
<div class="flex my-10">
|
||||
<BaseButton
|
||||
:disabled="isSaving"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:loading="isSaving"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
>
|
||||
<template #left="slotProps">
|
||||
<BaseIcon
|
||||
v-if="!isSaving"
|
||||
name="ArrowDownOnSquareIcon"
|
||||
:class="slotProps.class"
|
||||
/>
|
||||
</template>
|
||||
{{ $t('general.save') }}
|
||||
</BaseButton>
|
||||
<slot />
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useDialogStore } from '@/scripts/stores/dialog.store'
|
||||
import { useUserStore } from '@/scripts/stores/user.store'
|
||||
import { useModalStore } from '@/scripts/stores/modal.store'
|
||||
import { useNotificationStore } from '@/scripts/stores/notification.store'
|
||||
import { taxTypeService } from '@/scripts/api/services/tax-type.service'
|
||||
|
||||
const ABILITIES = {
|
||||
EDIT_TAX_TYPE: 'edit-tax-type',
|
||||
DELETE_TAX_TYPE: 'delete-tax-type',
|
||||
} as const
|
||||
|
||||
interface TaxTypeRow {
|
||||
id: number
|
||||
name: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
row: TaxTypeRow
|
||||
table?: { refresh: () => void } | null
|
||||
loadData?: (() => void) | null
|
||||
}>()
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
const modalStore = useModalStore()
|
||||
|
||||
async function editTaxType(id: number): Promise<void> {
|
||||
modalStore.openModal({
|
||||
title: t('settings.tax_types.edit_tax'),
|
||||
componentName: 'TaxTypeModal',
|
||||
size: 'sm',
|
||||
data: id,
|
||||
refreshData: props.loadData ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function removeTaxType(id: number): void {
|
||||
dialogStore
|
||||
.openDialog({
|
||||
title: t('general.are_you_sure'),
|
||||
message: t('settings.tax_types.confirm_delete'),
|
||||
yesLabel: t('general.ok'),
|
||||
noLabel: t('general.cancel'),
|
||||
variant: 'danger',
|
||||
hideNoButton: false,
|
||||
size: 'lg',
|
||||
})
|
||||
.then(async (res: boolean) => {
|
||||
if (res) {
|
||||
const response = await taxTypeService.delete(id)
|
||||
if (response.success) {
|
||||
notificationStore.showNotification({
|
||||
type: 'success',
|
||||
message: 'settings.tax_types.deleted_message',
|
||||
})
|
||||
props.loadData?.()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseDropdown>
|
||||
<template #activator>
|
||||
<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" />
|
||||
</template>
|
||||
|
||||
<BaseDropdownItem
|
||||
v-if="userStore.hasAbilities(ABILITIES.EDIT_TAX_TYPE)"
|
||||
@click="editTaxType(row.id)"
|
||||
>
|
||||
<BaseIcon
|
||||
name="PencilIcon"
|
||||
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
|
||||
/>
|
||||
{{ $t('general.edit') }}
|
||||
</BaseDropdownItem>
|
||||
|
||||
<BaseDropdownItem
|
||||
v-if="userStore.hasAbilities(ABILITIES.DELETE_TAX_TYPE)"
|
||||
@click="removeTaxType(row.id)"
|
||||
>
|
||||
<BaseIcon
|
||||
name="TrashIcon"
|
||||
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
|
||||
/>
|
||||
{{ $t('general.delete') }}
|
||||
</BaseDropdownItem>
|
||||
</BaseDropdown>
|
||||
</template>
|
||||
@@ -0,0 +1,295 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
required,
|
||||
minLength,
|
||||
maxLength,
|
||||
between,
|
||||
helpers,
|
||||
} from '@vuelidate/validators'
|
||||
import useVuelidate from '@vuelidate/core'
|
||||
import { useModalStore } from '@/scripts/stores/modal.store'
|
||||
import { useCompanyStore } from '@/scripts/stores/company.store'
|
||||
import { useNotificationStore } from '@/scripts/stores/notification.store'
|
||||
import { taxTypeService } from '@/scripts/api/services/tax-type.service'
|
||||
import type { CreateTaxTypePayload } from '@/scripts/api/services/tax-type.service'
|
||||
|
||||
interface TaxTypeForm {
|
||||
id: number | null
|
||||
name: string
|
||||
calculation_type: string
|
||||
percent: number
|
||||
fixed_amount: number
|
||||
description: string
|
||||
}
|
||||
|
||||
const modalStore = useModalStore()
|
||||
const companyStore = useCompanyStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isSaving = ref<boolean>(false)
|
||||
const isEdit = ref<boolean>(false)
|
||||
|
||||
const currentTaxType = ref<TaxTypeForm>({
|
||||
id: null,
|
||||
name: '',
|
||||
calculation_type: 'percentage',
|
||||
percent: 0,
|
||||
fixed_amount: 0,
|
||||
description: '',
|
||||
})
|
||||
|
||||
const defaultCurrency = computed(() => companyStore.selectedCompanyCurrency)
|
||||
|
||||
const modalActive = computed<boolean>(
|
||||
() => modalStore.active && modalStore.componentName === 'TaxTypeModal'
|
||||
)
|
||||
|
||||
const rules = computed(() => ({
|
||||
name: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
minLength: helpers.withMessage(
|
||||
t('validation.name_min_length', { count: 3 }),
|
||||
minLength(3)
|
||||
),
|
||||
},
|
||||
calculation_type: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
percent: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
between: helpers.withMessage(
|
||||
t('validation.enter_valid_tax_rate'),
|
||||
between(-100, 100)
|
||||
),
|
||||
},
|
||||
fixed_amount: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
description: {
|
||||
maxLength: helpers.withMessage(
|
||||
t('validation.description_maxlength', { count: 255 }),
|
||||
maxLength(255)
|
||||
),
|
||||
},
|
||||
}))
|
||||
|
||||
const v$ = useVuelidate(rules, currentTaxType)
|
||||
|
||||
const fixedAmount = computed<number>({
|
||||
get: () => currentTaxType.value.fixed_amount / 100,
|
||||
set: (value: number) => {
|
||||
currentTaxType.value.fixed_amount = Math.round(value * 100)
|
||||
},
|
||||
})
|
||||
|
||||
async function setInitialData(): Promise<void> {
|
||||
if (modalStore.data && typeof modalStore.data === 'number') {
|
||||
isEdit.value = true
|
||||
const response = await taxTypeService.get(modalStore.data)
|
||||
if (response.data) {
|
||||
const tax = response.data
|
||||
currentTaxType.value = {
|
||||
id: tax.id,
|
||||
name: tax.name,
|
||||
calculation_type: tax.calculation_type ?? 'percentage',
|
||||
percent: tax.percent,
|
||||
fixed_amount: tax.fixed_amount,
|
||||
description: tax.description ?? '',
|
||||
}
|
||||
}
|
||||
} else {
|
||||
isEdit.value = false
|
||||
resetForm()
|
||||
}
|
||||
}
|
||||
|
||||
async function submitTaxTypeData(): Promise<void> {
|
||||
v$.value.$touch()
|
||||
if (v$.value.$invalid) {
|
||||
return
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
const payload: CreateTaxTypePayload = {
|
||||
name: currentTaxType.value.name,
|
||||
percent: currentTaxType.value.percent,
|
||||
fixed_amount: currentTaxType.value.fixed_amount,
|
||||
calculation_type: currentTaxType.value.calculation_type,
|
||||
description: currentTaxType.value.description || null,
|
||||
}
|
||||
|
||||
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
|
||||
if (modalStore.refreshData) {
|
||||
modalStore.refreshData()
|
||||
}
|
||||
closeTaxTypeModal()
|
||||
} catch {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm(): void {
|
||||
currentTaxType.value = {
|
||||
id: null,
|
||||
name: '',
|
||||
calculation_type: 'percentage',
|
||||
percent: 0,
|
||||
fixed_amount: 0,
|
||||
description: '',
|
||||
}
|
||||
}
|
||||
|
||||
function closeTaxTypeModal(): void {
|
||||
modalStore.closeModal()
|
||||
setTimeout(() => {
|
||||
resetForm()
|
||||
isEdit.value = false
|
||||
v$.value.$reset()
|
||||
}, 300)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal
|
||||
:show="modalActive"
|
||||
@close="closeTaxTypeModal"
|
||||
@open="setInitialData"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex justify-between w-full">
|
||||
{{ modalStore.title }}
|
||||
<BaseIcon
|
||||
name="XMarkIcon"
|
||||
class="h-6 w-6 text-muted cursor-pointer"
|
||||
@click="closeTaxTypeModal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<form action="" @submit.prevent="submitTaxTypeData">
|
||||
<div class="p-4 sm:p-6">
|
||||
<BaseInputGrid layout="one-column">
|
||||
<BaseInputGroup
|
||||
:label="$t('tax_types.name')"
|
||||
variant="horizontal"
|
||||
:error="v$.name.$error && v$.name.$errors[0].$message"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model="currentTaxType.name"
|
||||
:invalid="v$.name.$error"
|
||||
type="text"
|
||||
@input="v$.name.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t('tax_types.tax_type')"
|
||||
variant="horizontal"
|
||||
required
|
||||
>
|
||||
<BaseSelectInput
|
||||
v-model="currentTaxType.calculation_type"
|
||||
:options="[
|
||||
{ id: 'percentage', label: $t('tax_types.percentage') },
|
||||
{ id: 'fixed', label: $t('tax_types.fixed_amount') },
|
||||
]"
|
||||
:allow-empty="false"
|
||||
value-prop="id"
|
||||
label-prop="label"
|
||||
track-by="label"
|
||||
:searchable="false"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
v-if="currentTaxType.calculation_type === 'percentage'"
|
||||
:label="$t('tax_types.percent')"
|
||||
variant="horizontal"
|
||||
required
|
||||
>
|
||||
<BaseMoney
|
||||
v-model="currentTaxType.percent"
|
||||
:currency="{
|
||||
decimal: '.',
|
||||
thousands: ',',
|
||||
symbol: '% ',
|
||||
precision: 2,
|
||||
masked: false,
|
||||
}"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
v-else
|
||||
:label="$t('tax_types.fixed_amount')"
|
||||
variant="horizontal"
|
||||
required
|
||||
>
|
||||
<BaseMoney v-model="fixedAmount" :currency="defaultCurrency" />
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t('tax_types.description')"
|
||||
:error="
|
||||
v$.description.$error && v$.description.$errors[0].$message
|
||||
"
|
||||
variant="horizontal"
|
||||
>
|
||||
<BaseTextarea
|
||||
v-model="currentTaxType.description"
|
||||
:invalid="v$.description.$error"
|
||||
rows="4"
|
||||
cols="50"
|
||||
@input="v$.description.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
</BaseInputGrid>
|
||||
</div>
|
||||
<div
|
||||
class="z-0 flex justify-end p-4 border-t border-solid border-line-default"
|
||||
>
|
||||
<BaseButton
|
||||
class="mr-3 text-sm"
|
||||
variant="primary-outline"
|
||||
type="button"
|
||||
@click="closeTaxTypeModal"
|
||||
>
|
||||
{{ $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>
|
||||
{{ isEdit ? $t('general.update') : $t('general.save') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</form>
|
||||
</BaseModal>
|
||||
</template>
|
||||
Reference in New Issue
Block a user