v6 update

This commit is contained in:
Mohit Panjwani
2022-01-10 16:06:17 +05:30
parent b770e6277f
commit bdea879273
722 changed files with 19047 additions and 9186 deletions

View File

@ -0,0 +1,228 @@
<template>
<form class="relative" @submit.prevent="updateUserData">
<BaseSettingCard
:title="$t('settings.account_settings.account_settings')"
:description="$t('settings.account_settings.section_description')"
>
<BaseInputGrid>
<BaseInputGroup
:label="$tc('settings.account_settings.profile_picture')"
>
<BaseFileUploader
v-model="imgFiles"
:avatar="true"
accept="image/*"
@change="onFileInputChange"
@remove="onFileInputRemove"
/>
</BaseInputGroup>
<!-- Empty Column -->
<span></span>
<BaseInputGroup
:label="$tc('settings.account_settings.name')"
:error="v$.name.$error && v$.name.$errors[0].$message"
required
>
<BaseInput
v-model="userForm.name"
:invalid="v$.name.$error"
@input="v$.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$tc('settings.account_settings.email')"
:error="v$.email.$error && v$.email.$errors[0].$message"
required
>
<BaseInput
v-model="userForm.email"
:invalid="v$.email.$error"
@input="v$.email.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:error="v$.password.$error && v$.password.$errors[0].$message"
:label="$tc('settings.account_settings.password')"
>
<BaseInput
v-model="userForm.password"
type="password"
@input="v$.password.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$tc('settings.account_settings.confirm_password')"
:error="
v$.confirm_password.$error &&
v$.confirm_password.$errors[0].$message
"
>
<BaseInput
v-model="userForm.confirm_password"
type="password"
@input="v$.confirm_password.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup :label="$tc('settings.language')">
<BaseMultiselect
v-model="userForm.language"
:options="globalStore.config.languages"
label="name"
value-prop="code"
track-by="code"
open-direction="top"
/>
</BaseInputGroup>
</BaseInputGrid>
<BaseButton :loading="isSaving" :disabled="isSaving" class="mt-6">
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
name="SaveIcon"
:class="slotProps.class"
></BaseIcon>
</template>
{{ $tc('settings.company_info.save') }}
</BaseButton>
</BaseSettingCard>
</form>
</template>
<script setup>
import { ref, computed, reactive } from 'vue'
import { useGlobalStore } from '@/scripts/admin/stores/global'
import { useUserStore } from '@/scripts/admin/stores/user'
import { useI18n } from 'vue-i18n'
import {
helpers,
sameAs,
email,
required,
minLength,
} from '@vuelidate/validators'
import { useVuelidate } from '@vuelidate/core'
import { useCompanyStore } from '@/scripts/admin/stores/company'
const userStore = useUserStore()
const globalStore = useGlobalStore()
const companyStore = useCompanyStore()
const { t } = useI18n()
let isSaving = ref(false)
let avatarFileBlob = ref(null)
let imgFiles = ref([])
if (userStore.currentUser.avatar) {
imgFiles.value.push({
image: userStore.currentUser.avatar,
})
}
const rules = computed(() => {
return {
name: {
required: helpers.withMessage(t('validation.required'), required),
},
email: {
required: helpers.withMessage(t('validation.required'), required),
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
password: {
minLength: helpers.withMessage(
t('validation.password_length', { count: 8 }),
minLength(8)
),
},
confirm_password: {
sameAsPassword: helpers.withMessage(
t('validation.password_incorrect'),
sameAs(userForm.password)
),
},
}
})
const userForm = reactive({
name: userStore.currentUser.name,
email: userStore.currentUser.email,
language:
userStore.currentUserSettings.language ||
companyStore.selectedCompanySettings.language,
password: '',
confirm_password: '',
})
const v$ = useVuelidate(
rules,
computed(() => userForm)
)
function onFileInputChange(fileName, file) {
avatarFileBlob.value = file
}
function onFileInputRemove() {
avatarFileBlob.value = null
}
async function updateUserData() {
v$.value.$touch()
if (v$.value.$invalid) {
return true
}
isSaving.value = true
let data = {
name: userForm.name,
email: userForm.email,
}
try {
if (
userForm.password != null &&
userForm.password !== undefined &&
userForm.password !== ''
) {
data = { ...data, password: userForm.password }
}
// Update Language if changed
if (userStore.currentUserSettings.language !== userForm.language) {
await userStore.updateUserSettings({
settings: {
language: userForm.language,
},
})
}
let response = await userStore.updateCurrentUser(data)
if (response.data.data) {
isSaving.value = false
if (avatarFileBlob.value) {
let avatarData = new FormData()
avatarData.append('admin_avatar', avatarFileBlob.value)
await userStore.uploadAvatar(avatarData)
}
userForm.password = ''
userForm.confirm_password = ''
}
} catch (error) {
isSaving.value = false
return true
}
}
</script>

View File

@ -0,0 +1,230 @@
<template>
<BackupModal />
<BaseSettingCard
:title="$tc('settings.backup.title', 1)"
:description="$t('settings.backup.description')"
>
<template #action>
<BaseButton variant="primary-outline" @click="onCreateNewBackup">
<template #left="slotProps">
<BaseIcon :class="slotProps.class" name="PlusIcon" />
</template>
{{ $t('settings.backup.new_backup') }}
</BaseButton>
</template>
<div class="grid my-14 md:grid-cols-3">
<BaseInputGroup
:label="$t('settings.disk.select_disk')"
:content-loading="isFetchingInitialData"
>
<BaseMultiselect
v-model="filters.selected_disk"
:content-loading="isFetchingInitialData"
:options="getDisksOptions"
track-by="id"
:placeholder="$t('settings.disk.select_disk')"
label="name"
:searchable="true"
object
class="w-full"
value-prop="id"
@select="refreshTable"
>
</BaseMultiselect>
</BaseInputGroup>
</div>
<BaseTable
ref="table"
class="mt-10"
:show-filter="false"
:data="fetchBackupsData"
:columns="backupColumns"
>
<template #cell-actions="{ row }">
<BaseDropdown>
<template #activator>
<div class="inline-block">
<BaseIcon name="DotsHorizontalIcon" class="text-gray-500" />
</div>
</template>
<BaseDropdownItem @click="onDownloadBckup(row.data)">
<BaseIcon name="CloudDownloadIcon" class="mr-3 text-gray-600" />
{{ $t('general.download') }}
</BaseDropdownItem>
<BaseDropdownItem @click="onRemoveBackup(row.data)">
<BaseIcon name="TrashIcon" class="mr-3 text-gray-600" />
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</template>
</BaseTable>
</BaseSettingCard>
</template>
<script setup>
import { useBackupStore } from '@/scripts/admin/stores/backup'
import { computed, ref, reactive, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDiskStore } from '@/scripts/admin/stores/disk'
import { useDialogStore } from '@/scripts/stores/dialog'
import { useModalStore } from '@/scripts/stores/modal'
import BackupModal from '@/scripts/admin/components/modal-components/BackupModal.vue'
const dialogStore = useDialogStore()
const backupStore = useBackupStore()
const modalStore = useModalStore()
const diskStore = useDiskStore()
const { t } = useI18n()
const filters = reactive({
selected_disk: { driver: 'local' },
})
const table = ref('')
let isFetchingInitialData = ref(true)
const backupColumns = computed(() => {
return [
{
key: 'path',
label: t('settings.backup.path'),
thClass: 'extra',
tdClass: 'font-medium text-gray-900',
},
{
key: 'created_at',
label: t('settings.backup.created_at'),
tdClass: 'font-medium text-gray-900',
},
{
key: 'size',
label: t('settings.backup.size'),
tdClass: 'font-medium text-gray-900',
},
{
key: 'actions',
label: '',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
]
})
const getDisksOptions = computed(() => {
return diskStore.disks.map((disk) => {
return {
...disk,
name: disk.name + ' — ' + '[' + disk.driver + ']',
}
})
})
loadDisksData()
function onRemoveBackup(backup) {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('settings.backup.backup_confirm_delete'),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'danger',
hideNoButton: false,
size: 'lg',
})
.then(async (res) => {
if (res) {
let data = {
disk: filters.selected_disk.driver,
file_disk_id: filters.selected_disk.id,
path: backup.path,
}
let response = await backupStore.removeBackup(data)
if (response.data.success || response.data.backup) {
table.value && table.value.refresh()
return true
}
}
})
}
function refreshTable() {
setTimeout(() => {
table.value.refresh()
}, 100)
}
async function loadDisksData() {
isFetchingInitialData.value = true
let res = await diskStore.fetchDisks({ limit: 'all' })
if (res.data.error) {
}
filters.selected_disk = res.data.data.find((disk) => disk.set_as_default == 0)
isFetchingInitialData.value = false
}
async function fetchBackupsData({ page, filter, sort }) {
let data = {
disk: filters.selected_disk.driver,
filed_disk_id: filters.selected_disk.id,
}
isFetchingInitialData.value = true
let response = await backupStore.fetchBackups(data)
isFetchingInitialData.value = false
return {
data: response.data.backups,
pagination: {
totalPages: 1,
currentPage: 1,
},
}
}
async function onCreateNewBackup() {
modalStore.openModal({
title: t('settings.backup.create_backup'),
componentName: 'BackupModal',
refreshData: table.value && table.value.refresh,
size: 'sm',
})
}
async function onDownloadBckup(backup) {
isFetchingInitialData.value = true
window
.axios({
method: 'GET',
url: '/api/v1/download-backup',
responseType: 'blob',
params: {
disk: filters.selected_disk.driver,
file_disk_id: filters.selected_disk.id,
path: backup.path,
},
})
.then((response) => {
const url = window.URL.createObjectURL(new Blob([response.data]))
const link = document.createElement('a')
link.href = url
link.setAttribute('download', backup.path.split('/')[1])
document.body.appendChild(link)
link.click()
isFetchingInitialData.value = false
})
.catch((e) => {
isFetchingInitialData.value = false
})
}
</script>

View File

@ -0,0 +1,260 @@
<template>
<form @submit.prevent="updateCompanyData">
<BaseSettingCard
:title="$t('settings.company_info.company_info')"
:description="$t('settings.company_info.section_description')"
>
<BaseInputGrid class="mt-5">
<BaseInputGroup :label="$tc('settings.company_info.company_logo')">
<BaseFileUploader
v-model="previewLogo"
base64
@change="onFileInputChange"
@remove="onFileInputRemove"
/>
</BaseInputGroup>
</BaseInputGrid>
<BaseInputGrid class="mt-5">
<BaseInputGroup
:label="$tc('settings.company_info.company_name')"
:error="v$.name.$error && v$.name.$errors[0].$message"
required
>
<BaseInput
v-model="companyForm.name"
:invalid="v$.name.$error"
@blur="v$.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup :label="$tc('settings.company_info.phone')">
<BaseInput v-model="companyForm.address.phone" />
</BaseInputGroup>
<BaseInputGroup
:label="$tc('settings.company_info.country')"
:error="
v$.address.country_id.$error &&
v$.address.country_id.$errors[0].$message
"
required
>
<BaseMultiselect
v-model="companyForm.address.country_id"
label="name"
:invalid="v$.address.country_id.$error"
:options="globalStore.countries"
value-prop="id"
:can-deselect="true"
:can-clear="false"
searchable
track-by="name"
/>
</BaseInputGroup>
<BaseInputGroup :label="$tc('settings.company_info.state')">
<BaseInput
v-model="companyForm.address.state"
name="state"
type="text"
/>
</BaseInputGroup>
<BaseInputGroup :label="$tc('settings.company_info.city')">
<BaseInput v-model="companyForm.address.city" type="text" />
</BaseInputGroup>
<BaseInputGroup :label="$tc('settings.company_info.zip')">
<BaseInput v-model="companyForm.address.zip" />
</BaseInputGroup>
<div>
<BaseInputGroup :label="$tc('settings.company_info.address')">
<BaseTextarea
v-model="companyForm.address.address_street_1"
rows="2"
/>
</BaseInputGroup>
<BaseTextarea
v-model="companyForm.address.address_street_2"
rows="2"
:row="2"
class="mt-2"
/>
</div>
</BaseInputGrid>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
type="submit"
class="mt-6"
>
<template #left="slotProps">
<BaseIcon v-if="!isSaving" :class="slotProps.class" name="SaveIcon" />
</template>
{{ $tc('settings.company_info.save') }}
</BaseButton>
<div v-if="companyStore.companies.length !== 1" class="py-5">
<BaseDivider class="my-4" />
<h3 class="text-lg leading-6 font-medium text-gray-900">
{{ $tc('settings.company_info.delete_company') }}
</h3>
<div class="mt-2 max-w-xl text-sm text-gray-500">
<p>
{{ $tc('settings.company_info.delete_company_description') }}
</p>
</div>
<div class="mt-5">
<button
type="button"
class="
inline-flex
items-center
justify-center
px-4
py-2
border border-transparent
font-medium
rounded-md
text-red-700
bg-red-100
hover:bg-red-200
focus:outline-none
focus:ring-2
focus:ring-offset-2
focus:ring-red-500
sm:text-sm
"
@click="removeCompany"
>
{{ $tc('general.delete') }}
</button>
</div>
</div>
</BaseSettingCard>
</form>
<DeleteCompanyModal />
</template>
<script setup>
import { reactive, ref, inject, computed } from 'vue'
import { useGlobalStore } from '@/scripts/admin/stores/global'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import { useI18n } from 'vue-i18n'
import { required, minLength, helpers } from '@vuelidate/validators'
import { useVuelidate } from '@vuelidate/core'
import { useModalStore } from '@/scripts/stores/modal'
import DeleteCompanyModal from '@/scripts/admin/components/modal-components/DeleteCompanyModal.vue'
const companyStore = useCompanyStore()
const globalStore = useGlobalStore()
const modalStore = useModalStore()
const { t } = useI18n()
const utils = inject('utils')
let isSaving = ref(false)
const companyForm = reactive({
name: null,
logo: null,
address: {
address_street_1: '',
address_street_2: '',
website: '',
country_id: null,
state: '',
city: '',
phone: '',
zip: '',
},
})
utils.mergeSettings(companyForm, {
...companyStore.selectedCompany,
})
let previewLogo = ref([])
let logoFileBlob = ref(null)
let logoFileName = ref(null)
if (companyForm.logo) {
previewLogo.value.push({
image: companyForm.logo,
})
}
const rules = computed(() => {
return {
name: {
required: helpers.withMessage(t('validation.required'), required),
minLength: helpers.withMessage(
t('validation.name_min_length'),
minLength(3)
),
},
address: {
country_id: {
required: helpers.withMessage(t('validation.required'), required),
},
},
}
})
const v$ = useVuelidate(
rules,
computed(() => companyForm)
)
globalStore.fetchCountries()
function onFileInputChange(fileName, file, fileCount, fileList) {
logoFileName.value = fileList.name
logoFileBlob.value = file
}
function onFileInputRemove() {
logoFileBlob.value = null
}
async function updateCompanyData() {
v$.value.$touch()
if (v$.value.$invalid) {
return true
}
isSaving.value = true
const res = await companyStore.updateCompany(companyForm)
if (res.data.data) {
if (logoFileBlob.value) {
let logoData = new FormData()
logoData.append(
'company_logo',
JSON.stringify({
name: logoFileName.value,
data: logoFileBlob.value,
})
)
await companyStore.updateCompanyLogo(logoData)
}
isSaving.value = false
}
isSaving.value = false
}
function removeCompany(id) {
modalStore.openModal({
title: t('settings.company_info.are_you_absolutely_sure'),
componentName: 'DeleteCompanyModal',
size: 'sm',
})
}
</script>

View File

@ -0,0 +1,150 @@
<template>
<BaseSettingCard
:title="$t('settings.menu_title.custom_fields')"
:description="$t('settings.custom_fields.section_description')"
>
<template #action>
<BaseButton
v-if="userStore.hasAbilities(abilities.CREATE_CUSTOM_FIELDS)"
variant="primary-outline"
@click="addCustomField"
>
<template #left="slotProps">
<BaseIcon :class="slotProps.class" name="PlusIcon" />
{{ $t('settings.custom_fields.add_custom_field') }}
</template>
</BaseButton>
</template>
<CustomFieldModal />
<BaseTable
ref="table"
:data="fetchData"
:columns="customFieldsColumns"
class="mt-16"
>
<template #cell-name="{ row }">
{{ row.data.name }}
<span class="text-xs text-gray-500"> ({{ row.data.slug }})</span>
</template>
<template #cell-is_required="{ row }">
<BaseBadge
:bg-color="
utils.getBadgeStatusColor(row.data.is_required ? 'YES' : 'NO')
.bgColor
"
:color="
utils.getBadgeStatusColor(row.data.is_required ? 'YES' : 'NO').color
"
>
{{
row.data.is_required
? $t('settings.custom_fields.yes')
: $t('settings.custom_fields.no').replace('_', ' ')
}}
</BaseBadge>
</template>
<template
v-if="
userStore.hasAbilities([
abilities.DELETE_CUSTOM_FIELDS,
abilities.EDIT_CUSTOM_FIELDS,
])
"
#cell-actions="{ row }"
>
<CustomFieldDropdown
:row="row.data"
:table="table"
:load-data="refreshTable"
/>
</template>
</BaseTable>
</BaseSettingCard>
</template>
<script setup>
import { computed, ref, inject } from 'vue'
import { useI18n } from 'vue-i18n'
import { useModalStore } from '@/scripts/stores/modal'
import { useCustomFieldStore } from '@/scripts/admin/stores/custom-field'
import { useUserStore } from '@/scripts/admin/stores/user'
import CustomFieldDropdown from '@/scripts/admin/components/dropdowns/CustomFieldIndexDropdown.vue'
import CustomFieldModal from '@/scripts/admin/components/modal-components/custom-fields/CustomFieldModal.vue'
import abilities from '@/scripts/admin/stub/abilities'
const modalStore = useModalStore()
const customFieldStore = useCustomFieldStore()
const userStore = useUserStore()
const utils = inject('utils')
const { t } = useI18n()
const table = ref(null)
const customFieldsColumns = computed(() => {
return [
{
key: 'name',
label: t('settings.custom_fields.name'),
thClass: 'extra',
tdClass: 'font-medium text-gray-900',
},
{
key: 'model_type',
label: t('settings.custom_fields.model'),
},
{
key: 'type',
label: t('settings.custom_fields.type'),
},
{
key: 'is_required',
label: t('settings.custom_fields.required'),
},
{
key: 'actions',
label: '',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
]
})
async function fetchData({ page, filter, sort }) {
let data = {
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
}
let response = await customFieldStore.fetchCustomFields(data)
return {
data: response.data.data,
pagination: {
totalPages: response.data.meta.last_page,
currentPage: page,
limit: 5,
totalCount: response.data.meta.total,
},
}
}
function addCustomField() {
modalStore.openModal({
title: t('settings.custom_fields.add_custom_field'),
componentName: 'CustomFieldModal',
size: 'sm',
refreshData: table.value && table.value.refresh,
})
}
async function refreshTable() {
table.value && table.value.refresh()
}
</script>

View File

@ -0,0 +1,191 @@
<template>
<ExchangeRateProviderModal />
<BaseCard>
<div slot="header" class="flex flex-wrap justify-between lg:flex-nowrap">
<div>
<h6 class="text-lg font-medium text-left">
{{ $t('settings.menu_title.exchange_rate') }}
</h6>
<p
class="mt-2 text-sm leading-snug text-left text-gray-500"
style="max-width: 680px"
>
{{ $t('settings.exchange_rate.providers_description') }}
</p>
</div>
<div class="mt-4 lg:mt-0 lg:ml-2">
<BaseButton
variant="primary-outline"
size="lg"
@click="addExchangeRate"
>
<template #left="slotProps">
<PlusIcon :class="slotProps.class" />
</template>
{{ $t('settings.exchange_rate.new_driver') }}
</BaseButton>
</div>
</div>
<BaseTable ref="table" class="mt-16" :data="fetchData" :columns="drivers">
<template #cell-driver="{ row }">
<span class="capitalize">{{ row.data.driver.replace('_', ' ') }}</span>
</template>
<template #cell-active="{ row }">
<BaseBadge
:bg-color="
utils.getBadgeStatusColor(row.data.active ? 'YES' : 'NO').bgColor
"
:color="
utils.getBadgeStatusColor(row.data.active ? 'YES' : 'NO').color
"
>
{{ row.data.active ? 'YES' : 'NO' }}
</BaseBadge>
</template>
<template #cell-actions="{ row }">
<BaseDropdown>
<template #activator>
<div class="inline-block">
<DotsHorizontalIcon class="w-5 text-gray-500" />
</div>
</template>
<BaseDropdownItem @click="editExchangeRate(row.data.id)">
<PencilIcon class="h-5 mr-3 text-gray-600" />
{{ $t('general.edit') }}
</BaseDropdownItem>
<BaseDropdownItem @click="removeExchangeRate(row.data.id)">
<TrashIcon class="h-5 mr-3 text-gray-600" />
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</template>
</BaseTable>
</BaseCard>
</template>
<script setup>
import { useExchangeRateStore } from '@/scripts/admin/stores/exchange-rate'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import { useModalStore } from '@/scripts/stores/modal'
import { useDialogStore } from '@/scripts/stores/dialog'
import { SaveIcon } from '@heroicons/vue/outline'
import { ref, computed, inject, reactive } from 'vue'
import ExchangeRateProviderModal from '@/scripts/admin/components/modal-components/ExchangeRateProviderModal.vue'
import { useI18n } from 'vue-i18n'
import {
PlusIcon,
DotsHorizontalIcon,
PencilIcon,
TrashIcon,
} from '@heroicons/vue/outline'
import BaseTable from '@/scripts/components/base/base-table/BaseTable.vue'
// store
const { tm, t } = useI18n()
const companyStore = useCompanyStore()
const exchangeRateStore = useExchangeRateStore()
const modalStore = useModalStore()
const dialogStore = useDialogStore()
//created
// local state
let table = ref('')
const utils = inject('utils')
const drivers = computed(() => {
return [
{
key: 'driver',
label: t('settings.exchange_rate.driver'),
thClass: 'extra',
tdClass: 'font-medium text-gray-900',
},
{
key: 'key',
label: t('settings.exchange_rate.key'),
thClass: 'extra',
tdClass: 'font-medium text-gray-900',
},
{
key: 'active',
label: t('settings.exchange_rate.active'),
thClass: 'extra',
tdClass: 'font-medium text-gray-900',
},
{
key: 'actions',
label: '',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
]
})
async function fetchData({ page, sort }) {
let data = reactive({
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
})
let response = await exchangeRateStore.fetchProviders(data)
return {
data: response.data.data,
pagination: {
totalPages: response.data.meta.last_page,
currentPage: page,
totalCount: response.data.meta.total,
limit: 5,
},
}
}
async function updateRate() {
await exchangeRateStore.updateExchangeRate(
exchangeRateStore.currentExchangeRate.rate
)
}
function addExchangeRate() {
modalStore.openModal({
title: t('settings.exchange_rate.new_driver'),
componentName: 'ExchangeRateProviderModal',
size: 'md',
refreshData: table.value && table.value.refresh,
})
}
function editExchangeRate(data) {
exchangeRateStore.fetchProvider(data)
modalStore.openModal({
title: t('settings.exchange_rate.edit_driver'),
componentName: 'ExchangeRateProviderModal',
size: 'md',
data: data,
refreshData: table.value && table.value.refresh,
})
}
function removeExchangeRate(id) {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('settings.exchange_rate.exchange_rate_confirm_delete'),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'danger',
hideNoButton: false,
size: 'lg',
})
.then(async (res) => {
if (res) {
await exchangeRateStore.deleteExchangeRate(id)
table.value && table.value.refresh()
}
})
}
</script>

View File

@ -0,0 +1,117 @@
<template>
<CategoryModal />
<BaseSettingCard
:title="$t('settings.expense_category.title')"
:description="$t('settings.expense_category.description')"
>
<template #action>
<BaseButton
variant="primary-outline"
type="button"
@click="openCategoryModal"
>
<template #left="slotProps">
<BaseIcon :class="slotProps.class" name="PlusIcon" />
</template>
{{ $t('settings.expense_category.add_new_category') }}
</BaseButton>
</template>
<BaseTable
ref="table"
:data="fetchData"
:columns="ExpenseCategoryColumns"
class="mt-16"
>
<template #cell-description="{ row }">
<div class="w-64">
<p class="truncate">{{ row.data.description }}</p>
</div>
</template>
<template #cell-actions="{ row }">
<ExpenseCategoryDropdown
:row="row.data"
:table="table"
:load-data="refreshTable"
/>
</template>
</BaseTable>
</BaseSettingCard>
</template>
<script setup>
import { useDialogStore } from '@/scripts/stores/dialog'
import { useCategoryStore } from '@/scripts/admin/stores/category'
import { useModalStore } from '@/scripts/stores/modal'
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import ExpenseCategoryDropdown from '@/scripts/admin/components/dropdowns/ExpenseCategoryIndexDropdown.vue'
import CategoryModal from '@/scripts/admin/components/modal-components/CategoryModal.vue'
const categoryStore = useCategoryStore()
const dialogStore = useDialogStore()
const modalStore = useModalStore()
const { t } = useI18n()
const table = ref(null)
const ExpenseCategoryColumns = computed(() => {
return [
{
key: 'name',
label: t('settings.expense_category.category_name'),
thClass: 'extra',
tdClass: 'font-medium text-gray-900',
},
{
key: 'description',
label: t('settings.expense_category.category_description'),
thClass: 'extra',
tdClass: 'font-medium text-gray-900',
},
{
key: 'actions',
label: '',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
]
})
async function fetchData({ page, filter, sort }) {
let data = {
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
}
let response = await categoryStore.fetchCategories(data)
return {
data: response.data.data,
pagination: {
totalPages: response.data.meta.last_page,
currentPage: page,
totalCount: response.data.meta.total,
limit: 5,
},
}
}
function openCategoryModal() {
modalStore.openModal({
title: t('settings.expense_category.add_category'),
componentName: 'CategoryModal',
size: 'sm',
refreshData: table.value && table.value.refresh,
})
}
async function refreshTable() {
table.value && table.value.refresh()
}
</script>

View File

@ -0,0 +1,257 @@
<template>
<FileDiskModal />
<BaseSettingCard
:title="$tc('settings.disk.title', 1)"
:description="$t('settings.disk.description')"
>
<template #action>
<BaseButton variant="primary-outline" @click="openCreateDiskModal">
<template #left="slotProps">
<BaseIcon :class="slotProps.class" name="PlusIcon" />
</template>
{{ $t('settings.disk.new_disk') }}
</BaseButton>
</template>
<BaseTable
ref="table"
class="mt-16"
:data="fetchData"
:columns="fileDiskColumns"
>
<template #cell-set_as_default="{ row }">
<BaseBadge
:bg-color="
utils.getBadgeStatusColor(row.data.set_as_default ? 'YES' : 'NO')
.bgColor
"
:color="
utils.getBadgeStatusColor(row.data.set_as_default ? 'YES' : 'NO')
.color
"
>
{{ row.data.set_as_default ? 'Yes' : 'No'.replace('_', ' ') }}
</BaseBadge>
</template>
<template #cell-actions="{ row }">
<BaseDropdown v-if="isNotSystemDisk(row.data)">
<template #activator>
<div class="inline-block">
<BaseIcon name="DotsHorizontalIcon" class="text-gray-500" />
</div>
</template>
<BaseDropdownItem
v-if="!row.data.set_as_default"
@click="setDefaultDiskData(row.data.id)"
>
<BaseIcon class="mr-3 tetx-gray-600" name="CheckCircleIcon" />
{{ $t('settings.disk.set_default_disk') }}
</BaseDropdownItem>
<BaseDropdownItem
v-if="row.data.type !== 'SYSTEM'"
@click="openEditDiskModal(row.data)"
>
<BaseIcon name="PencilIcon" class="mr-3 text-gray-600" />
{{ $t('general.edit') }}
</BaseDropdownItem>
<BaseDropdownItem
v-if="row.data.type !== 'SYSTEM' && !row.data.set_as_default"
@click="removeDisk(row.data.id)"
>
<BaseIcon name="TrashIcon" class="mr-3 text-gray-600" />
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</template>
</BaseTable>
<BaseDivider class="mt-8 mb-2" />
<BaseSwitchSection
v-model="savePdfToDiskField"
:title="$t('settings.disk.save_pdf_to_disk')"
:description="$t('settings.disk.disk_setting_description')"
/>
</BaseSettingCard>
</template>
<script setup>
import { useDiskStore } from '@/scripts/admin/stores/disk'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import { useDialogStore } from '@/scripts/stores/dialog'
import { useModalStore } from '@/scripts/stores/modal'
import { ref, computed, reactive, onMounted, inject } from 'vue'
import { useI18n } from 'vue-i18n'
import FileDiskModal from '@/scripts/admin/components/modal-components/FileDiskModal.vue'
const utils = inject('utils')
const modelStore = useModalStore()
const diskStore = useDiskStore()
const companyStore = useCompanyStore()
const dialogStore = useDialogStore()
const { t } = useI18n()
let disk = 'local'
let loading = ref(false)
let table = ref('')
const fileDiskColumns = computed(() => {
return [
{
key: 'name',
label: t('settings.disk.disk_name'),
thClass: 'extra',
tdClass: 'font-medium text-gray-900',
},
{
key: 'driver',
label: t('settings.disk.filesystem_driver'),
thClass: 'extra',
tdClass: 'font-medium text-gray-900',
},
{
key: 'type',
label: t('settings.disk.disk_type'),
thClass: 'extra',
tdClass: 'font-medium text-gray-900',
},
{
key: 'set_as_default',
label: t('settings.disk.is_default'),
thClass: 'extra',
tdClass: 'font-medium text-gray-900',
},
{
key: 'actions',
label: '',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
]
})
const savePdfToDisk = ref(companyStore.selectedCompanySettings.save_pdf_to_disk)
const savePdfToDiskField = computed({
get: () => {
return savePdfToDisk.value === 'YES'
},
set: async (newValue) => {
const value = newValue ? 'YES' : 'NO'
let data = {
settings: {
save_pdf_to_disk: value,
},
}
savePdfToDisk.value = value
await companyStore.updateCompanySettings({
data,
message: 'general.setting_updated',
})
},
})
async function fetchData({ page, filter, sort }) {
let data = reactive({
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
})
let response = await diskStore.fetchDisks(data)
return {
data: response.data.data,
pagination: {
totalPages: response.data.meta.last_page,
currentPage: page,
totalCount: response.data.meta.total,
},
}
}
function isNotSystemDisk(disk) {
if (!disk.set_as_default) return true
if (disk.type == 'SYSTEM' && disk.set_as_default) return false
return true
}
function openCreateDiskModal() {
modelStore.openModal({
title: t('settings.disk.new_disk'),
componentName: 'FileDiskModal',
variant: 'lg',
refreshData: table.value && table.value.refresh,
})
}
function openEditDiskModal(data) {
modelStore.openModal({
title: t('settings.disk.edit_file_disk'),
componentName: 'FileDiskModal',
variant: 'lg',
id: data.id,
data: data,
refreshData: table.value && table.value.refresh,
})
}
function setDefaultDiskData(id) {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('settings.disk.set_default_disk_confirm'),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'primary',
hideNoButton: false,
size: 'lg',
})
.then(async (res) => {
if (res) {
loading.value = true
let data = reactive({
set_as_default: true,
id,
})
await diskStore.updateDisk(data).then(() => {
table.value && table.value.refresh()
})
}
})
}
function removeDisk(id) {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('settings.disk.confirm_delete'),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'danger',
hideNoButton: false,
size: 'lg',
})
.then(async (res) => {
if (res) {
let response = await diskStore.deleteFileDisk(id)
if (response.data.success) {
table.value && table.value.refresh()
return true
}
}
})
}
</script>

View File

@ -0,0 +1,93 @@
<template>
<MailTestModal />
<BaseSettingCard
:title="$t('settings.mail.mail_config')"
:description="$t('settings.mail.mail_config_desc')"
>
<div v-if="mailDriverStore && mailDriverStore.mailConfigData" class="mt-14">
<component
:is="mailDriver"
:config-data="mailDriverStore.mailConfigData"
:is-saving="isSaving"
:mail-drivers="mailDriverStore.mail_drivers"
:is-fetching-initial-data="isFetchingInitialData"
@on-change-driver="(val) => changeDriver(val)"
@submit-data="saveEmailConfig"
>
<BaseButton
variant="primary-outline"
type="button"
class="ml-2"
:content-loading="isFetchingInitialData"
@click="openMailTestModal"
>
{{ $t('general.test_mail_conf') }}
</BaseButton>
</component>
</div>
</BaseSettingCard>
</template>
<script setup>
import Smtp from '@/scripts/admin/views/settings/mail-driver/SmtpMailDriver.vue'
import Mailgun from '@/scripts/admin/views/settings/mail-driver/MailgunMailDriver.vue'
import Ses from '@/scripts/admin/views/settings/mail-driver/SesMailDriver.vue'
import Basic from '@/scripts/admin/views/settings/mail-driver/BasicMailDriver.vue'
import { ref, computed } from 'vue'
import { useMailDriverStore } from '@/scripts/admin/stores/mail-driver'
import { useModalStore } from '@/scripts/stores/modal'
import MailTestModal from '@/scripts/admin/components/modal-components/MailTestModal.vue'
import { useI18n } from 'vue-i18n'
let isSaving = ref(false)
let isFetchingInitialData = ref(false)
const mailDriverStore = useMailDriverStore()
const modalStore = useModalStore()
const { t } = useI18n()
loadData()
function changeDriver(value) {
mailDriverStore.mail_driver = value
mailDriverStore.mailConfigData.mail_driver = value
}
async function loadData() {
isFetchingInitialData.value = true
Promise.all([
await mailDriverStore.fetchMailDrivers(),
await mailDriverStore.fetchMailConfig(),
]).then(([res1]) => {
isFetchingInitialData.value = false
})
}
const mailDriver = computed(() => {
if (mailDriverStore.mail_driver == 'smtp') return Smtp
if (mailDriverStore.mail_driver == 'mailgun') return Mailgun
if (mailDriverStore.mail_driver == 'sendmail') return Basic
if (mailDriverStore.mail_driver == 'ses') return Ses
if (mailDriverStore.mail_driver == 'mail') return Basic
return Smtp
})
async function saveEmailConfig(value) {
try {
isSaving.value = true
await mailDriverStore.updateMailConfig(value)
isSaving.value = false
return true
} catch (e) {
console.error(e)
}
}
function openMailTestModal() {
modalStore.openModal({
title: t('general.test_mail_conf'),
componentName: 'MailTestModal',
size: 'sm',
})
}
</script>

View File

@ -0,0 +1,116 @@
<template>
<NoteModal />
<BaseSettingCard
:title="$t('settings.customization.notes.title')"
:description="$t('settings.customization.notes.description')"
>
<template #action>
<BaseButton
v-if="userStore.hasAbilities(abilities.MANAGE_NOTE)"
variant="primary-outline"
@click="openNoteSelectModal"
>
<template #left="slotProps">
<BaseIcon :class="slotProps.class" name="PlusIcon" />
</template>
{{ $t('settings.customization.notes.add_note') }}
</BaseButton>
</template>
<BaseTable
ref="table"
:data="fetchData"
:columns="notesColumns"
class="mt-14"
>
<template #cell-actions="{ row }">
<NoteDropdown
:row="row.data"
:table="table"
:load-data="refreshTable"
/>
</template>
</BaseTable>
</BaseSettingCard>
</template>
<script setup>
import { computed, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useModalStore } from '@/scripts/stores/modal'
import { useDialogStore } from '@/scripts/stores/dialog'
import { useNotesStore } from '@/scripts/admin/stores/note'
import { useNotificationStore } from '@/scripts/stores/notification'
import NoteDropdown from '@/scripts/admin/components/dropdowns/NoteIndexDropdown.vue'
import NoteModal from '@/scripts/admin/components/modal-components/NoteModal.vue'
import { useUserStore } from '@/scripts/admin/stores/user'
import abilities from '@/scripts/admin/stub/abilities'
const { t } = useI18n()
const modalStore = useModalStore()
const dialogStore = useDialogStore()
const noteStore = useNotesStore()
const notificationStore = useNotificationStore()
const userStore = useUserStore()
const table = ref('')
const notesColumns = computed(() => {
return [
{
key: 'name',
label: t('settings.customization.notes.name'),
thClass: 'extra',
tdClass: 'font-medium text-gray-900',
},
{
key: 'type',
label: t('settings.customization.notes.type'),
thClass: 'extra',
tdClass: 'font-medium text-gray-900',
},
{
key: 'actions',
label: '',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
]
})
async function fetchData({ page, filter, sort }) {
let data = reactive({
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
})
let response = await noteStore.fetchNotes(data)
return {
data: response.data.data,
pagination: {
totalPages: response.data.meta.last_page,
currentPage: page,
totalCount: response.data.meta.total,
limit: 5,
},
}
}
async function openNoteSelectModal() {
await modalStore.openModal({
title: t('settings.customization.notes.add_note'),
componentName: 'NoteModal',
size: 'md',
refreshData: table.value && table.value.refresh,
})
}
async function refreshTable() {
table.value && table.value.refresh()
}
</script>

View File

@ -0,0 +1,162 @@
<template>
<BaseSettingCard
:title="$t('settings.notification.title')"
:description="$t('settings.notification.description')"
>
<form action="" @submit.prevent="submitForm">
<div class="grid-cols-2 col-span-1 mt-14">
<BaseInputGroup
:error="
v$.notification_email.$error &&
v$.notification_email.$errors[0].$message
"
:label="$t('settings.notification.email')"
class="my-2"
required
>
<BaseInput
v-model.trim="settingsForm.notification_email"
:invalid="v$.notification_email.$error"
type="email"
@input="v$.notification_email.$touch()"
/>
</BaseInputGroup>
<BaseButton
:disabled="isSaving"
:loading="isSaving"
variant="primary"
type="submit"
class="mt-6"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
:class="slotProps.class"
name="SaveIcon"
/>
</template>
{{ $tc('settings.notification.save') }}
</BaseButton>
</div>
</form>
<BaseDivider class="mt-6 mb-2" />
<ul class="divide-y divide-gray-200">
<BaseSwitchSection
v-model="invoiceViewedField"
:title="$t('settings.notification.invoice_viewed')"
:description="$t('settings.notification.invoice_viewed_desc')"
/>
<BaseSwitchSection
v-model="estimateViewedField"
:title="$t('settings.notification.estimate_viewed')"
:description="$t('settings.notification.estimate_viewed_desc')"
/>
</ul>
</BaseSettingCard>
</template>
<script setup>
import { ref, onMounted, computed, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { required, email, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useCompanyStore } from '@/scripts/admin/stores/company'
const companyStore = useCompanyStore()
let isSaving = ref(false)
const { t } = useI18n()
const settingsForm = reactive({
notify_invoice_viewed:
companyStore.selectedCompanySettings.notify_invoice_viewed,
notify_estimate_viewed:
companyStore.selectedCompanySettings.notify_estimate_viewed,
notification_email: companyStore.selectedCompanySettings.notification_email,
})
const rules = computed(() => {
return {
notification_email: {
required: helpers.withMessage(t('validation.required'), required),
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
}
})
const v$ = useVuelidate(
rules,
computed(() => settingsForm)
)
const invoiceViewedField = computed({
get: () => {
return settingsForm.notify_invoice_viewed === 'YES'
},
set: async (newValue) => {
const value = newValue ? 'YES' : 'NO'
let data = {
settings: {
notify_invoice_viewed: value,
},
}
settingsForm.notify_invoice_viewed = value
await companyStore.updateCompanySettings({
data,
message: 'general.setting_updated',
})
},
})
const estimateViewedField = computed({
get: () => {
return settingsForm.notify_estimate_viewed === 'YES'
},
set: async (newValue) => {
const value = newValue ? 'YES' : 'NO'
let data = {
settings: {
notify_estimate_viewed: value,
},
}
settingsForm.notify_estimate_viewed = value
await companyStore.updateCompanySettings({
data,
message: 'general.setting_updated',
})
},
})
async function submitForm() {
v$.value.$touch()
if (v$.value.$invalid) {
return true
}
isSaving.value = true
const data = {
settings: {
notification_email: settingsForm.notification_email,
},
}
await companyStore.updateCompanySettings({
data,
message: 'settings.notification.email_save_message',
})
isSaving.value = false
}
</script>

View File

@ -0,0 +1,104 @@
<template>
<PaymentModeModal />
<BaseSettingCard
:title="$t('settings.payment_modes.title')"
:description="$t('settings.payment_modes.description')"
>
<template #action>
<BaseButton
type="submit"
variant="primary-outline"
@click="addPaymentMode"
>
<template #left="slotProps">
<BaseIcon :class="slotProps.class" name="PlusIcon" />
</template>
{{ $t('settings.payment_modes.add_payment_mode') }}
</BaseButton>
</template>
<BaseTable
ref="table"
:data="fetchData"
:columns="paymentColumns"
class="mt-16"
>
<template #cell-actions="{ row }">
<PaymentModeDropdown
:row="row.data"
:table="table"
:load-data="refreshTable"
/>
</template>
</BaseTable>
</BaseSettingCard>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { usePaymentStore } from '@/scripts/admin/stores/payment'
import { useDialogStore } from '@/scripts/stores/dialog'
import { useModalStore } from '@/scripts/stores/modal'
import PaymentModeModal from '@/scripts/admin/components/modal-components/PaymentModeModal.vue'
import PaymentModeDropdown from '@/scripts/admin/components/dropdowns/PaymentModeIndexDropdown.vue'
const modalStore = useModalStore()
const dialogStore = useDialogStore()
const paymentStore = usePaymentStore()
const { t } = useI18n()
const table = ref(null)
const paymentColumns = computed(() => {
return [
{
key: 'name',
label: t('settings.payment_modes.mode_name'),
thClass: 'extra',
tdClass: 'font-medium text-gray-900',
},
{
key: 'actions',
label: '',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
]
})
async function refreshTable() {
table.value && table.value.refresh()
}
async function fetchData({ page, filter, sort }) {
let data = {
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
}
let response = await paymentStore.fetchPaymentModes(data)
return {
data: response.data.data,
pagination: {
totalPages: response.data.meta.last_page,
currentPage: page,
totalCount: response.data.meta.total,
limit: 5,
},
}
}
function addPaymentMode() {
modalStore.openModal({
title: t('settings.payment_modes.add_payment_mode'),
componentName: 'PaymentModeModal',
refreshData: table.value && table.value.refresh,
size: 'sm',
})
}
</script>

View File

@ -0,0 +1,331 @@
<template>
<form action="" class="relative" @submit.prevent="updatePreferencesData">
<BaseSettingCard
:title="$t('settings.menu_title.preferences')"
:description="$t('settings.preferences.general_settings')"
>
<BaseInputGrid class="mt-5">
<BaseInputGroup
:content-loading="isFetchingInitialData"
:label="$tc('settings.preferences.currency')"
:help-text="$t('settings.preferences.company_currency_unchangeable')"
:error="v$.currency.$error && v$.currency.$errors[0].$message"
required
>
<BaseMultiselect
v-model="settingsForm.currency"
:content-loading="isFetchingInitialData"
:options="globalStore.currencies"
label="name"
value-prop="id"
:searchable="true"
track-by="name"
:invalid="v$.currency.$error"
disabled
class="w-full"
>
</BaseMultiselect>
</BaseInputGroup>
<BaseInputGroup
:label="$tc('settings.preferences.default_language')"
:content-loading="isFetchingInitialData"
:error="v$.language.$error && v$.language.$errors[0].$message"
required
>
<BaseMultiselect
v-model="settingsForm.language"
:content-loading="isFetchingInitialData"
:options="globalStore.config.languages"
label="name"
value-prop="code"
class="w-full"
track-by="code"
:searchable="true"
:invalid="v$.language.$error"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$tc('settings.preferences.time_zone')"
:content-loading="isFetchingInitialData"
:error="v$.time_zone.$error && v$.time_zone.$errors[0].$message"
required
>
<BaseMultiselect
v-model="settingsForm.time_zone"
:content-loading="isFetchingInitialData"
:options="globalStore.timeZones"
label="key"
value-prop="value"
track-by="key"
:searchable="true"
:invalid="v$.time_zone.$error"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$tc('settings.preferences.date_format')"
:content-loading="isFetchingInitialData"
:error="
v$.carbon_date_format.$error &&
v$.carbon_date_format.$errors[0].$message
"
required
>
<BaseMultiselect
v-model="settingsForm.carbon_date_format"
:content-loading="isFetchingInitialData"
:options="globalStore.dateFormats"
label="display_date"
value-prop="carbon_format_value"
track-by="carbon_format_value"
searchable
:invalid="v$.carbon_date_format.$error"
class="w-full"
/>
</BaseInputGroup>
<BaseInputGroup
:content-loading="isFetchingInitialData"
:error="v$.fiscal_year.$error && v$.fiscal_year.$errors[0].$message"
:label="$tc('settings.preferences.fiscal_year')"
required
>
<BaseMultiselect
v-model="settingsForm.fiscal_year"
:content-loading="isFetchingInitialData"
:options="globalStore.config.fiscal_years"
label="key"
value-prop="value"
:invalid="v$.fiscal_year.$error"
track-by="key"
:searchable="true"
class="w-full"
/>
</BaseInputGroup>
</BaseInputGrid>
<BaseButton
:content-loading="isFetchingInitialData"
:disabled="isSaving"
:loading="isSaving"
type="submit"
class="mt-6"
>
<template #left="slotProps">
<BaseIcon name="SaveIcon" :class="slotProps.class" />
</template>
{{ $tc('settings.company_info.save') }}
</BaseButton>
<BaseDivider class="mt-6 mb-2" />
<ul>
<form @submit.prevent="submitData">
<BaseSwitchSection
v-model="expirePdfField"
:title="$t('settings.preferences.expire_public_links')"
:description="$t('settings.preferences.expire_setting_description')"
/>
<!--pdf_link_expiry_days -->
<BaseInputGroup
v-if="expirePdfField"
:content-loading="isFetchingInitialData"
:label="$t('settings.preferences.expire_public_links')"
class="mt-2 mb-4"
>
<BaseInput
v-model="settingsForm.link_expiry_days"
:disabled="
settingsForm.automatically_expire_public_links === 'NO'
"
:content-loading="isFetchingInitialData"
type="number"
/>
</BaseInputGroup>
<BaseButton
:content-loading="isFetchingInitialData"
:disabled="isDataSaving"
:loading="isDataSaving"
type="submit"
class="mt-6"
>
<template #left="slotProps">
<BaseIcon name="SaveIcon" :class="slotProps.class" />
</template>
{{ $tc('general.save') }}
</BaseButton>
</form>
<BaseDivider class="mt-6 mb-2" />
<BaseSwitchSection
v-model="discountPerItemField"
:title="$t('settings.preferences.discount_per_item')"
:description="$t('settings.preferences.discount_setting_description')"
/>
</ul>
</BaseSettingCard>
</form>
</template>
<script setup>
import { ref, computed, watch, reactive } from 'vue'
import { useGlobalStore } from '@/scripts/admin/stores/global'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import { useI18n } from 'vue-i18n'
import { required, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
const companyStore = useCompanyStore()
const globalStore = useGlobalStore()
const { t, tm } = useI18n()
let isSaving = ref(false)
let isDataSaving = ref(false)
let isFetchingInitialData = ref(false)
const settingsForm = reactive({ ...companyStore.selectedCompanySettings })
const retrospectiveEditOptions = computed(() => {
return globalStore.config.retrospective_edits.map((option) => {
option.title = t(option.key)
return option
})
})
watch(
() => settingsForm.carbon_date_format,
(val) => {
if (val) {
const dateFormatObject = globalStore.dateFormats.find((d) => {
return d.carbon_format_value === val
})
settingsForm.moment_date_format = dateFormatObject.moment_format_value
}
}
)
const discountPerItemField = computed({
get: () => {
return settingsForm.discount_per_item === 'YES'
},
set: async (newValue) => {
const value = newValue ? 'YES' : 'NO'
let data = {
settings: {
discount_per_item: value,
},
}
settingsForm.discount_per_item = value
await companyStore.updateCompanySettings({
data,
message: 'general.setting_updated',
})
},
})
const expirePdfField = computed({
get: () => {
return settingsForm.automatically_expire_public_links === 'YES'
},
set: async (newValue) => {
const value = newValue ? 'YES' : 'NO'
let data = {
settings: {
automatically_expire_public_links: value,
},
}
settingsForm.automatically_expire_public_links = value
},
})
const rules = computed(() => {
return {
currency: {
required: helpers.withMessage(t('validation.required'), required),
},
language: {
required: helpers.withMessage(t('validation.required'), required),
},
carbon_date_format: {
required: helpers.withMessage(t('validation.required'), required),
},
moment_date_format: {
required: helpers.withMessage(t('validation.required'), required),
},
time_zone: {
required: helpers.withMessage(t('validation.required'), required),
},
fiscal_year: {
required: helpers.withMessage(t('validation.required'), required),
},
}
})
const v$ = useVuelidate(
rules,
computed(() => settingsForm)
)
setInitialData()
async function setInitialData() {
isFetchingInitialData.value = true
Promise.all([
globalStore.fetchCurrencies(),
globalStore.fetchDateFormats(),
globalStore.fetchTimeZones(),
]).then(([res1]) => {
isFetchingInitialData.value = false
})
}
async function updatePreferencesData() {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
let data = {
settings: {
...settingsForm,
},
}
isSaving.value = true
delete data.settings.link_expiry_days
let res = await companyStore.updateCompanySettings({
data: data,
message: 'settings.preferences.updated_message',
})
isSaving.value = false
}
async function submitData() {
isDataSaving.value = true
let res = await companyStore.updateCompanySettings({
data: {
settings: {
link_expiry_days: settingsForm.link_expiry_days,
automatically_expire_public_links:
settingsForm.automatically_expire_public_links,
},
},
message: 'settings.preferences.updated_message',
})
isDataSaving.value = false
}
</script>

View File

@ -0,0 +1,112 @@
<template>
<RolesModal />
<BaseSettingCard
:title="$t('settings.roles.title')"
:description="$t('settings.roles.description')"
>
<template v-if="userStore.currentUser.is_owner" #action>
<BaseButton variant="primary-outline" @click="openRoleModal">
<template #left="slotProps">
<BaseIcon name="PlusIcon" :class="slotProps.class" />
</template>
{{ $t('settings.roles.add_new_role') }}
</BaseButton>
</template>
<BaseTable
ref="table"
:data="fetchData"
:columns="roleColumns"
class="mt-14"
>
<!-- Added on -->
<template #cell-created_at="{ row }">
{{ row.data.formatted_created_at }}
</template>
<template #cell-actions="{ row }">
<RoleDropdown
v-if="
userStore.currentUser.is_owner && row.data.name !== 'super admin'
"
:row="row.data"
:table="table"
:load-data="refreshTable"
/>
</template>
</BaseTable>
</BaseSettingCard>
</template>
<script setup>
import RoleDropdown from '@/scripts/admin/components/dropdowns/RoleIndexDropdown.vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoleStore } from '@/scripts/admin/stores/role'
import { useModalStore } from '@/scripts/stores/modal'
import { useUserStore } from '@/scripts/admin/stores/user'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import RolesModal from '@/scripts/admin/components/modal-components/RolesModal.vue'
import abilities from '@/scripts/admin/stub/abilities'
const modalStore = useModalStore()
const roleStore = useRoleStore()
const userStore = useUserStore()
const companyStore = useCompanyStore()
const { t } = useI18n()
const table = ref(null)
const roleColumns = computed(() => {
return [
{
key: 'name',
label: t('settings.roles.role_name'),
thClass: 'extra',
tdClass: 'font-medium text-gray-900',
},
{
key: 'created_at',
label: t('settings.roles.added_on'),
tdClass: 'font-medium text-gray-900',
},
{
key: 'actions',
label: '',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
]
})
async function fetchData({ page, filter, sort }) {
let data = {
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
company_id: companyStore.selectedCompany.id,
}
let response = await roleStore.fetchRoles(data)
return {
data: response.data.data,
}
}
async function refreshTable() {
table.value && table.value.refresh()
}
async function openRoleModal() {
await roleStore.fetchAbilities()
modalStore.openModal({
title: t('settings.roles.add_role'),
componentName: 'RolesModal',
size: 'lg',
refreshData: table.value && table.value.refresh,
})
}
</script>

View File

@ -0,0 +1,95 @@
<template>
<BasePage>
<BasePageHeader :title="$tc('settings.setting', 1)" class="mb-6">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="/admin/dashboard" />
<BaseBreadcrumbItem
:title="$tc('settings.setting', 2)"
to="/admin/settings/account-settings"
active
/>
</BaseBreadcrumb>
</BasePageHeader>
<div class="w-full mb-6 select-wrapper xl:hidden">
<BaseMultiselect
v-model="currentSetting"
:options="dropdownMenuItems"
:can-deselect="false"
value-prop="title"
track-by="title"
label="title"
object
@update:modelValue="navigateToSetting"
/>
</div>
<div class="flex">
<div class="hidden mt-1 xl:block min-w-[240px]">
<BaseList>
<BaseListItem
v-for="(menuItem, index) in globalStore.settingMenu"
:key="index"
:title="$t(menuItem.title)"
:to="menuItem.link"
:active="hasActiveUrl(menuItem.link)"
:index="index"
class="py-3"
>
<template #icon>
<BaseIcon :name="menuItem.icon"></BaseIcon>
</template>
</BaseListItem>
</BaseList>
</div>
<div class="w-full overflow-hidden">
<RouterView />
</div>
</div>
</BasePage>
</template>
<script setup>
import { ref, reactive, watchEffect, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useGlobalStore } from '@/scripts/admin/stores/global'
import BaseList from '@/scripts/components/list/BaseList.vue'
import BaseListItem from '@/scripts/components/list/BaseListItem.vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
let currentSetting = ref({})
const globalStore = useGlobalStore()
const route = useRoute()
const router = useRouter()
const dropdownMenuItems = computed(() => {
return globalStore.settingMenu.map((item) => {
return Object.assign({}, item, {
title: t(item.title),
})
})
})
watchEffect(() => {
if (route.path === '/admin/settings') {
router.push('/admin/settings/account-settings')
}
const item = dropdownMenuItems.value.find((item) => {
return item.link === route.path
})
currentSetting.value = item
})
function hasActiveUrl(url) {
return route.path.indexOf(url) > -1
}
function navigateToSetting(setting) {
return router.push(setting.link)
}
</script>

View File

@ -0,0 +1,182 @@
<template>
<BaseSettingCard
:title="$t('settings.tax_types.title')"
:description="$t('settings.tax_types.description')"
>
<TaxTypeModal />
<template v-if="userStore.hasAbilities(abilities.CREATE_TAX_TYPE)" #action>
<BaseButton type="submit" variant="primary-outline" @click="openTaxModal">
<template #left="slotProps">
<BaseIcon :class="slotProps.class" name="PlusIcon" />
</template>
{{ $t('settings.tax_types.add_new_tax') }}
</BaseButton>
</template>
<BaseTable
ref="table"
class="mt-16"
:data="fetchData"
:columns="taxTypeColumns"
>
<template #cell-compound_tax="{ row }">
<BaseBadge
:bg-color="
utils.getBadgeStatusColor(row.data.compound_tax ? 'YES' : 'NO')
.bgColor
"
:color="
utils.getBadgeStatusColor(row.data.compound_tax ? 'YES' : 'NO')
.color
"
>
{{ row.data.compound_tax ? 'Yes' : 'No'.replace('_', ' ') }}
</BaseBadge>
</template>
<template #cell-percent="{ row }"> {{ row.data.percent }} % </template>
<template v-if="hasAtleastOneAbility()" #cell-actions="{ row }">
<TaxTypeDropdown
:row="row.data"
:table="table"
:load-data="refreshTable"
/>
</template>
</BaseTable>
<div v-if="userStore.currentUser.is_owner">
<BaseDivider class="mt-8 mb-2" />
<BaseSwitchSection
v-model="taxPerItemField"
:disabled="salesTaxEnabled"
:title="$t('settings.tax_types.tax_per_item')"
:description="$t('settings.tax_types.tax_setting_description')"
/>
</div>
</BaseSettingCard>
</template>
<script setup>
import { useTaxTypeStore } from '@/scripts/admin/stores/tax-type'
import { useModalStore } from '@/scripts/stores/modal'
import { computed, reactive, ref, inject } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import { useUserStore } from '@/scripts/admin/stores/user'
import { useModuleStore } from '@/scripts/admin/stores/module'
import TaxTypeDropdown from '@/scripts/admin/components/dropdowns/TaxTypeIndexDropdown.vue'
import TaxTypeModal from '@/scripts/admin/components/modal-components/TaxTypeModal.vue'
import abilities from '@/scripts/admin/stub/abilities'
const { t } = useI18n()
const utils = inject('utils')
const companyStore = useCompanyStore()
const taxTypeStore = useTaxTypeStore()
const modalStore = useModalStore()
const userStore = useUserStore()
const moduleStore = useModuleStore()
const table = ref(null)
const taxPerItemSetting = ref(companyStore.selectedCompanySettings.tax_per_item)
const taxTypeColumns = computed(() => {
return [
{
key: 'name',
label: t('settings.tax_types.tax_name'),
thClass: 'extra',
tdClass: 'font-medium text-gray-900',
},
{
key: 'compound_tax',
label: t('settings.tax_types.compound_tax'),
tdClass: 'font-medium text-gray-900',
},
{
key: 'percent',
label: t('settings.tax_types.percent'),
thClass: 'extra',
tdClass: 'font-medium text-gray-900',
},
{
key: 'actions',
label: '',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
]
})
const salesTaxEnabled = computed(() => {
return (
companyStore.selectedCompanySettings.sales_tax_us_enabled === 'YES' &&
moduleStore.salesTaxUSEnabled
)
})
const taxPerItemField = computed({
get: () => {
return taxPerItemSetting.value === 'YES'
},
set: async (newValue) => {
const value = newValue ? 'YES' : 'NO'
let data = {
settings: {
tax_per_item: value,
},
}
taxPerItemSetting.value = value
await companyStore.updateCompanySettings({
data,
message: 'general.setting_updated',
})
},
})
function hasAtleastOneAbility() {
return userStore.hasAbilities([
abilities.DELETE_TAX_TYPE,
abilities.EDIT_TAX_TYPE,
])
}
async function fetchData({ page, filter, sort }) {
let data = {
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
}
let response = await taxTypeStore.fetchTaxTypes(data)
return {
data: response.data.data,
pagination: {
totalPages: response.data.meta.last_page,
currentPage: page,
totalCount: response.data.meta.total,
limit: 5,
},
}
}
async function refreshTable() {
table.value && table.value.refresh()
}
function openTaxModal() {
modalStore.openModal({
title: t('settings.tax_types.add_tax'),
componentName: 'TaxTypeModal',
size: 'sm',
refreshData: table.value && table.value.refresh,
})
}
</script>

View File

@ -0,0 +1,436 @@
<template>
<BaseSettingCard
:title="$t('settings.update_app.title')"
:description="$t('settings.update_app.description')"
>
<div class="pb-8 ml-0">
<label class="text-sm not-italic font-medium input-label">
{{ $t('settings.update_app.current_version') }}
</label>
<div
class="
box-border
flex
w-16
p-3
my-2
text-sm text-gray-600
bg-gray-200
border border-gray-200 border-solid
rounded-md
version
"
>
{{ currentVersion }}
</div>
<BaseButton
:loading="isCheckingforUpdate"
:disabled="isCheckingforUpdate || isUpdating"
variant="primary-outline"
class="mt-6"
@click="checkUpdate"
>
{{ $t('settings.update_app.check_update') }}
</BaseButton>
<BaseDivider v-if="isUpdateAvailable" class="mt-6 mb-4" />
<div v-show="!isUpdating" v-if="isUpdateAvailable" class="mt-4 content">
<BaseHeading type="heading-title" class="mb-2">
{{ $t('settings.update_app.avail_update') }}
</BaseHeading>
<div class="rounded-md bg-primary-50 p-4 mb-3">
<div class="flex">
<div class="shrink-0">
<BaseIcon
name="InformationCircleIcon"
class="h-5 w-5 text-primary-400"
aria-hidden="true"
/>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-primary-800">
{{ $t('general.note') }}
</h3>
<div class="mt-2 text-sm text-primary-700">
<p>
{{ $t('settings.update_app.update_warning') }}
</p>
</div>
</div>
</div>
</div>
<label class="text-sm not-italic font-medium input-label">
{{ $t('settings.update_app.next_version') }}
</label>
<br />
<div
class="
box-border
flex
w-16
p-3
my-2
text-sm text-gray-600
bg-gray-200
border border-gray-200 border-solid
rounded-md
version
"
>
{{ updateData.version }}
</div>
<div
class="
pl-5
mt-4
mb-8
text-sm
leading-snug
text-gray-500
update-description
"
style="white-space: pre-wrap; max-width: 480px"
v-html="description"
></div>
<label class="text-sm not-italic font-medium input-label">
{{ $t('settings.update_app.requirements') }}
</label>
<table class="w-1/2 mt-2 border-2 border-gray-200 BaseTable-fixed">
<tr
v-for="(ext, i) in requiredExtentions"
:key="i"
class="p-2 border-2 border-gray-200"
>
<td width="70%" class="p-2 text-sm truncate">
{{ i }}
</td>
<td width="30%" class="p-2 text-sm text-right">
<span
v-if="ext"
class="inline-block w-4 h-4 ml-3 mr-2 bg-green-500 rounded-full"
/>
<span
v-else
class="inline-block w-4 h-4 ml-3 mr-2 bg-red-500 rounded-full"
/>
</td>
</tr>
</table>
<BaseButton class="mt-10" variant="primary" @click="onUpdateApp">
{{ $t('settings.update_app.update') }}
</BaseButton>
</div>
<div v-if="isUpdating" class="relative flex justify-between mt-4 content">
<div>
<h6 class="m-0 mb-3 font-medium sw-section-title">
{{ $t('settings.update_app.update_progress') }}
</h6>
<p
class="mb-8 text-sm leading-snug text-gray-500"
style="max-width: 480px"
>
{{ $t('settings.update_app.progress_text') }}
</p>
</div>
<LoadingIcon
class="absolute right-0 h-6 m-1 animate-spin text-primary-400"
/>
</div>
<ul v-if="isUpdating" class="w-full p-0 list-none">
<li
v-for="step in updateSteps"
:key="step.stepUrl"
class="
flex
justify-between
w-full
py-3
border-b border-gray-200 border-solid
last:border-b-0
"
>
<p class="m-0 text-sm leading-8">{{ $t(step.translationKey) }}</p>
<div class="flex flex-row items-center">
<span v-if="step.time" class="mr-3 text-xs text-gray-500">
{{ step.time }}
</span>
<span
:class="statusClass(step)"
class="block py-1 text-sm text-center uppercase rounded-full"
style="width: 88px"
>
{{ getStatus(step) }}
</span>
</div>
</li>
</ul>
</div>
</BaseSettingCard>
</template>
<script setup>
import { useNotificationStore } from '@/scripts/stores/notification'
import axios from 'axios'
import LoadingIcon from '@/scripts/components/icons/LoadingIcon.vue'
import { reactive, ref, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { handleError } from '@/scripts/helpers/error-handling'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import { useExchangeRateStore } from '@/scripts/admin/stores/exchange-rate'
import { useDialogStore } from '@/scripts/stores/dialog'
const notificationStore = useNotificationStore()
const dialogStore = useDialogStore()
const { t, tm } = useI18n()
const comapnyStore = useCompanyStore()
const exchangeRateStore = useExchangeRateStore()
let isUpdateAvailable = ref(false)
let isCheckingforUpdate = ref(false)
let description = ref('')
let currentVersion = ref('')
let requiredExtentions = ref(null)
let deletedFiles = ref(null)
let isUpdating = ref(false)
const updateSteps = reactive([
{
translationKey: 'settings.update_app.download_zip_file',
stepUrl: '/api/v1/update/download',
time: null,
started: false,
completed: false,
},
{
translationKey: 'settings.update_app.unzipping_package',
stepUrl: '/api/v1/update/unzip',
time: null,
started: false,
completed: false,
},
{
translationKey: 'settings.update_app.copying_files',
stepUrl: '/api/v1/update/copy',
time: null,
started: false,
completed: false,
},
{
translationKey: 'settings.update_app.deleting_files',
stepUrl: '/api/v1/update/delete',
time: null,
started: false,
completed: false,
},
{
translationKey: 'settings.update_app.running_migrations',
stepUrl: '/api/v1/update/migrate',
time: null,
started: false,
completed: false,
},
{
translationKey: 'settings.update_app.finishing_update',
stepUrl: '/api/v1/update/finish',
time: null,
started: false,
completed: false,
},
])
const updateData = reactive({
isMinor: Boolean,
installed: '',
version: '',
})
let minPhpVesrion = ref(null)
window.addEventListener('beforeunload', (event) => {
if (isUpdating.value) {
event.returnValue = 'Update is in progress!'
}
})
// Created
axios.get('/api/v1/app/version').then((res) => {
currentVersion.value = res.data.version
})
// comapnyStore
// .fetchCompanySettings(['bulk_exchange_rate_configured'])
// .then((res) => {
// isExchangeRateUpdated.value =
// res.data.bulk_exchange_rate_configured === 'YES'
// })
// Comuted props
const allowToUpdate = computed(() => {
if (requiredExtentions.value !== null) {
return Object.keys(requiredExtentions.value).every((k) => {
return requiredExtentions.value[k]
})
}
return true
})
function statusClass(step) {
const status = getStatus(step)
switch (status) {
case 'pending':
return 'text-primary-800 bg-gray-200'
case 'finished':
return 'text-teal-500 bg-teal-100'
case 'running':
return 'text-blue-400 bg-blue-100'
case 'error':
return 'text-danger bg-red-200'
default:
return ''
}
}
async function checkUpdate() {
try {
isCheckingforUpdate.value = true
let response = await axios.get('/api/v1/check/update')
isCheckingforUpdate.value = false
if (!response.data.version) {
notificationStore.showNotification({
title: 'Info!',
type: 'info',
message: t('settings.update_app.latest_message'),
})
return
}
if (response.data) {
updateData.isMinor = response.data.is_minor
updateData.version = response.data.version.version
description.value = response.data.version.description
requiredExtentions.value = response.data.version.extensions
isUpdateAvailable.value = true
minPhpVesrion.value = response.data.version.minimum_php_version
deletedFiles.value = response.data.version.deleted_files
}
} catch (e) {
isUpdateAvailable.value = false
isCheckingforUpdate.value = false
handleError(e)
}
}
function onUpdateApp() {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('settings.update_app.update_warning'),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'danger',
hideNoButton: false,
size: 'lg',
})
.then(async (res) => {
if (res) {
let path = null
if (!allowToUpdate.value) {
notificationStore.showNotification({
type: 'error',
message:
'Your current configuration does not match the update requirements. Please try again after all the requirements are fulfilled.',
})
return true
}
for (let index = 0; index < updateSteps.length; index++) {
let currentStep = updateSteps[index]
try {
isUpdating.value = true
currentStep.started = true
let updateParams = {
version: updateData.version,
installed: currentVersion.value,
deleted_files: deletedFiles.value,
path: path || null,
}
let requestResponse = await axios.post(
currentStep.stepUrl,
updateParams
)
currentStep.completed = true
if (requestResponse.data && requestResponse.data.path) {
path = requestResponse.data.path
}
// on finish
if (
currentStep.translationKey ==
'settings.update_app.finishing_update'
) {
isUpdating.value = false
notificationStore.showNotification({
type: 'success',
message: t('settings.update_app.update_success'),
})
setTimeout(() => {
location.reload()
}, 3000)
}
} catch (error) {
currentStep.started = false
currentStep.completed = true
handleError(error)
onUpdateFailed(currentStep.translationKey)
return false
}
}
}
})
}
function onUpdateFailed(translationKey) {
let stepName = t(translationKey)
if (stepName.value) {
onUpdateApp()
return
}
isUpdating.value = false
}
function getStatus(step) {
if (step.started && step.completed) {
return 'finished'
} else if (step.started && !step.completed) {
return 'running'
} else if (!step.started && !step.completed) {
return 'pending'
} else {
return 'error'
}
}
</script>
<style>
.update-description ul {
list-style: disc !important;
}
.update-description li {
margin-bottom: 4px;
}
</style>

View File

@ -0,0 +1,42 @@
<template>
<div class="relative">
<BaseCard container-class="px-4 py-5 sm:px-8 sm:py-2">
<BaseTabGroup>
<BaseTab
tab-panel-container="py-4 mt-px"
:title="$t('settings.customization.invoices.title')"
>
<InvoicesTab />
</BaseTab>
<BaseTab
tab-panel-container="py-4 mt-px"
:title="$t('settings.customization.estimates.title')"
>
<EstimatesTab />
</BaseTab>
<BaseTab
tab-panel-container="py-4 mt-px"
:title="$t('settings.customization.payments.title')"
>
<PaymentsTab />
</BaseTab>
<BaseTab
tab-panel-container="py-4 mt-px"
:title="$t('settings.customization.items.title')"
>
<ItemsTab />
</BaseTab>
</BaseTabGroup>
</BaseCard>
</div>
</template>
<script setup>
import InvoicesTab from '@/scripts/admin/views/settings/customization/invoices/InvoicesTab.vue'
import EstimatesTab from '@/scripts/admin/views/settings/customization/estimates/EstimatesTab.vue'
import PaymentsTab from '@/scripts/admin/views/settings/customization/payments/PaymentsTab.vue'
import ItemsTab from '@/scripts/admin/views/settings/customization/items/ItemsTab.vue'
</script>

View File

@ -0,0 +1,453 @@
<template>
<h6 class="text-gray-900 text-lg font-medium">
{{ $t(`settings.customization.${type}s.${type}_number_format`) }}
</h6>
<p class="mt-1 text-sm text-gray-500">
{{
$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-gray-700
border-t border-b border-gray-200 border-solid
"
></th>
<th
class="
px-5
py-3
text-sm
not-italic
font-medium
leading-5
text-left text-gray-700
border-t border-b border-gray-200 border-solid
"
>
Component
</th>
<th
class="
px-5
py-3
text-sm
not-italic
font-medium
leading-5
text-left text-gray-700
border-t border-b border-gray-200 border-solid
"
>
Parameter
</th>
<th
class="
px-5
py-3
text-sm
not-italic
font-medium
leading-5
text-left text-gray-700
border-t border-b border-gray-200 border-solid
"
></th>
</tr>
</thead>
<draggable
v-model="selectedFields"
class="divide-y divide-gray-200"
item-key="id"
tag="tbody"
handle=".handle"
filter=".ignore-element"
>
<template #item="{ element }">
<tr class="relative">
<td class="text-gray-300 cursor-move handle align-middle">
<DragIcon />
</td>
<td class="px-5 py-4">
<label
class="
block
text-sm
not-italic
font-medium
text-primary-800
whitespace-nowrap
mr-2
min-w-[200px]
"
>
{{ element.label }}
</label>
<p class="text-xs text-gray-500 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:modelValue="onUpdate($event, element)"
/>
</BaseInputGroup>
</td>
<td class="px-5 py-4 text-right align-middle pt-10">
<BaseButton
variant="white"
@click.prevent="removeComponent(element)"
>
Remove
<template #left="slotProps">
<BaseIcon
name="XIcon"
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="SaveIcon" />
</template>
{{ $t('settings.customization.save') }}
</BaseButton>
</template>
<script setup>
import { ref, computed, reactive, watch } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import draggable from 'vuedraggable'
import Guid from 'guid'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import { useGlobalStore } from '@/scripts/admin/stores/global'
import DragIcon from '@/scripts/components/icons/DragIcon.vue'
const props = defineProps({
type: {
type: String,
required: true,
},
typeStore: {
type: Object,
required: true,
},
defaultSeries: {
type: String,
default: 'INV',
},
})
const { t } = useI18n()
const companyStore = useCompanyStore()
const globalStore = useGlobalStore()
const selectedFields = ref([])
const isSaving = ref(false)
const allFields = ref([
{
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(() => {
return allFields.value.filter(function (obj) {
return !selectedFields.value.some(function (obj2) {
if (obj.allowMultiple) {
return false
}
return obj.name == obj2.name
})
})
})
const nextNumber = ref('')
const isFetchingNextNumber = ref(false)
const isLoadingPlaceholders = ref(false)
const getNumberFormat = computed(() => {
let format = ''
selectedFields.value.forEach((field) => {
let fieldString = `{{${field.name}`
if (field.value) {
fieldString += `:${field.value}`
}
format += `${fieldString}}}`
})
return format
})
watch(selectedFields, (val) => {
fetchNextNumber()
})
setInitialFields()
async function setInitialFields() {
let data = {
format: companyStore.selectedCompanySettings[`${props.type}_number_format`],
}
isLoadingPlaceholders.value = true
let res = await globalStore.fetchPlaceholders(data)
res.data.placeholders.forEach((placeholder) => {
let found = allFields.value.find((field) => {
return field.name === placeholder.name
})
const value = placeholder.value ?? ''
selectedFields.value.push({ ...found, value, id: Guid.raw() })
})
isLoadingPlaceholders.value = false
fetchNextNumber()
}
function isFieldAdded(field) {
return selectedFields.value.find((v) => v.name === field.name)
}
function onSelectField(field) {
if (isFieldAdded(field) && !field.allowMultiple) {
return
}
selectedFields.value.push({ ...field, id: Guid.raw() })
fetchNextNumber()
}
function removeComponent(component) {
selectedFields.value = selectedFields.value.filter(function (el) {
return component.id !== el.id
})
}
function onUpdate(val, element) {
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() {
if (!getNumberFormat.value) {
nextNumber.value = ''
return
}
let data = {
key: props.type,
format: getNumberFormat.value,
}
isFetchingNextNumber.value = true
let res = await props.typeStore.getNextNumber(data)
isFetchingNextNumber.value = false
if (res.data) {
nextNumber.value = res.data.nextNumber
}
}
async function submitForm() {
if (isFetchingNextNumber.value || isLoadingPlaceholders.value) {
return
}
isSaving.value = true
let data = { 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>

View File

@ -0,0 +1,73 @@
<template>
<EstimatesTabEstimateNumber />
<BaseDivider class="my-8" />
<EstimatesTabExpiryDate />
<BaseDivider class="my-8" />
<EstimatesTabConvertEstimate />
<BaseDivider class="my-8" />
<EstimatesTabDefaultFormats />
<BaseDivider class="mt-6 mb-2" />
<ul class="divide-y divide-gray-200">
<BaseSwitchSection
v-model="sendAsAttachmentField"
:title="$t('settings.customization.estimates.estimate_email_attachment')"
:description="
$t(
'settings.customization.estimates.estimate_email_attachment_setting_description'
)
"
/>
</ul>
</template>
<script setup>
import { computed, reactive, inject } from 'vue'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import EstimatesTabEstimateNumber from './EstimatesTabEstimateNumber.vue'
import EstimatesTabExpiryDate from './EstimatesTabExpiryDate.vue'
import EstimatesTabDefaultFormats from './EstimatesTabDefaultFormats.vue'
import EstimatesTabConvertEstimate from './EstimatesTabConvertEstimate.vue'
const utils = inject('utils')
const companyStore = useCompanyStore()
const estimateSettings = reactive({
estimate_email_attachment: null,
})
utils.mergeSettings(estimateSettings, {
...companyStore.selectedCompanySettings,
})
const sendAsAttachmentField = computed({
get: () => {
return estimateSettings.estimate_email_attachment === 'YES'
},
set: async (newValue) => {
const value = newValue ? 'YES' : 'NO'
let data = {
settings: {
estimate_email_attachment: value,
},
}
estimateSettings.estimate_email_attachment = value
await companyStore.updateCompanySettings({
data,
message: 'general.setting_updated',
})
},
})
</script>

View File

@ -0,0 +1,82 @@
<template>
<h6 class="text-gray-900 text-lg font-medium">
{{ $tc('settings.customization.estimates.convert_estimate_options') }}
</h6>
<p class="mt-1 text-sm text-gray-500">
{{ $t('settings.customization.estimates.convert_estimate_description') }}
</p>
<BaseInputGroup required>
<BaseRadio
id="no_action"
v-model="settingsForm.estimate_convert_action"
:label="$t('settings.customization.estimates.no_action')"
size="sm"
name="filter"
value="no_action"
class="mt-2"
@update:modelValue="submitForm"
/>
<BaseRadio
id="delete_estimate"
v-model="settingsForm.estimate_convert_action"
:label="$t('settings.customization.estimates.delete_estimate')"
size="sm"
name="filter"
value="delete_estimate"
class="my-2"
@update:modelValue="submitForm"
/>
<BaseRadio
id="mark_estimate_as_accepted"
v-model="settingsForm.estimate_convert_action"
:label="$t('settings.customization.estimates.mark_estimate_as_accepted')"
size="sm"
name="filter"
value="mark_estimate_as_accepted"
@update:modelValue="submitForm"
/>
</BaseInputGroup>
</template>
<script setup>
import { reactive, computed, ref, inject } from 'vue'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import { required, helpers } from '@vuelidate/validators'
import { useI18n } from 'vue-i18n'
import { useGlobalStore } from '@/scripts/admin/stores/global'
const { t, tm } = useI18n()
const companyStore = useCompanyStore()
const globalStore = useGlobalStore()
const utils = inject('utils')
const settingsForm = reactive({ estimate_convert_action: null })
utils.mergeSettings(settingsForm, {
...companyStore.selectedCompanySettings,
})
const retrospectiveEditOptions = computed(() => {
return globalStore.config.estimate_convert_action.map((option) => {
option.title = t(option.key)
return option
})
})
async function submitForm() {
let data = {
settings: {
...settingsForm,
},
}
await companyStore.updateCompanySettings({
data,
message: 'settings.customization.estimates.estimate_settings_updated',
})
return true
}
</script>

View File

@ -0,0 +1,129 @@
<template>
<form @submit.prevent="submitForm">
<h6 class="text-gray-900 text-lg font-medium">
{{ $t('settings.customization.estimates.default_formats') }}
</h6>
<p class="mt-1 text-sm text-gray-500 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="SaveIcon" />
</template>
{{ $t('settings.customization.save') }}
</BaseButton>
</form>
</template>
<script setup>
import { ref, reactive, inject } from 'vue'
import { useCompanyStore } from '@/scripts/admin/stores/company'
const companyStore = useCompanyStore()
const utils = inject('utils')
const estimateMailFields = ref([
'customer',
'customerCustom',
'estimate',
'estimateCustom',
'company',
])
const billingFields = ref([
'billing',
'customer',
'customerCustom',
'estimateCustom',
])
const shippingFields = ref([
'shipping',
'customer',
'customerCustom',
'estimateCustom',
])
const companyFields = ref(['company', 'estimateCustom'])
let isSaving = ref(false)
const formatSettings = reactive({
estimate_mail_body: null,
estimate_company_address_format: null,
estimate_shipping_address_format: null,
estimate_billing_address_format: null,
})
utils.mergeSettings(formatSettings, {
...companyStore.selectedCompanySettings,
})
async function submitForm() {
isSaving.value = true
let data = {
settings: {
...formatSettings,
},
}
await companyStore.updateCompanySettings({
data,
message: 'settings.customization.estimates.estimate_settings_updated',
})
isSaving.value = false
return true
}
</script>

View File

@ -0,0 +1,14 @@
<template>
<NumberCustomizer
type="estimate"
:type-store="estimateStore"
default-series="EST"
/>
</template>
<script setup>
import { useEstimateStore } from '@/scripts/admin/stores/estimate'
import NumberCustomizer from '../NumberCustomizer.vue'
const estimateStore = useEstimateStore()
</script>

View File

@ -0,0 +1,136 @@
<template>
<form @submit.prevent="submitForm">
<h6 class="text-gray-900 text-lg font-medium">
{{ $t('settings.customization.estimates.expiry_date') }}
</h6>
<p class="mt-1 text-sm text-gray-500 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="SaveIcon" />
</template>
{{ $t('settings.customization.save') }}
</BaseButton>
</form>
</template>
<script setup>
import { ref, computed, onMounted, reactive, inject } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import { numeric, helpers, requiredIf } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
const { t } = useI18n()
const companyStore = useCompanyStore()
const utils = inject('utils')
let isSaving = ref(false)
const expiryDateSettings = reactive({
estimate_set_expiry_date_automatically: null,
estimate_expiry_date_days: null,
})
utils.mergeSettings(expiryDateSettings, {
...companyStore.selectedCompanySettings,
})
const expiryDateAutoField = computed({
get: () => {
return expiryDateSettings.estimate_set_expiry_date_automatically === 'YES'
},
set: async (newValue) => {
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
let 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>

View File

@ -0,0 +1,71 @@
<template>
<InvoicesTabInvoiceNumber />
<BaseDivider class="my-8" />
<InvoicesTabDueDate />
<BaseDivider class="my-8" />
<InvoicesTabRetrospective />
<BaseDivider class="my-8" />
<InvoicesTabDefaultFormats />
<BaseDivider class="mt-6 mb-2" />
<ul class="divide-y divide-gray-200">
<BaseSwitchSection
v-model="sendAsAttachmentField"
:title="$t('settings.customization.invoices.invoice_email_attachment')"
:description="
$t(
'settings.customization.invoices.invoice_email_attachment_setting_description'
)
"
/>
</ul>
</template>
<script setup>
import { computed, reactive, inject } from 'vue'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import InvoicesTabInvoiceNumber from './InvoicesTabInvoiceNumber.vue'
import InvoicesTabRetrospective from './InvoicesTabRetrospective.vue'
import InvoicesTabDueDate from './InvoicesTabDueDate.vue'
import InvoicesTabDefaultFormats from './InvoicesTabDefaultFormats.vue'
const utils = inject('utils')
const companyStore = useCompanyStore()
const invoiceSettings = reactive({
invoice_email_attachment: null,
})
utils.mergeSettings(invoiceSettings, {
...companyStore.selectedCompanySettings,
})
const sendAsAttachmentField = computed({
get: () => {
return invoiceSettings.invoice_email_attachment === 'YES'
},
set: async (newValue) => {
const value = newValue ? 'YES' : 'NO'
let data = {
settings: {
invoice_email_attachment: value,
},
}
invoiceSettings.invoice_email_attachment = value
await companyStore.updateCompanySettings({
data,
message: 'general.setting_updated',
})
},
})
</script>

View File

@ -0,0 +1,127 @@
<template>
<form @submit.prevent="submitForm">
<h6 class="text-gray-900 text-lg font-medium">
{{ $t('settings.customization.invoices.default_formats') }}
</h6>
<p class="mt-1 text-sm text-gray-500 mb-2">
{{ $t('settings.customization.invoices.default_formats_description') }}
</p>
<BaseInputGroup
:label="$t('settings.customization.invoices.default_invoice_email_body')"
class="mt-6 mb-4"
>
<BaseCustomInput
v-model="formatSettings.invoice_mail_body"
:fields="invoiceMailFields"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.customization.invoices.company_address_format')"
class="mt-6 mb-4"
>
<BaseCustomInput
v-model="formatSettings.invoice_company_address_format"
:fields="companyFields"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.customization.invoices.shipping_address_format')"
class="mt-6 mb-4"
>
<BaseCustomInput
v-model="formatSettings.invoice_shipping_address_format"
:fields="shippingFields"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.customization.invoices.billing_address_format')"
class="mt-6 mb-4"
>
<BaseCustomInput
v-model="formatSettings.invoice_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="SaveIcon" />
</template>
{{ $t('settings.customization.save') }}
</BaseButton>
</form>
</template>
<script setup>
import { ref, reactive, inject } from 'vue'
import { useCompanyStore } from '@/scripts/admin/stores/company'
const companyStore = useCompanyStore()
const utils = inject('utils')
const invoiceMailFields = ref([
'customer',
'customerCustom',
'invoice',
'invoiceCustom',
'company',
])
const billingFields = ref([
'billing',
'customer',
'customerCustom',
'invoiceCustom',
])
const shippingFields = ref([
'shipping',
'customer',
'customerCustom',
'invoiceCustom',
])
const companyFields = ref(['company', 'invoiceCustom'])
let isSaving = ref(false)
const formatSettings = reactive({
invoice_mail_body: null,
invoice_company_address_format: null,
invoice_shipping_address_format: null,
invoice_billing_address_format: null,
})
utils.mergeSettings(formatSettings, {
...companyStore.selectedCompanySettings,
})
async function submitForm() {
isSaving.value = true
let data = {
settings: {
...formatSettings,
},
}
await companyStore.updateCompanySettings({
data,
message: 'settings.customization.invoices.invoice_settings_updated',
})
isSaving.value = false
return true
}
</script>

View File

@ -0,0 +1,134 @@
<template>
<form @submit.prevent="submitForm">
<h6 class="text-gray-900 text-lg font-medium">
{{ $t('settings.customization.invoices.due_date') }}
</h6>
<p class="mt-1 text-sm text-gray-500 mb-2">
{{ $t('settings.customization.invoices.due_date_description') }}
</p>
<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="SaveIcon" />
</template>
{{ $t('settings.customization.save') }}
</BaseButton>
</form>
</template>
<script setup>
import { ref, computed, onMounted, reactive, inject } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import { numeric, helpers, requiredIf } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
const { t } = useI18n()
const companyStore = useCompanyStore()
const utils = inject('utils')
let isSaving = ref(false)
const dueDateSettings = reactive({
invoice_set_due_date_automatically: null,
invoice_due_date_days: null,
})
utils.mergeSettings(dueDateSettings, {
...companyStore.selectedCompanySettings,
})
const dueDateAutoField = computed({
get: () => {
return dueDateSettings.invoice_set_due_date_automatically === 'YES'
},
set: async (newValue) => {
const value = newValue ? 'YES' : 'NO'
dueDateSettings.invoice_set_due_date_automatically = value
},
})
const rules = computed(() => {
return {
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() {
v$.value.dueDateSettings.$touch()
if (v$.value.dueDateSettings.$invalid) {
return false
}
isSaving.value = true
let data = {
settings: {
...dueDateSettings,
},
}
// Don't pass due_date_days if setting is not enabled
if (!dueDateAutoField.value) {
delete data.settings.invoice_due_date_days
}
await companyStore.updateCompanySettings({
data,
message: 'settings.customization.invoices.invoice_settings_updated',
})
isSaving.value = false
return true
}
</script>

View File

@ -0,0 +1,14 @@
<template>
<NumberCustomizer
type="invoice"
:type-store="invoiceStore"
default-series="INV"
/>
</template>
<script setup>
import { useInvoiceStore } from '@/scripts/admin/stores/invoice'
import NumberCustomizer from '../NumberCustomizer.vue'
const invoiceStore = useInvoiceStore()
</script>

View File

@ -0,0 +1,93 @@
<template>
<h6 class="text-gray-900 text-lg font-medium">
{{ $tc('settings.customization.invoices.retrospective_edits') }}
</h6>
<p class="mt-1 text-sm text-gray-500">
{{ $t('settings.customization.invoices.retrospective_edits_description') }}
</p>
<BaseInputGroup required>
<BaseRadio
id="allow"
v-model="settingsForm.retrospective_edits"
:label="$t('settings.customization.invoices.allow')"
size="sm"
name="filter"
value="allow"
class="mt-2"
@update:modelValue="submitForm"
/>
<BaseRadio
id="disable_on_invoice_partial_paid"
v-model="settingsForm.retrospective_edits"
:label="
$t('settings.customization.invoices.disable_on_invoice_partial_paid')
"
size="sm"
name="filter"
value="disable_on_invoice_partial_paid"
class="mt-2"
@update:modelValue="submitForm"
/>
<BaseRadio
id="disable_on_invoice_paid"
v-model="settingsForm.retrospective_edits"
:label="$t('settings.customization.invoices.disable_on_invoice_paid')"
size="sm"
name="filter"
value="disable_on_invoice_paid"
class="my-2"
@update:modelValue="submitForm"
/>
<BaseRadio
id="disable_on_invoice_sent"
v-model="settingsForm.retrospective_edits"
:label="$t('settings.customization.invoices.disable_on_invoice_sent')"
size="sm"
name="filter"
value="disable_on_invoice_sent"
@update:modelValue="submitForm"
/>
</BaseInputGroup>
</template>
<script setup>
import { reactive, computed, ref, inject } from 'vue'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import { useI18n } from 'vue-i18n'
import { useGlobalStore } from '@/scripts/admin/stores/global'
const { t, tm } = useI18n()
const companyStore = useCompanyStore()
const globalStore = useGlobalStore()
const utils = inject('utils')
const settingsForm = reactive({ retrospective_edits: null })
utils.mergeSettings(settingsForm, {
...companyStore.selectedCompanySettings,
})
const retrospectiveEditOptions = computed(() => {
return globalStore.config.retrospective_edits.map((option) => {
option.title = t(option.key)
return option
})
})
async function submitForm() {
let data = {
settings: {
...settingsForm,
},
}
await companyStore.updateCompanySettings({
data,
message: 'settings.customization.invoices.invoice_settings_updated',
})
return true
}
</script>

View File

@ -0,0 +1,131 @@
<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="DotsHorizontalIcon" class="text-gray-500" />
</div>
</template>
<BaseDropdownItem @click="editItemUnit(row)">
<BaseIcon
name="PencilIcon"
class="w-5 h-5 mr-3 text-gray-400 group-hover:text-gray-500"
/>
{{ $t('general.edit') }}
</BaseDropdownItem>
<BaseDropdownItem @click="removeItemUnit(row)">
<BaseIcon
name="TrashIcon"
class="w-5 h-5 mr-3 text-gray-400 group-hover:text-gray-500"
/>
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</template>
</BaseTable>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useItemStore } from '@/scripts/admin/stores/item'
import { useModalStore } from '@/scripts/stores/modal'
import { useDialogStore } from '@/scripts/stores/dialog'
import ItemUnitModal from '@/scripts/admin/components/modal-components/ItemUnitModal.vue'
const { t } = useI18n()
const table = ref(null)
const itemStore = useItemStore()
const modalStore = useModalStore()
const dialogStore = useDialogStore()
const columns = computed(() => {
return [
{
key: 'name',
label: t('settings.customization.items.unit_name'),
thClass: 'extra',
tdClass: 'font-medium text-gray-900',
},
{
key: 'actions',
label: '',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
]
})
async function fetchData({ page, filter, sort }) {
let data = {
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
}
let response = await itemStore.fetchItemUnits(data)
return {
data: response.data.data,
pagination: {
totalPages: response.data.meta.last_page,
currentPage: page,
totalCount: response.data.meta.total,
limit: 5,
},
}
}
async function addItemUnit() {
modalStore.openModal({
title: t('settings.customization.items.add_item_unit'),
componentName: 'ItemUnitModal',
refreshData: table.value.refresh,
size: 'sm',
})
}
async function editItemUnit(row) {
itemStore.fetchItemUnit(row.data.id)
modalStore.openModal({
title: t('settings.customization.items.edit_item_unit'),
componentName: 'ItemUnitModal',
id: row.data.id,
data: row.data,
refreshData: table.value && table.value.refresh,
})
}
function removeItemUnit(row) {
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) => {
if (res) {
await itemStore.deleteItemUnit(row.data.id)
table.value && table.value.refresh()
}
})
}
</script>

View File

@ -0,0 +1,61 @@
<template>
<PaymentsTabPaymentNumber />
<BaseDivider class="my-8" />
<PaymentsTabDefaultFormats />
<BaseDivider class="mt-6 mb-2" />
<ul class="divide-y divide-gray-200">
<BaseSwitchSection
v-model="sendAsAttachmentField"
:title="$t('settings.customization.payments.payment_email_attachment')"
:description="
$t(
'settings.customization.payments.payment_email_attachment_setting_description'
)
"
/>
</ul>
</template>
<script setup>
import { computed, reactive, inject } from 'vue'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import PaymentsTabPaymentNumber from './PaymentsTabPaymentNumber.vue'
import PaymentsTabDefaultFormats from './PaymentsTabDefaultFormats.vue'
const utils = inject('utils')
const companyStore = useCompanyStore()
const paymentSettings = reactive({
payment_email_attachment: null,
})
utils.mergeSettings(paymentSettings, {
...companyStore.selectedCompanySettings,
})
const sendAsAttachmentField = computed({
get: () => {
return paymentSettings.payment_email_attachment === 'YES'
},
set: async (newValue) => {
const value = newValue ? 'YES' : 'NO'
let data = {
settings: {
payment_email_attachment: value,
},
}
paymentSettings.payment_email_attachment = value
await companyStore.updateCompanySettings({
data,
message: 'general.setting_updated',
})
},
})
</script>

View File

@ -0,0 +1,111 @@
<template>
<form @submit.prevent="submitForm">
<h6 class="text-gray-900 text-lg font-medium">
{{ $t('settings.customization.payments.default_formats') }}
</h6>
<p class="mt-1 text-sm text-gray-500 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="SaveIcon" />
</template>
{{ $t('settings.customization.save') }}
</BaseButton>
</form>
</template>
<script setup>
import { ref, reactive, inject } from 'vue'
import { useCompanyStore } from '@/scripts/admin/stores/company'
const companyStore = useCompanyStore()
const utils = inject('utils')
const mailFields = ref([
'customer',
'customerCustom',
'company',
'payment',
'paymentCustom',
])
const customerAddressFields = ref([
'billing',
'customer',
'customerCustom',
'paymentCustom',
])
const companyFields = ref(['company', 'paymentCustom'])
let isSaving = ref(false)
const formatSettings = reactive({
payment_mail_body: null,
payment_company_address_format: null,
payment_from_customer_address_format: null,
})
utils.mergeSettings(formatSettings, {
...companyStore.selectedCompanySettings,
})
async function submitForm() {
isSaving.value = true
let data = {
settings: {
...formatSettings,
},
}
await companyStore.updateCompanySettings({
data,
message: 'settings.customization.payments.payment_settings_updated',
})
isSaving.value = false
return true
}
</script>

View File

@ -0,0 +1,14 @@
<template>
<NumberCustomizer
type="payment"
:type-store="paymentStore"
default-series="PAY"
/>
</template>
<script setup>
import { usePaymentStore } from '@/scripts/admin/stores/payment'
import NumberCustomizer from '../NumberCustomizer.vue'
const paymentStore = usePaymentStore()
</script>

View File

@ -0,0 +1,158 @@
<template>
<form @submit.prevent="saveEmailConfig">
<BaseInputGrid>
<BaseInputGroup
:label="$t('settings.mail.driver')"
:content-loading="isFetchingInitialData"
:error="
v$.basicMailConfig.mail_driver.$error &&
v$.basicMailConfig.mail_driver.$errors[0].$message
"
required
>
<BaseMultiselect
v-model="mailDriverStore.basicMailConfig.mail_driver"
:content-loading="isFetchingInitialData"
:options="mailDrivers"
:can-deselect="false"
:invalid="v$.basicMailConfig.mail_driver.$error"
@update:modelValue="onChangeDriver"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.from_mail')"
:content-loading="isFetchingInitialData"
:error="
v$.basicMailConfig.from_mail.$error &&
v$.basicMailConfig.from_mail.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="mailDriverStore.basicMailConfig.from_mail"
:content-loading="isFetchingInitialData"
type="text"
name="from_mail"
:invalid="v$.basicMailConfig.from_mail.$error"
@input="v$.basicMailConfig.from_mail.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.from_name')"
:content-loading="isFetchingInitialData"
:error="
v$.basicMailConfig.from_name.$error &&
v$.basicMailConfig.from_name.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="mailDriverStore.basicMailConfig.from_name"
:content-loading="isFetchingInitialData"
type="text"
name="name"
:invalid="v$.basicMailConfig.from_name.$error"
@input="v$.basicMailConfig.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="SaveIcon" />
</template>
{{ $t('general.save') }}
</BaseButton>
<slot />
</div>
</form>
</template>
<script setup>
import { onMounted, computed } from 'vue'
import { required, email, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useI18n } from 'vue-i18n'
import { useMailDriverStore } from '@/scripts/admin/stores/mail-driver'
const props = defineProps({
configData: {
type: Object,
require: true,
default: Object,
},
isSaving: {
type: Boolean,
require: true,
default: false,
},
isFetchingInitialData: {
type: Boolean,
require: true,
default: false,
},
mailDrivers: {
type: Array,
require: true,
default: Array,
},
})
const emit = defineEmits(['submit-data', 'on-change-driver'])
const mailDriverStore = useMailDriverStore()
const { t } = useI18n()
const rules = computed(() => {
return {
basicMailConfig: {
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,
computed(() => mailDriverStore)
)
onMounted(() => {
for (const key in mailDriverStore.basicMailConfig) {
if (props.configData.hasOwnProperty(key)) {
mailDriverStore.$patch((state) => {
state.basicMailConfig[key] = props.configData[key]
})
}
}
})
async function saveEmailConfig() {
v$.value.basicMailConfig.$touch()
if (!v$.value.basicMailConfig.$invalid) {
emit('submit-data', mailDriverStore.basicMailConfig)
}
return false
}
function onChangeDriver() {
v$.value.basicMailConfig.mail_driver.$touch()
emit('on-change-driver', mailDriverStore.basicMailConfig.mail_driver)
}
</script>

View File

@ -0,0 +1,247 @@
<template>
<form @submit.prevent="saveEmailConfig">
<BaseInputGrid>
<BaseInputGroup
:label="$t('settings.mail.driver')"
:content-loading="isFetchingInitialData"
:error="
v$.mailgunConfig.mail_driver.$error &&
v$.mailgunConfig.mail_driver.$errors[0].$message
"
required
>
<BaseMultiselect
v-model="mailDriverStore.mailgunConfig.mail_driver"
:content-loading="isFetchingInitialData"
:options="mailDrivers"
:can-deselect="false"
:invalid="v$.mailgunConfig.mail_driver.$error"
@update:modelValue="onChangeDriver"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.mailgun_domain')"
:content-loading="isFetchingInitialData"
:error="
v$.mailgunConfig.mail_mailgun_domain.$error &&
v$.mailgunConfig.mail_mailgun_domain.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="mailDriverStore.mailgunConfig.mail_mailgun_domain"
:content-loading="isFetchingInitialData"
type="text"
name="mailgun_domain"
:invalid="v$.mailgunConfig.mail_mailgun_domain.$error"
@input="v$.mailgunConfig.mail_mailgun_domain.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.mailgun_secret')"
:content-loading="isFetchingInitialData"
:error="
v$.mailgunConfig.mail_mailgun_secret.$error &&
v$.mailgunConfig.mail_mailgun_secret.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="mailDriverStore.mailgunConfig.mail_mailgun_secret"
:content-loading="isFetchingInitialData"
:type="getInputType"
name="mailgun_secret"
autocomplete="off"
:invalid="v$.mailgunConfig.mail_mailgun_secret.$error"
@input="v$.mailgunConfig.mail_mailgun_secret.$touch()"
>
<template #right>
<BaseIcon
v-if="isShowPassword"
class="mr-1 text-gray-500 cursor-pointer"
name="EyeOffIcon"
@click="isShowPassword = !isShowPassword"
/>
<BaseIcon
v-else
class="mr-1 text-gray-500 cursor-pointer"
name="EyeIcon"
@click="isShowPassword = !isShowPassword"
/>
</template>
</BaseInput>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.mailgun_endpoint')"
:content-loading="isFetchingInitialData"
:error="
v$.mailgunConfig.mail_mailgun_endpoint.$error &&
v$.mailgunConfig.mail_mailgun_endpoint.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="mailDriverStore.mailgunConfig.mail_mailgun_endpoint"
:content-loading="isFetchingInitialData"
type="text"
name="mailgun_endpoint"
:invalid="v$.mailgunConfig.mail_mailgun_endpoint.$error"
@input="v$.mailgunConfig.mail_mailgun_endpoint.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.from_mail')"
:content-loading="isFetchingInitialData"
:error="
v$.mailgunConfig.from_mail.$error &&
v$.mailgunConfig.from_mail.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="mailDriverStore.mailgunConfig.from_mail"
:content-loading="isFetchingInitialData"
type="text"
name="from_mail"
:invalid="v$.mailgunConfig.from_mail.$error"
@input="v$.mailgunConfig.from_mail.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.from_name')"
:content-loading="isFetchingInitialData"
:error="
v$.mailgunConfig.from_name.$error &&
v$.mailgunConfig.from_name.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="mailDriverStore.mailgunConfig.from_name"
:content-loading="isFetchingInitialData"
type="text"
name="from_name"
:invalid="v$.mailgunConfig.from_name.$error"
@input="v$.mailgunConfig.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="SaveIcon" :class="slotProps.class" />
</template>
{{ $t('general.save') }}
</BaseButton>
<slot />
</div>
</form>
</template>
<script setup>
import { onMounted, ref, computed } from 'vue'
import { required, email, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useI18n } from 'vue-i18n'
import { useMailDriverStore } from '@/scripts/admin/stores/mail-driver'
const props = defineProps({
configData: {
type: Object,
require: true,
default: Object,
},
isSaving: {
type: Boolean,
require: true,
default: false,
},
isFetchingInitialData: {
type: Boolean,
require: true,
default: false,
},
mailDrivers: {
type: Array,
require: true,
default: Array,
},
})
const emit = defineEmits(['submit-data', 'on-change-driver'])
const mailDriverStore = useMailDriverStore()
const { t } = useI18n()
let isShowPassword = ref(false)
const getInputType = computed(() => {
if (isShowPassword.value) {
return 'text'
}
return 'password'
})
const rules = computed(() => {
return {
mailgunConfig: {
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,
},
from_name: {
required: helpers.withMessage(t('validation.required'), required),
},
},
}
})
const v$ = useVuelidate(
rules,
computed(() => mailDriverStore)
)
onMounted(() => {
for (const key in mailDriverStore.mailgunConfig) {
if (props.configData.hasOwnProperty(key)) {
mailDriverStore.mailgunConfig[key] = props.configData[key]
}
}
})
async function saveEmailConfig() {
v$.value.mailgunConfig.$touch()
if (!v$.value.mailgunConfig.$invalid) {
emit('submit-data', mailDriverStore.mailgunConfig)
}
return false
}
function onChangeDriver() {
v$.value.mailgunConfig.mail_driver.$touch()
emit('on-change-driver', mailDriverStore.mailgunConfig.mail_driver)
}
</script>

View File

@ -0,0 +1,294 @@
<template>
<form @submit.prevent="saveEmailConfig">
<BaseInputGrid>
<BaseInputGroup
:label="$t('settings.mail.driver')"
:content-loading="isFetchingInitialData"
:error="
v$.sesConfig.mail_driver.$error &&
v$.sesConfig.mail_driver.$errors[0].$message
"
required
>
<BaseMultiselect
v-model="mailDriverStore.sesConfig.mail_driver"
:content-loading="isFetchingInitialData"
:options="mailDrivers"
:can-deselect="false"
:invalid="v$.sesConfig.mail_driver.$error"
@update:modelValue="onChangeDriver"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.host')"
:content-loading="isFetchingInitialData"
:error="
v$.sesConfig.mail_host.$error &&
v$.sesConfig.mail_host.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="mailDriverStore.sesConfig.mail_host"
:content-loading="isFetchingInitialData"
type="text"
name="mail_host"
:invalid="v$.sesConfig.mail_host.$error"
@input="v$.sesConfig.mail_host.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.port')"
:content-loading="isFetchingInitialData"
:error="
v$.sesConfig.mail_port.$error &&
v$.sesConfig.mail_port.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="mailDriverStore.sesConfig.mail_port"
:content-loading="isFetchingInitialData"
type="text"
name="mail_port"
:invalid="v$.sesConfig.mail_port.$error"
@input="v$.sesConfig.mail_port.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.encryption')"
:content-loading="isFetchingInitialData"
:error="
v$.sesConfig.mail_encryption.$error &&
v$.sesConfig.mail_encryption.$errors[0].$message
"
required
>
<BaseMultiselect
v-model.trim="mailDriverStore.sesConfig.mail_encryption"
:content-loading="isFetchingInitialData"
:options="encryptions"
:invalid="v$.sesConfig.mail_encryption.$error"
placeholder="Select option"
@input="v$.sesConfig.mail_encryption.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.from_mail')"
:content-loading="isFetchingInitialData"
:error="
v$.sesConfig.from_mail.$error &&
v$.sesConfig.from_mail.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="mailDriverStore.sesConfig.from_mail"
:content-loading="isFetchingInitialData"
type="text"
name="from_mail"
:invalid="v$.sesConfig.from_mail.$error"
@input="v$.sesConfig.from_mail.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.from_name')"
:content-loading="isFetchingInitialData"
:error="
v$.sesConfig.from_name.$error &&
v$.sesConfig.from_name.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="mailDriverStore.sesConfig.from_name"
:content-loading="isFetchingInitialData"
type="text"
name="name"
:invalid="v$.sesConfig.from_name.$error"
@input="v$.sesConfig.from_name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.ses_key')"
:content-loading="isFetchingInitialData"
:error="
v$.sesConfig.mail_ses_key.$error &&
v$.sesConfig.mail_ses_key.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="mailDriverStore.sesConfig.mail_ses_key"
:content-loading="isFetchingInitialData"
type="text"
name="mail_ses_key"
:invalid="v$.sesConfig.mail_ses_key.$error"
@input="v$.sesConfig.mail_ses_key.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.ses_secret')"
:content-loading="isFetchingInitialData"
:error="
v$.sesConfig.mail_ses_secret.$error &&
v$.mail_ses_secret.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="mailDriverStore.sesConfig.mail_ses_secret"
:content-loading="isFetchingInitialData"
:type="getInputType"
name="mail_ses_secret"
autocomplete="off"
:invalid="v$.sesConfig.mail_ses_secret.$error"
@input="v$.sesConfig.mail_ses_secret.$touch()"
>
<template #right>
<BaseIcon
v-if="isShowPassword"
class="mr-1 text-gray-500 cursor-pointer"
name="EyeOffIcon"
@click="isShowPassword = !isShowPassword"
/>
<BaseIcon
v-else
class="mr-1 text-gray-500 cursor-pointer"
name="EyeIcon"
@click="isShowPassword = !isShowPassword"
/>
</template>
</BaseInput>
</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="SaveIcon" :class="slotProps.class" />
</template>
{{ $t('general.save') }}
</BaseButton>
<slot />
</div>
</form>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { required, email, numeric, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useI18n } from 'vue-i18n'
import { useMailDriverStore } from '@/scripts/admin/stores/mail-driver'
const props = defineProps({
configData: {
type: Object,
require: true,
default: Object,
},
isSaving: {
type: Boolean,
require: true,
default: false,
},
isFetchingInitialData: {
type: Boolean,
require: true,
default: false,
},
mailDrivers: {
type: Array,
require: true,
default: Array,
},
})
const emit = defineEmits(['submit-data', 'on-change-driver'])
const mailDriverStore = useMailDriverStore()
const { t } = useI18n()
let isShowPassword = ref(false)
const encryptions = reactive(['tls', 'ssl', 'starttls'])
const rules = computed(() => {
return {
sesConfig: {
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,
},
mail_ses_key: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_ses_secret: {
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,
computed(() => mailDriverStore)
)
const getInputType = computed(() => {
if (isShowPassword.value) {
return 'text'
}
return 'password'
})
onMounted(() => {
for (const key in mailDriverStore.sesConfig) {
if (props.configData.hasOwnProperty(key)) {
mailDriverStore.sesConfig[key] = props.configData[key]
}
}
})
async function saveEmailConfig() {
v$.value.sesConfig.$touch()
if (!v$.value.sesConfig.$invalid) {
emit('submit-data', mailDriverStore.sesConfig)
}
return false
}
function onChangeDriver() {
v$.value.sesConfig.mail_driver.$touch()
emit('on-change-driver', mailDriverStore.sesConfig.mail_driver)
}
</script>

View File

@ -0,0 +1,275 @@
<template>
<form @submit.prevent="saveEmailConfig">
<BaseInputGrid>
<BaseInputGroup
:label="$t('settings.mail.driver')"
:content-loading="isFetchingInitialData"
:error="
v$.smtpConfig.mail_driver.$error &&
v$.smtpConfig.mail_driver.$errors[0].$message
"
required
>
<BaseMultiselect
v-model="mailDriverStore.smtpConfig.mail_driver"
:content-loading="isFetchingInitialData"
:options="mailDrivers"
:can-deselect="false"
:invalid="v$.smtpConfig.mail_driver.$error"
@update:modelValue="onChangeDriver"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.host')"
:content-loading="isFetchingInitialData"
:error="
v$.smtpConfig.mail_host.$error &&
v$.smtpConfig.mail_host.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="mailDriverStore.smtpConfig.mail_host"
:content-loading="isFetchingInitialData"
type="text"
name="mail_host"
:invalid="v$.smtpConfig.mail_host.$error"
@input="v$.smtpConfig.mail_host.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:content-loading="isFetchingInitialData"
:label="$t('settings.mail.username')"
>
<BaseInput
v-model.trim="mailDriverStore.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="mailDriverStore.smtpConfig.mail_password"
:content-loading="isFetchingInitialData"
:type="getInputType"
name="password"
>
<template #right>
<BaseIcon
v-if="isShowPassword"
class="mr-1 text-gray-500 cursor-pointer"
name="EyeOffIcon"
@click="isShowPassword = !isShowPassword"
/>
<BaseIcon
v-else
class="mr-1 text-gray-500 cursor-pointer"
name="EyeIcon"
@click="isShowPassword = !isShowPassword"
/>
</template>
</BaseInput>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.port')"
:content-loading="isFetchingInitialData"
:error="
v$.smtpConfig.mail_port.$error &&
v$.smtpConfig.mail_port.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="mailDriverStore.smtpConfig.mail_port"
:content-loading="isFetchingInitialData"
type="text"
name="mail_port"
:invalid="v$.smtpConfig.mail_port.$error"
@input="v$.smtpConfig.mail_port.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.encryption')"
:content-loading="isFetchingInitialData"
:error="
v$.smtpConfig.mail_encryption.$error &&
v$.smtpConfig.mail_encryption.$errors[0].$message
"
required
>
<BaseMultiselect
v-model.trim="mailDriverStore.smtpConfig.mail_encryption"
:content-loading="isFetchingInitialData"
:options="encryptions"
:searchable="true"
:show-labels="false"
placeholder="Select option"
:invalid="v$.smtpConfig.mail_encryption.$error"
@input="v$.smtpConfig.mail_encryption.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.from_mail')"
:content-loading="isFetchingInitialData"
:error="
v$.smtpConfig.from_mail.$error &&
v$.smtpConfig.from_mail.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="mailDriverStore.smtpConfig.from_mail"
:content-loading="isFetchingInitialData"
type="text"
name="from_mail"
:invalid="v$.smtpConfig.from_mail.$error"
@input="v$.smtpConfig.from_mail.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.from_name')"
:content-loading="isFetchingInitialData"
:error="
v$.smtpConfig.from_name.$error &&
v$.smtpConfig.from_name.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="mailDriverStore.smtpConfig.from_name"
:content-loading="isFetchingInitialData"
type="text"
name="from_name"
:invalid="v$.smtpConfig.from_name.$error"
@input="v$.smtpConfig.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="SaveIcon" :class="slotProps.class" />
</template>
{{ $t('general.save') }}
</BaseButton>
<slot />
</div>
</form>
</template>
<script setup>
import { reactive, onMounted, ref, computed } from 'vue'
import { required, email, numeric, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useI18n } from 'vue-i18n'
import { useMailDriverStore } from '@/scripts/admin/stores/mail-driver'
const props = defineProps({
configData: {
type: Object,
require: true,
default: Object,
},
isSaving: {
type: Boolean,
require: true,
default: false,
},
isFetchingInitialData: {
type: Boolean,
require: true,
default: false,
},
mailDrivers: {
type: Array,
require: true,
default: Array,
},
})
const emit = defineEmits(['submit-data', 'on-change-driver'])
const mailDriverStore = useMailDriverStore()
const { t } = useI18n()
let isShowPassword = ref(false)
const encryptions = reactive(['tls', 'ssl', 'starttls'])
const getInputType = computed(() => {
if (isShowPassword.value) {
return 'text'
}
return 'password'
})
const rules = computed(() => {
return {
smtpConfig: {
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_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,
computed(() => mailDriverStore)
)
onMounted(() => {
for (const key in mailDriverStore.smtpConfig) {
if (props.configData.hasOwnProperty(key)) {
mailDriverStore.smtpConfig[key] = props.configData[key]
}
}
})
async function saveEmailConfig() {
v$.value.smtpConfig.$touch()
if (!v$.value.smtpConfig.$invalid) {
emit('submit-data', mailDriverStore.smtpConfig)
}
return false
}
function onChangeDriver() {
v$.value.smtpConfig.mail_driver.$touch()
emit('on-change-driver', mailDriverStore.smtpConfig.mail_driver)
}
</script>