Rename resources/scripts-v2 to resources/scripts and drop @v2 alias

Now that the legacy v1 frontend (commit 064bdf53) is gone, the v2 directory is the only frontend and the v2 suffix is just noise. Renames resources/scripts-v2 to resources/scripts via git mv (so git records the move as renames, preserving blame and log --follow), then bulk-rewrites the 152 files that imported via @v2/... to use @/scripts/... instead. The existing @ alias (resources/) covers the new path with no extra config needed.

Drops the now-unused @v2 alias from vite.config.js and points the laravel-vite-plugin entry at resources/scripts/main.ts. Updates the only blade reference (resources/views/app.blade.php) to match. The package.json test script (eslint ./resources/scripts) automatically targets the right place after the rename without any edit.

Verified: npm run build exits clean and the Vite warning lines now reference resources/scripts/plugins/i18n.ts, confirming every import resolved through the new path. git log --follow on any moved file walks back through its scripts-v2 history.
This commit is contained in:
Darko Gjorgjijoski
2026-04-07 12:50:16 +02:00
parent 064bdf5395
commit 71388ec6a5
448 changed files with 381 additions and 382 deletions

View File

@@ -0,0 +1,154 @@
<script setup lang="ts">
import { onMounted, computed, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { required, email, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
interface BasicMailConfig {
mail_driver: string
from_mail: string
from_name: string
}
const props = withDefaults(
defineProps<{
configData?: Record<string, unknown>
isSaving?: boolean
isFetchingInitialData?: boolean
mailDrivers?: string[]
}>(),
{
configData: () => ({}),
isSaving: false,
isFetchingInitialData: false,
mailDrivers: () => [],
}
)
const emit = defineEmits<{
'submit-data': [config: BasicMailConfig]
'on-change-driver': [driver: string]
}>()
const { t } = useI18n()
const basicMailConfig = reactive<BasicMailConfig>({
mail_driver: 'sendmail',
from_mail: '',
from_name: '',
})
const rules = computed(() => ({
mail_driver: {
required: helpers.withMessage(t('validation.required'), required),
},
from_mail: {
required: helpers.withMessage(t('validation.required'), required),
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
from_name: {
required: helpers.withMessage(t('validation.required'), required),
},
}))
const v$ = useVuelidate(rules, basicMailConfig)
onMounted(() => {
for (const key in basicMailConfig) {
if (Object.prototype.hasOwnProperty.call(props.configData, key)) {
;(basicMailConfig as Record<string, unknown>)[key] = props.configData[key]
}
}
})
async function saveEmailConfig(): Promise<void> {
v$.value.$touch()
if (!v$.value.$invalid) {
emit('submit-data', { ...basicMailConfig })
}
}
function onChangeDriver(): void {
v$.value.mail_driver.$touch()
emit('on-change-driver', basicMailConfig.mail_driver)
}
</script>
<template>
<form @submit.prevent="saveEmailConfig">
<BaseInputGrid>
<BaseInputGroup
:label="$t('settings.mail.driver')"
:content-loading="isFetchingInitialData"
:error="
v$.mail_driver.$error && v$.mail_driver.$errors[0].$message
"
required
>
<BaseMultiselect
v-model="basicMailConfig.mail_driver"
:content-loading="isFetchingInitialData"
:options="mailDrivers"
:can-deselect="false"
:invalid="v$.mail_driver.$error"
@update:model-value="onChangeDriver"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.from_mail')"
:content-loading="isFetchingInitialData"
:error="
v$.from_mail.$error && v$.from_mail.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="basicMailConfig.from_mail"
:content-loading="isFetchingInitialData"
type="text"
name="from_mail"
:invalid="v$.from_mail.$error"
@input="v$.from_mail.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.from_name')"
:content-loading="isFetchingInitialData"
:error="
v$.from_name.$error && v$.from_name.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="basicMailConfig.from_name"
:content-loading="isFetchingInitialData"
type="text"
name="name"
:invalid="v$.from_name.$error"
@input="v$.from_name.$touch()"
/>
</BaseInputGroup>
</BaseInputGrid>
<div class="flex mt-8">
<BaseButton
:content-loading="isFetchingInitialData"
:disabled="isSaving"
:loading="isSaving"
variant="primary"
type="submit"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
:class="slotProps.class"
name="ArrowDownOnSquareIcon"
/>
</template>
{{ $t('general.save') }}
</BaseButton>
<slot />
</div>
</form>
</template>

View File

@@ -0,0 +1,203 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { required, minLength, maxLength, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useModalStore } from '@/scripts/stores/modal.store'
import { useNotificationStore } from '@/scripts/stores/notification.store'
import { expenseService } from '@/scripts/api/services/expense.service'
interface CategoryForm {
id: number | null
name: string
description: string
}
const modalStore = useModalStore()
const notificationStore = useNotificationStore()
const { t } = useI18n()
const isSaving = ref<boolean>(false)
const isEdit = ref<boolean>(false)
const currentCategory = ref<CategoryForm>({
id: null,
name: '',
description: '',
})
const modalActive = computed<boolean>(
() => modalStore.active && modalStore.componentName === 'CategoryModal'
)
const rules = computed(() => ({
name: {
required: helpers.withMessage(t('validation.required'), required),
minLength: helpers.withMessage(
t('validation.name_min_length', { count: 3 }),
minLength(3)
),
},
description: {
maxLength: helpers.withMessage(
t('validation.description_maxlength', { count: 255 }),
maxLength(255)
),
},
}))
const v$ = useVuelidate(rules, currentCategory)
async function setInitialData(): Promise<void> {
if (modalStore.data && typeof modalStore.data === 'number') {
isEdit.value = true
const response = await expenseService.getCategory(modalStore.data)
if (response.data) {
currentCategory.value = {
id: response.data.id,
name: response.data.name,
description: response.data.description ?? '',
}
}
} else {
isEdit.value = false
resetForm()
}
}
async function submitCategoryData(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
isSaving.value = true
try {
if (isEdit.value && currentCategory.value.id) {
await expenseService.updateCategory(currentCategory.value.id, {
name: currentCategory.value.name,
description: currentCategory.value.description || null,
})
notificationStore.showNotification({
type: 'success',
message: 'settings.expense_category.updated_message',
})
} else {
await expenseService.createCategory({
name: currentCategory.value.name,
description: currentCategory.value.description || null,
})
notificationStore.showNotification({
type: 'success',
message: 'settings.expense_category.created_message',
})
}
isSaving.value = false
if (modalStore.refreshData) {
modalStore.refreshData()
}
closeCategoryModal()
} catch {
isSaving.value = false
}
}
function resetForm(): void {
currentCategory.value = {
id: null,
name: '',
description: '',
}
}
function closeCategoryModal(): void {
modalStore.closeModal()
setTimeout(() => {
resetForm()
v$.value.$reset()
}, 300)
}
</script>
<template>
<BaseModal
:show="modalActive"
@close="closeCategoryModal"
@open="setInitialData"
>
<template #header>
<div class="flex justify-between w-full">
{{ modalStore.title }}
<BaseIcon
name="XMarkIcon"
class="w-6 h-6 text-muted cursor-pointer"
@click="closeCategoryModal"
/>
</div>
</template>
<form action="" @submit.prevent="submitCategoryData">
<div class="p-8 sm:p-6">
<BaseInputGrid layout="one-column">
<BaseInputGroup
:label="$t('expenses.category')"
:error="v$.name.$error && v$.name.$errors[0].$message"
required
>
<BaseInput
v-model="currentCategory.name"
:invalid="v$.name.$error"
type="text"
@input="v$.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('expenses.description')"
:error="
v$.description.$error && v$.description.$errors[0].$message
"
>
<BaseTextarea
v-model="currentCategory.description"
rows="4"
cols="50"
@input="v$.description.$touch()"
/>
</BaseInputGroup>
</BaseInputGrid>
</div>
<div
class="z-0 flex justify-end p-4 border-t border-line-default border-solid"
>
<BaseButton
type="button"
variant="primary-outline"
class="mr-3 text-sm"
@click="closeCategoryModal"
>
{{ $t('general.cancel') }}
</BaseButton>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
variant="primary"
type="submit"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
name="ArrowDownOnSquareIcon"
:class="slotProps.class"
/>
</template>
{{ isEdit ? $t('general.update') : $t('general.save') }}
</BaseButton>
</div>
</form>
</BaseModal>
</template>

View File

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

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useDialogStore } from '@/scripts/stores/dialog.store'
import { useUserStore } from '@/scripts/stores/user.store'
import { useModalStore } from '@/scripts/stores/modal.store'
import { customFieldService } from '@/scripts/api/services/custom-field.service'
const ABILITIES = {
EDIT_CUSTOM_FIELDS: 'edit-custom-field',
DELETE_CUSTOM_FIELDS: 'delete-custom-field',
} as const
interface CustomFieldRow {
id: number
name: string
[key: string]: unknown
}
const props = defineProps<{
row: CustomFieldRow
table?: { refresh: () => void } | null
loadData?: (() => void) | null
}>()
const dialogStore = useDialogStore()
const { t } = useI18n()
const userStore = useUserStore()
const modalStore = useModalStore()
async function editCustomField(id: number): Promise<void> {
modalStore.openModal({
title: t('settings.custom_fields.edit_custom_field'),
componentName: 'CustomFieldModal',
size: 'sm',
data: id,
refreshData: props.loadData ?? undefined,
})
}
async function removeCustomField(id: number): Promise<void> {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('settings.custom_fields.custom_field_confirm_delete'),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'danger',
hideNoButton: false,
size: 'lg',
})
.then(async (res: boolean) => {
if (res) {
await customFieldService.delete(id)
props.loadData?.()
}
})
}
</script>
<template>
<BaseDropdown>
<template #activator>
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-muted" />
</template>
<BaseDropdownItem
v-if="userStore.hasAbilities(ABILITIES.EDIT_CUSTOM_FIELDS)"
@click="editCustomField(row.id)"
>
<BaseIcon
name="PencilIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.edit') }}
</BaseDropdownItem>
<BaseDropdownItem
v-if="userStore.hasAbilities(ABILITIES.DELETE_CUSTOM_FIELDS)"
@click="removeCustomField(row.id)"
>
<BaseIcon
name="TrashIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</template>

View File

@@ -0,0 +1,490 @@
<script setup lang="ts">
import { reactive, ref, computed, defineAsyncComponent } from 'vue'
import { useI18n } from 'vue-i18n'
import useVuelidate from '@vuelidate/core'
import { required, numeric, helpers } from '@vuelidate/validators'
import { useModalStore } from '@/scripts/stores/modal.store'
import { useNotificationStore } from '@/scripts/stores/notification.store'
import { customFieldService } from '@/scripts/api/services/custom-field.service'
import type { CreateCustomFieldPayload } from '@/scripts/api/services/custom-field.service'
interface FieldOption {
name: string
}
interface DataType {
label: string
value: string
}
interface CustomFieldForm {
id: number | null
name: string
label: string
model_type: string
type: string
placeholder: string | null
is_required: number
options: FieldOption[]
order: number | null
default_answer: string | boolean | number | null
dateTimeValue: string | null
in_use: boolean
}
const modalStore = useModalStore()
const notificationStore = useNotificationStore()
const { t } = useI18n()
const isSaving = ref<boolean>(false)
const isEdit = ref<boolean>(false)
const currentCustomField = ref<CustomFieldForm>({
id: null,
name: '',
label: '',
model_type: 'Customer',
type: 'Input',
placeholder: null,
is_required: 0,
options: [],
order: null,
default_answer: null,
dateTimeValue: null,
in_use: false,
})
const modelTypes = reactive([
{ label: t('settings.custom_fields.model_type.customer'), value: 'Customer' },
{ label: t('settings.custom_fields.model_type.invoice'), value: 'Invoice' },
{ label: t('settings.custom_fields.model_type.estimate'), value: 'Estimate' },
{ label: t('settings.custom_fields.model_type.expense'), value: 'Expense' },
{ label: t('settings.custom_fields.model_type.payment'), value: 'Payment' },
])
const dataTypes = reactive<DataType[]>([
{ label: 'Text', value: 'Input' },
{ label: 'Textarea', value: 'TextArea' },
{ label: 'Phone', value: 'Phone' },
{ label: 'URL', value: 'Url' },
{ label: 'Number', value: 'Number' },
{ label: 'Select Field', value: 'Dropdown' },
{ label: 'Switch Toggle', value: 'Switch' },
{ label: 'Date', value: 'Date' },
{ label: 'Time', value: 'Time' },
{ label: 'Date & Time', value: 'DateTime' },
])
const selectedType = ref<DataType>(dataTypes[0])
const modalActive = computed<boolean>(
() => modalStore.active && modalStore.componentName === 'CustomFieldModal'
)
const isSwitchTypeSelected = computed<boolean>(
() => selectedType.value?.label === 'Switch Toggle'
)
const isDropdownSelected = computed<boolean>(
() => selectedType.value?.label === 'Select Field'
)
const defaultValueComponent = computed(() => {
if (currentCustomField.value.type) {
return defineAsyncComponent(
() =>
import(
`@/scripts/admin/components/custom-fields/types/${currentCustomField.value.type}Type.vue`
)
)
}
return null
})
const isRequiredField = computed<boolean>({
get: () => currentCustomField.value.is_required === 1,
set: (value: boolean) => {
currentCustomField.value.is_required = value ? 1 : 0
},
})
const rules = computed(() => ({
name: {
required: helpers.withMessage(t('validation.required'), required),
},
label: {
required: helpers.withMessage(t('validation.required'), required),
},
model_type: {
required: helpers.withMessage(t('validation.required'), required),
},
order: {
required: helpers.withMessage(t('validation.required'), required),
numeric: helpers.withMessage(t('validation.numbers_only'), numeric),
},
type: {
required: helpers.withMessage(t('validation.required'), required),
},
}))
const v$ = useVuelidate(rules, currentCustomField)
function setData(): void {
if (isEdit.value) {
const found = dataTypes.find(
(type) => type.value === currentCustomField.value.type
)
if (found) selectedType.value = found
} else {
currentCustomField.value.model_type = modelTypes[0].value
currentCustomField.value.type = dataTypes[0].value
selectedType.value = dataTypes[0]
}
}
async function setInitialData(): Promise<void> {
if (modalStore.data && typeof modalStore.data === 'number') {
isEdit.value = true
const response = await customFieldService.get(modalStore.data)
if (response.data) {
const field = response.data
currentCustomField.value = {
id: field.id,
name: field.name,
label: field.label,
model_type: field.model_type,
type: field.type,
placeholder: field.placeholder,
is_required: field.is_required ? 1 : 0,
options: field.options
? field.options.map((o) => ({ name: typeof o === 'string' ? o : o }))
: [],
order: field.order,
default_answer: field.default_answer,
dateTimeValue: null,
in_use: field.in_use,
}
}
} else {
isEdit.value = false
resetForm()
}
setData()
}
async function submitCustomFieldData(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
isSaving.value = true
let defaultAnswer = currentCustomField.value.default_answer
// Handle Time type — convert object {HH, mm, ss} to 'HH:mm' string
if (currentCustomField.value.type === 'Time' && typeof defaultAnswer === 'object' && defaultAnswer !== null) {
const timeObj = defaultAnswer as Record<string, string>
defaultAnswer = `${timeObj.HH ?? '00'}:${timeObj.mm ?? '00'}`
}
const payload: CreateCustomFieldPayload = {
name: currentCustomField.value.name,
label: currentCustomField.value.label,
model_type: currentCustomField.value.model_type,
type: currentCustomField.value.type,
placeholder: currentCustomField.value.placeholder,
is_required: currentCustomField.value.is_required === 1,
options: currentCustomField.value.options.length
? currentCustomField.value.options.map((o) => o.name)
: null,
order: currentCustomField.value.order,
default_answer: defaultAnswer as string ?? null,
}
try {
if (isEdit.value && currentCustomField.value.id) {
await customFieldService.update(currentCustomField.value.id, payload)
notificationStore.showNotification({
type: 'success',
message: 'settings.custom_fields.updated_message',
})
} else {
await customFieldService.create(payload)
notificationStore.showNotification({
type: 'success',
message: 'settings.custom_fields.created_message',
})
}
isSaving.value = false
if (modalStore.refreshData) {
modalStore.refreshData()
}
closeCustomFieldModal()
} catch {
isSaving.value = false
}
}
const newOptionValue = ref<string>('')
function onAddOption(): void {
if (!newOptionValue.value?.trim()) return
currentCustomField.value.options = [
{ name: newOptionValue.value.trim() },
...currentCustomField.value.options,
]
newOptionValue.value = ''
}
function addNewOption(option: string): void {
currentCustomField.value.options = [
{ name: option },
...currentCustomField.value.options,
]
}
function removeOption(index: number): void {
if (isEdit.value && currentCustomField.value.in_use) {
return
}
const option = currentCustomField.value.options[index]
if (option.name === currentCustomField.value.default_answer) {
currentCustomField.value.default_answer = null
}
currentCustomField.value.options.splice(index, 1)
}
function onSelectedTypeChange(data: DataType): void {
currentCustomField.value.type = data.value
}
function resetForm(): void {
currentCustomField.value = {
id: null,
name: '',
label: '',
model_type: 'Customer',
type: 'Input',
placeholder: null,
is_required: 0,
options: [],
order: null,
default_answer: null,
dateTimeValue: null,
in_use: false,
}
selectedType.value = dataTypes[0]
}
function closeCustomFieldModal(): void {
modalStore.closeModal()
setTimeout(() => {
resetForm()
v$.value.$reset()
}, 300)
}
</script>
<template>
<BaseModal :show="modalActive" @open="setInitialData">
<template #header>
<div class="flex justify-between w-full">
{{ modalStore.title }}
<BaseIcon
name="XMarkIcon"
class="w-6 h-6 text-muted cursor-pointer"
@click="closeCustomFieldModal"
/>
</div>
</template>
<form action="" @submit.prevent="submitCustomFieldData">
<div class="overflow-y-auto max-h-[550px]">
<div class="px-4 md:px-8 py-8 overflow-y-auto sm:p-6">
<BaseInputGrid layout="one-column">
<BaseInputGroup
:label="$t('settings.custom_fields.name')"
required
:error="v$.name.$error && v$.name.$errors[0].$message"
>
<BaseInput
v-model="currentCustomField.name"
:invalid="v$.name.$error"
@input="v$.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.custom_fields.model')"
:error="
v$.model_type.$error && v$.model_type.$errors[0].$message
"
:help-text="
currentCustomField.in_use
? $t('settings.custom_fields.model_in_use')
: ''
"
required
>
<BaseMultiselect
v-model="currentCustomField.model_type"
:options="modelTypes"
value-prop="value"
:can-deselect="false"
:invalid="v$.model_type.$error"
:searchable="true"
:disabled="currentCustomField.in_use"
@input="v$.model_type.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
class="flex items-center space-x-4"
:label="$t('settings.custom_fields.required')"
>
<BaseSwitch v-model="isRequiredField" />
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.custom_fields.type')"
:error="v$.type.$error && v$.type.$errors[0].$message"
:help-text="
currentCustomField.in_use
? $t('settings.custom_fields.type_in_use')
: ''
"
required
>
<BaseMultiselect
v-model="selectedType"
:options="dataTypes"
:invalid="v$.type.$error"
:disabled="currentCustomField.in_use"
:searchable="true"
:can-deselect="false"
object
@update:model-value="onSelectedTypeChange"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.custom_fields.label')"
required
:error="v$.label.$error && v$.label.$errors[0].$message"
>
<BaseInput
v-model="currentCustomField.label"
:invalid="v$.label.$error"
@input="v$.label.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
v-if="isDropdownSelected"
:label="$t('settings.custom_fields.options')"
>
<!-- Add Option Input -->
<div class="flex items-center mt-1">
<BaseInput
v-model="newOptionValue"
type="text"
class="w-full md:w-96"
:placeholder="$t('settings.custom_fields.press_enter_to_add')"
@keydown.enter.prevent.stop="onAddOption"
/>
<BaseIcon
name="PlusCircleIcon"
class="ml-1 text-primary-500 cursor-pointer"
@click="onAddOption"
/>
</div>
<div
v-for="(option, index) in currentCustomField.options"
:key="index"
class="flex items-center mt-5"
>
<BaseInput v-model="option.name" class="w-64" />
<BaseIcon
name="MinusCircleIcon"
class="ml-1 cursor-pointer"
:class="
currentCustomField.in_use
? 'text-subtle'
: 'text-red-300'
"
@click="removeOption(index)"
/>
</div>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.custom_fields.default_value')"
class="relative"
>
<component
:is="defaultValueComponent"
v-if="defaultValueComponent"
v-model="currentCustomField.default_answer"
:options="currentCustomField.options"
:default-date-time="currentCustomField.dateTimeValue"
/>
</BaseInputGroup>
<BaseInputGroup
v-if="!isSwitchTypeSelected"
:label="$t('settings.custom_fields.placeholder')"
>
<BaseInput v-model="currentCustomField.placeholder" />
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.custom_fields.order')"
:error="v$.order.$error && v$.order.$errors[0].$message"
required
>
<BaseInput
v-model="currentCustomField.order"
type="number"
:invalid="v$.order.$error"
@input="v$.order.$touch()"
/>
</BaseInputGroup>
</BaseInputGrid>
</div>
</div>
<div
class="z-0 flex justify-end p-4 border-t border-solid border-line-default"
>
<BaseButton
class="mr-3"
type="button"
variant="primary-outline"
@click="closeCustomFieldModal"
>
{{ $t('general.cancel') }}
</BaseButton>
<BaseButton
variant="primary"
:loading="isSaving"
:disabled="isSaving"
type="submit"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
:class="slotProps.class"
name="ArrowDownOnSquareIcon"
/>
</template>
{{ isEdit ? $t('general.update') : $t('general.save') }}
</BaseButton>
</div>
</form>
</BaseModal>
</template>

View File

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

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
import { computed, reactive, inject } from 'vue'
import { useCompanyStore } from '@/scripts/stores/company.store'
import { useEstimateStore } from '@/scripts/features/company/estimates/store'
import NumberCustomizer from './NumberCustomizer.vue'
import EstimatesTabExpiryDate from './EstimatesTabExpiryDate.vue'
import EstimatesTabConvertEstimate from './EstimatesTabConvertEstimate.vue'
import EstimatesTabDefaultFormats from './EstimatesTabDefaultFormats.vue'
interface Utils {
mergeSettings: (target: Record<string, unknown>, source: Record<string, unknown>) => void
}
const utils = inject<Utils>('utils')!
const companyStore = useCompanyStore()
const estimateStore = useEstimateStore()
const estimateSettings = reactive<{ estimate_email_attachment: string | null }>({
estimate_email_attachment: null,
})
utils.mergeSettings(
estimateSettings as unknown as Record<string, unknown>,
{ ...companyStore.selectedCompanySettings }
)
const sendAsAttachmentField = computed<boolean>({
get: () => estimateSettings.estimate_email_attachment === 'YES',
set: async (newValue: boolean) => {
const value = newValue ? 'YES' : 'NO'
const data = {
settings: {
estimate_email_attachment: value,
},
}
estimateSettings.estimate_email_attachment = value
await companyStore.updateCompanySettings({
data,
message: 'general.setting_updated',
})
},
})
</script>
<template>
<NumberCustomizer type="estimate" :type-store="estimateStore" />
<BaseDivider class="mt-6 mb-2" />
<EstimatesTabExpiryDate />
<BaseDivider class="mt-6 mb-2" />
<EstimatesTabConvertEstimate />
<BaseDivider class="mt-6 mb-2" />
<EstimatesTabDefaultFormats />
<BaseDivider class="mt-6 mb-2" />
<ul class="divide-y divide-line-default">
<BaseSwitchSection
v-model="sendAsAttachmentField"
:title="$t('settings.customization.estimates.estimate_email_attachment')"
:description="
$t(
'settings.customization.estimates.estimate_email_attachment_setting_description'
)
"
/>
</ul>
</template>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,448 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import useVuelidate from '@vuelidate/core'
import { required, helpers, requiredIf, url } from '@vuelidate/validators'
import { useModalStore } from '@/scripts/stores/modal.store'
import { useNotificationStore } from '@/scripts/stores/notification.store'
import { exchangeRateService } from '@/scripts/api/services/exchange-rate.service'
import { useDebounceFn } from '@vueuse/core'
interface DriverOption {
key: string
value: string
}
interface ServerOption {
value: string
}
interface ExchangeRateForm {
id: number | null
driver: string
key: string | null
active: boolean
currencies: string[]
}
interface CurrencyConverterForm {
type: string
url: string
}
const { t } = useI18n()
const modalStore = useModalStore()
const notificationStore = useNotificationStore()
const isSaving = ref<boolean>(false)
const isFetchingInitialData = ref<boolean>(false)
const isFetchingCurrencies = ref<boolean>(false)
const isEdit = ref<boolean>(false)
const currenciesAlreadyInUsed = ref<string[]>([])
const supportedCurrencies = ref<string[]>([])
const serverOptions = ref<ServerOption[]>([])
const drivers = ref<DriverOption[]>([])
const currentExchangeRate = ref<ExchangeRateForm>({
id: null,
driver: 'currency_converter',
key: null,
active: true,
currencies: [],
})
const currencyConverter = ref<CurrencyConverterForm>({
type: '',
url: '',
})
const modalActive = computed<boolean>(
() =>
modalStore.active &&
modalStore.componentName === 'ExchangeRateProviderModal'
)
const isCurrencyConverter = computed<boolean>(
() => currentExchangeRate.value.driver === 'currency_converter'
)
const isDedicatedServer = computed<boolean>(
() => currencyConverter.value.type === 'DEDICATED'
)
const driverSite = computed<string>(() => {
switch (currentExchangeRate.value.driver) {
case 'currency_converter':
return 'https://www.currencyconverterapi.com'
case 'currency_freak':
return 'https://currencyfreaks.com'
case 'currency_layer':
return 'https://currencylayer.com'
case 'open_exchange_rate':
return 'https://openexchangerates.org'
default:
return ''
}
})
const driversLists = computed(() =>
drivers.value.map((item) => ({
...item,
key: t(item.key),
}))
)
const rules = computed(() => ({
driver: {
required: helpers.withMessage(t('validation.required'), required),
},
key: {
required: helpers.withMessage(t('validation.required'), required),
},
currencies: {
required: helpers.withMessage(t('validation.required'), required),
},
converterType: {
required: helpers.withMessage(
t('validation.required'),
requiredIf(isCurrencyConverter)
),
},
converterUrl: {
required: helpers.withMessage(
t('validation.required'),
requiredIf(isDedicatedServer)
),
url: helpers.withMessage(t('validation.invalid_url'), url),
},
}))
const v$ = useVuelidate(rules, currentExchangeRate)
watch(isCurrencyConverter, (newVal) => {
if (newVal) {
fetchServers()
}
}, { immediate: true })
const fetchCurrenciesDebounced = useDebounceFn(() => {
fetchCurrencies()
}, 500)
watch(() => currentExchangeRate.value.key, (newVal) => {
if (newVal) fetchCurrenciesDebounced()
})
watch(() => currencyConverter.value.type, (newVal) => {
if (newVal) fetchCurrenciesDebounced()
})
function dismiss(): void {
currenciesAlreadyInUsed.value = []
}
function removeUsedSelectedCurrencies(): void {
const { currencies } = currentExchangeRate.value
currenciesAlreadyInUsed.value.forEach((uc) => {
const idx = currencies.indexOf(uc)
if (idx > -1) currencies.splice(idx, 1)
})
currenciesAlreadyInUsed.value = []
}
function resetCurrency(): void {
currentExchangeRate.value.key = null
currentExchangeRate.value.currencies = []
supportedCurrencies.value = []
}
function resetModalData(): void {
supportedCurrencies.value = []
currentExchangeRate.value = {
id: null,
driver: 'currency_converter',
key: null,
active: true,
currencies: [],
}
currencyConverter.value = { type: '', url: '' }
currenciesAlreadyInUsed.value = []
isEdit.value = false
}
async function fetchInitialData(): Promise<void> {
isFetchingInitialData.value = true
const driversRes = await exchangeRateService.getDrivers()
if (driversRes.exchange_rate_drivers) {
drivers.value = (driversRes.exchange_rate_drivers as unknown as DriverOption[])
}
if (modalStore.data && typeof modalStore.data === 'number') {
isEdit.value = true
const response = await exchangeRateService.getProvider(modalStore.data)
if (response.data) {
const provider = response.data
currentExchangeRate.value = {
id: provider.id,
driver: provider.driver,
key: provider.key,
active: provider.active,
currencies: provider.currencies ?? [],
}
}
} else {
currentExchangeRate.value.driver = 'currency_converter'
}
isFetchingInitialData.value = false
}
async function fetchServers(): Promise<void> {
const res = await exchangeRateService.getCurrencyConverterServers()
serverOptions.value = (res as Record<string, ServerOption[]>).currency_converter_servers ?? []
currencyConverter.value.type = 'FREE'
}
async function fetchCurrencies(): Promise<void> {
const { driver, key } = currentExchangeRate.value
if (!driver || !key) return
if (isCurrencyConverter.value && !currencyConverter.value.type) return
isFetchingCurrencies.value = true
try {
const driverConfig: Record<string, string> = {}
if (currencyConverter.value.type) {
driverConfig.type = currencyConverter.value.type
}
if (currencyConverter.value.url) {
driverConfig.url = currencyConverter.value.url
}
const res = await exchangeRateService.getSupportedCurrencies({
driver,
key,
driver_config: Object.keys(driverConfig).length ? driverConfig : undefined,
})
supportedCurrencies.value = res.supportedCurrencies ?? []
} finally {
isFetchingCurrencies.value = false
}
}
async function submitExchangeRate(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) return
const data: Record<string, unknown> = {
...currentExchangeRate.value,
}
if (isCurrencyConverter.value) {
data.driver_config = { ...currencyConverter.value }
if (!isDedicatedServer.value) {
(data.driver_config as CurrencyConverterForm).url = ''
}
}
isSaving.value = true
try {
if (isEdit.value && currentExchangeRate.value.id) {
await exchangeRateService.updateProvider(
currentExchangeRate.value.id,
data as Parameters<typeof exchangeRateService.updateProvider>[1]
)
notificationStore.showNotification({
type: 'success',
message: 'settings.exchange_rate.updated_message',
})
} else {
await exchangeRateService.createProvider(
data as Parameters<typeof exchangeRateService.createProvider>[0]
)
notificationStore.showNotification({
type: 'success',
message: 'settings.exchange_rate.created_message',
})
}
if (modalStore.refreshData) {
modalStore.refreshData()
}
closeExchangeRateModal()
} finally {
isSaving.value = false
}
}
function closeExchangeRateModal(): void {
modalStore.closeModal()
setTimeout(() => {
resetModalData()
v$.value.$reset()
}, 300)
}
</script>
<template>
<BaseModal
:show="modalActive"
@close="closeExchangeRateModal"
@open="fetchInitialData"
>
<template #header>
<div class="flex justify-between w-full">
{{ modalStore.title }}
<BaseIcon
name="XMarkIcon"
class="w-6 h-6 text-muted cursor-pointer"
@click="closeExchangeRateModal"
/>
</div>
</template>
<form @submit.prevent="submitExchangeRate">
<div class="px-4 md:px-8 py-8 overflow-y-auto sm:p-6">
<BaseInputGrid layout="one-column">
<BaseInputGroup
:label="$t('settings.exchange_rate.driver')"
:content-loading="isFetchingInitialData"
required
:error="v$.driver.$error && v$.driver.$errors[0].$message"
:help-text="driverSite"
>
<BaseMultiselect
v-model="currentExchangeRate.driver"
:options="driversLists"
:content-loading="isFetchingInitialData"
value-prop="value"
:can-deselect="true"
label="key"
:searchable="true"
:invalid="v$.driver.$error"
track-by="key"
@update:model-value="resetCurrency"
/>
</BaseInputGroup>
<BaseInputGroup
v-if="isCurrencyConverter"
required
:label="$t('settings.exchange_rate.server')"
:content-loading="isFetchingInitialData"
>
<BaseMultiselect
v-model="currencyConverter.type"
:content-loading="isFetchingInitialData"
value-prop="value"
searchable
:options="serverOptions"
label="value"
track-by="value"
@update:model-value="resetCurrency"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.exchange_rate.key')"
required
:content-loading="isFetchingInitialData"
:error="v$.key.$error && v$.key.$errors[0].$message"
>
<BaseInput
v-model="currentExchangeRate.key"
:content-loading="isFetchingInitialData"
type="text"
name="key"
:loading="isFetchingCurrencies"
loading-position="right"
:invalid="v$.key.$error"
/>
</BaseInputGroup>
<BaseInputGroup
v-if="supportedCurrencies.length"
:label="$t('settings.exchange_rate.currency')"
:content-loading="isFetchingInitialData"
:error="
v$.currencies.$error && v$.currencies.$errors[0].$message
"
:help-text="$t('settings.exchange_rate.currency_help_text')"
>
<BaseMultiselect
v-model="currentExchangeRate.currencies"
:content-loading="isFetchingInitialData"
value-prop="code"
mode="tags"
searchable
:options="supportedCurrencies"
:invalid="v$.currencies.$error"
label="code"
track-by="code"
open-direction="top"
/>
</BaseInputGroup>
<BaseInputGroup
v-if="isDedicatedServer"
:label="$t('settings.exchange_rate.url')"
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model="currencyConverter.url"
:content-loading="isFetchingInitialData"
type="url"
/>
</BaseInputGroup>
<BaseSwitch
v-model="currentExchangeRate.active"
class="flex"
:label-right="$t('settings.exchange_rate.active')"
/>
</BaseInputGrid>
<BaseInfoAlert
v-if="
currenciesAlreadyInUsed.length && currentExchangeRate.active
"
class="mt-5"
:title="$t('settings.exchange_rate.currency_in_used')"
:lists="[currenciesAlreadyInUsed.toString()]"
:actions="['Remove']"
@hide="dismiss"
@Remove="removeUsedSelectedCurrencies"
/>
</div>
<div
class="z-0 flex justify-end p-4 border-t border-line-default border-solid"
>
<BaseButton
class="mr-3"
variant="primary-outline"
type="button"
:disabled="isSaving"
@click="closeExchangeRateModal"
>
{{ $t('general.cancel') }}
</BaseButton>
<BaseButton
:loading="isSaving"
:disabled="isSaving || isFetchingCurrencies"
variant="primary"
type="submit"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
name="ArrowDownOnSquareIcon"
:class="slotProps.class"
/>
</template>
{{ isEdit ? $t('general.update') : $t('general.save') }}
</BaseButton>
</div>
</form>
</BaseModal>
</template>

View File

@@ -0,0 +1,104 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useDialogStore } from '@/scripts/stores/dialog.store'
import { useUserStore } from '@/scripts/stores/user.store'
import { useModalStore } from '@/scripts/stores/modal.store'
import { useNotificationStore } from '@/scripts/stores/notification.store'
import { expenseService } from '@/scripts/api/services/expense.service'
const ABILITIES = {
EDIT_EXPENSE: 'edit-expense',
DELETE_EXPENSE: 'delete-expense',
} as const
interface ExpenseCategoryRow {
id: number
name: string
[key: string]: unknown
}
const props = defineProps<{
row: ExpenseCategoryRow
table?: { refresh: () => void } | null
loadData?: (() => void) | null
}>()
const dialogStore = useDialogStore()
const notificationStore = useNotificationStore()
const { t } = useI18n()
const route = useRoute()
const userStore = useUserStore()
const modalStore = useModalStore()
function editExpenseCategory(id: number): void {
modalStore.openModal({
title: t('settings.expense_category.edit_category'),
componentName: 'CategoryModal',
data: id,
refreshData: props.loadData ?? undefined,
size: 'sm',
})
}
function removeExpenseCategory(id: number): void {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('settings.expense_category.confirm_delete'),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'danger',
hideNoButton: false,
size: 'lg',
})
.then(async (res: boolean) => {
if (res) {
const response = await expenseService.deleteCategory(id)
if (response.success) {
notificationStore.showNotification({
type: 'success',
message: 'settings.expense_category.deleted_message',
})
props.loadData?.()
}
}
})
}
</script>
<template>
<BaseDropdown>
<template #activator>
<BaseButton
v-if="route.name === 'settings.expense-categories'"
variant="primary"
>
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-white" />
</BaseButton>
<BaseIcon v-else name="EllipsisHorizontalIcon" class="h-5 text-muted" />
</template>
<BaseDropdownItem
v-if="userStore.hasAbilities(ABILITIES.EDIT_EXPENSE)"
@click="editExpenseCategory(row.id)"
>
<BaseIcon
name="PencilIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.edit') }}
</BaseDropdownItem>
<BaseDropdownItem
v-if="userStore.hasAbilities(ABILITIES.DELETE_EXPENSE)"
@click="removeExpenseCategory(row.id)"
>
<BaseIcon
name="TrashIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</template>

View File

@@ -0,0 +1,76 @@
<script setup lang="ts">
import { computed, reactive, inject } from 'vue'
import { useCompanyStore } from '@/scripts/stores/company.store'
import { useInvoiceStore } from '@/scripts/features/company/invoices/store'
import NumberCustomizer from './NumberCustomizer.vue'
import InvoicesTabDueDate from './InvoicesTabDueDate.vue'
import InvoicesTabRetrospective from './InvoicesTabRetrospective.vue'
import InvoicesTabDefaultFormats from './InvoicesTabDefaultFormats.vue'
interface Utils {
mergeSettings: (target: Record<string, unknown>, source: Record<string, unknown>) => void
}
const utils = inject<Utils>('utils')!
const companyStore = useCompanyStore()
const invoiceStore = useInvoiceStore()
const invoiceSettings = reactive<{ invoice_email_attachment: string | null }>({
invoice_email_attachment: null,
})
utils.mergeSettings(
invoiceSettings as unknown as Record<string, unknown>,
{ ...companyStore.selectedCompanySettings }
)
const sendAsAttachmentField = computed<boolean>({
get: () => invoiceSettings.invoice_email_attachment === 'YES',
set: async (newValue: boolean) => {
const value = newValue ? 'YES' : 'NO'
const data = {
settings: {
invoice_email_attachment: value,
},
}
invoiceSettings.invoice_email_attachment = value
await companyStore.updateCompanySettings({
data,
message: 'general.setting_updated',
})
},
})
</script>
<template>
<NumberCustomizer type="invoice" :type-store="invoiceStore" />
<BaseDivider class="mt-6 mb-2" />
<InvoicesTabDueDate />
<BaseDivider class="mt-6 mb-2" />
<InvoicesTabRetrospective />
<BaseDivider class="mt-6 mb-2" />
<InvoicesTabDefaultFormats />
<BaseDivider class="mt-6 mb-2" />
<ul class="divide-y divide-line-default">
<BaseSwitchSection
v-model="sendAsAttachmentField"
:title="$t('settings.customization.invoices.invoice_email_attachment')"
:description="
$t(
'settings.customization.invoices.invoice_email_attachment_setting_description'
)
"
/>
</ul>
</template>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,166 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { required, minLength, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useModalStore } from '@/scripts/stores/modal.store'
import { useItemStore } from '@/scripts/features/company/items/store'
interface ItemUnitForm {
id: number | null
name: string
}
const modalStore = useModalStore()
const itemStore = useItemStore()
const { t } = useI18n()
const isSaving = ref<boolean>(false)
const isEdit = ref<boolean>(false)
const currentItemUnit = ref<ItemUnitForm>({
id: null,
name: '',
})
const modalActive = computed<boolean>(
() => modalStore.active && modalStore.componentName === 'ItemUnitModal'
)
const rules = computed(() => ({
name: {
required: helpers.withMessage(t('validation.required'), required),
minLength: helpers.withMessage(
t('validation.name_min_length', { count: 2 }),
minLength(2)
),
},
}))
const v$ = useVuelidate(rules, currentItemUnit)
async function setInitialData(): Promise<void> {
if (modalStore.data && typeof modalStore.data === 'number') {
isEdit.value = true
await itemStore.fetchItemUnit(modalStore.data)
currentItemUnit.value = {
id: itemStore.currentItemUnit.id ?? null,
name: itemStore.currentItemUnit.name,
}
} else {
isEdit.value = false
resetForm()
}
}
async function submitItemUnit(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
isSaving.value = true
try {
let res
if (isEdit.value && currentItemUnit.value.id) {
res = await itemStore.updateItemUnit({
id: currentItemUnit.value.id,
name: currentItemUnit.value.name,
})
} else {
res = await itemStore.addItemUnit({
name: currentItemUnit.value.name,
})
}
if (modalStore.refreshData && res?.data) {
modalStore.refreshData(res.data)
}
closeItemUnitModal()
} catch {
// handled
} finally {
isSaving.value = false
}
}
function resetForm(): void {
currentItemUnit.value = { id: null, name: '' }
}
function closeItemUnitModal(): void {
modalStore.closeModal()
setTimeout(() => {
resetForm()
isEdit.value = false
v$.value.$reset()
}, 300)
}
</script>
<template>
<BaseModal
:show="modalActive"
@close="closeItemUnitModal"
@open="setInitialData"
>
<template #header>
<div class="flex justify-between w-full">
{{ modalStore.title }}
<BaseIcon
name="XMarkIcon"
class="w-6 h-6 text-muted cursor-pointer"
@click="closeItemUnitModal"
/>
</div>
</template>
<form action="" @submit.prevent="submitItemUnit">
<div class="p-8 sm:p-6">
<BaseInputGroup
:label="$t('settings.customization.items.unit_name')"
:error="v$.name.$error && v$.name.$errors[0].$message"
variant="horizontal"
required
>
<BaseInput
v-model="currentItemUnit.name"
:invalid="v$.name.$error"
type="text"
@input="v$.name.$touch()"
/>
</BaseInputGroup>
</div>
<div
class="z-0 flex justify-end p-4 border-t border-line-default border-solid"
>
<BaseButton
type="button"
variant="primary-outline"
class="mr-3 text-sm"
@click="closeItemUnitModal"
>
{{ $t('general.cancel') }}
</BaseButton>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
variant="primary"
type="submit"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
name="ArrowDownOnSquareIcon"
:class="slotProps.class"
/>
</template>
{{ isEdit ? $t('general.update') : $t('general.save') }}
</BaseButton>
</div>
</form>
</BaseModal>
</template>

View File

@@ -0,0 +1,157 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useModalStore } from '@/scripts/stores/modal.store'
import { useDialogStore } from '@/scripts/stores/dialog.store'
import { itemService } from '@/scripts/api/services/item.service'
import ItemUnitModal from './ItemUnitModal.vue'
interface TableColumn {
key: string
label?: string
thClass?: string
tdClass?: string
sortable?: boolean
}
interface FetchParams {
page: number
filter: Record<string, unknown>
sort: { fieldName: string; order: string }
}
interface FetchResult {
data: unknown[]
pagination: {
totalPages: number
currentPage: number
totalCount: number
limit: number
}
}
interface RowData {
data: {
id: number
name: string
}
}
const { t } = useI18n()
const table = ref<{ refresh: () => void } | null>(null)
const modalStore = useModalStore()
const dialogStore = useDialogStore()
const columns = computed<TableColumn[]>(() => [
{
key: 'name',
label: t('settings.customization.items.unit_name'),
thClass: 'extra',
tdClass: 'font-medium text-heading',
},
{
key: 'actions',
label: '',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
])
async function fetchData({ page, sort }: FetchParams): Promise<FetchResult> {
const data = {
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
}
const response = await itemService.listUnits(data)
return {
data: (response as Record<string, unknown>).data as unknown[],
pagination: {
totalPages: ((response as Record<string, unknown>).meta as Record<string, number>).last_page,
currentPage: page,
totalCount: ((response as Record<string, unknown>).meta as Record<string, number>).total,
limit: 5,
},
}
}
function addItemUnit(): void {
modalStore.openModal({
title: t('settings.customization.items.add_item_unit'),
componentName: 'ItemUnitModal',
refreshData: table.value?.refresh,
size: 'sm',
})
}
function editItemUnit(row: RowData): void {
modalStore.openModal({
title: t('settings.customization.items.edit_item_unit'),
componentName: 'ItemUnitModal',
data: row.data.id,
refreshData: table.value?.refresh,
})
}
function removeItemUnit(row: RowData): void {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('settings.customization.items.item_unit_confirm_delete'),
yesLabel: t('general.yes'),
noLabel: t('general.no'),
variant: 'danger',
hideNoButton: false,
size: 'lg',
})
.then(async (res: boolean) => {
if (res) {
await itemService.deleteUnit(row.data.id)
table.value?.refresh()
}
})
}
</script>
<template>
<ItemUnitModal />
<div class="flex flex-wrap justify-end mt-2 lg:flex-nowrap">
<BaseButton variant="primary-outline" @click="addItemUnit">
<template #left="slotProps">
<BaseIcon :class="slotProps.class" name="PlusIcon" />
</template>
{{ $t('settings.customization.items.add_item_unit') }}
</BaseButton>
</div>
<BaseTable ref="table" class="mt-10" :data="fetchData" :columns="columns">
<template #cell-actions="{ row }">
<BaseDropdown>
<template #activator>
<div class="inline-block">
<BaseIcon name="EllipsisHorizontalIcon" class="text-muted" />
</div>
</template>
<BaseDropdownItem @click="editItemUnit(row)">
<BaseIcon
name="PencilIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.edit') }}
</BaseDropdownItem>
<BaseDropdownItem @click="removeItemUnit(row)">
<BaseIcon
name="TrashIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</template>
</BaseTable>
</template>

View File

@@ -0,0 +1,193 @@
<script setup lang="ts">
import { reactive, ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { required, email, maxLength, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useModalStore } from '@/scripts/stores/modal.store'
import { mailService } from '@/scripts/api/services/mail.service'
import { companyService } from '@/scripts/api/services/company.service'
interface MailTestForm {
to: string
subject: string
message: string
}
const props = withDefaults(
defineProps<{
storeType?: 'company' | 'global'
}>(),
{
storeType: 'global',
}
)
const modalStore = useModalStore()
const { t } = useI18n()
const isSaving = ref<boolean>(false)
const formData = reactive<MailTestForm>({
to: '',
subject: '',
message: '',
})
const modalActive = computed<boolean>(
() => modalStore.active && modalStore.componentName === 'MailTestModal'
)
const rules = {
to: {
required: helpers.withMessage(t('validation.required'), required),
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
subject: {
required: helpers.withMessage(t('validation.required'), required),
maxLength: helpers.withMessage(
t('validation.subject_maxlength'),
maxLength(100)
),
},
message: {
required: helpers.withMessage(t('validation.required'), required),
maxLength: helpers.withMessage(
t('validation.message_maxlength'),
maxLength(255)
),
},
}
const v$ = useVuelidate({ formData: rules }, { formData })
function resetFormData(): void {
formData.to = ''
formData.subject = ''
formData.message = ''
v$.value.$reset()
}
async function onTestMailSend(): Promise<void> {
v$.value.formData.$touch()
if (v$.value.$invalid) {
return
}
isSaving.value = true
try {
const payload = {
to: formData.to,
subject: formData.subject,
message: formData.message,
}
if (props.storeType === 'company') {
await companyService.testMailConfig(payload)
} else {
await mailService.testEmail(payload)
}
closeTestModal()
} finally {
isSaving.value = false
}
}
function closeTestModal(): void {
modalStore.closeModal()
setTimeout(() => {
resetFormData()
}, 300)
}
</script>
<template>
<BaseModal :show="modalActive" @close="closeTestModal">
<template #header>
<div class="flex justify-between w-full">
{{ modalStore.title }}
<BaseIcon
name="XMarkIcon"
class="w-6 h-6 text-muted cursor-pointer"
@click="closeTestModal"
/>
</div>
</template>
<form action="" @submit.prevent="onTestMailSend">
<div class="p-4 md:p-8">
<BaseInputGrid layout="one-column">
<BaseInputGroup
:label="$t('general.to')"
:error="
v$.formData.to.$error && v$.formData.to.$errors[0].$message
"
variant="horizontal"
required
>
<BaseInput
v-model="formData.to"
type="text"
:invalid="v$.formData.to.$error"
@input="v$.formData.to.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('general.subject')"
:error="
v$.formData.subject.$error &&
v$.formData.subject.$errors[0].$message
"
variant="horizontal"
required
>
<BaseInput
v-model="formData.subject"
type="text"
:invalid="v$.formData.subject.$error"
@input="v$.formData.subject.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('general.message')"
:error="
v$.formData.message.$error &&
v$.formData.message.$errors[0].$message
"
variant="horizontal"
required
>
<BaseTextarea
v-model="formData.message"
rows="4"
cols="50"
:invalid="v$.formData.message.$error"
@input="v$.formData.message.$touch()"
/>
</BaseInputGroup>
</BaseInputGrid>
</div>
<div
class="z-0 flex justify-end p-4 border-t border-line-default border-solid"
>
<BaseButton
variant="primary-outline"
type="button"
class="mr-3"
@click="closeTestModal()"
>
{{ $t('general.cancel') }}
</BaseButton>
<BaseButton :loading="isSaving" variant="primary" type="submit">
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
name="PaperAirplaneIcon"
:class="slotProps.class"
/>
</template>
{{ $t('general.send') }}
</BaseButton>
</div>
</form>
</BaseModal>
</template>

View File

@@ -0,0 +1,241 @@
<script setup lang="ts">
import { onMounted, ref, computed, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { required, email, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
interface MailgunConfig {
mail_driver: string
mail_mailgun_domain: string
mail_mailgun_secret: string
mail_mailgun_endpoint: string
from_mail: string
from_name: string
}
const props = withDefaults(
defineProps<{
configData?: Record<string, unknown>
isSaving?: boolean
isFetchingInitialData?: boolean
mailDrivers?: string[]
}>(),
{
configData: () => ({}),
isSaving: false,
isFetchingInitialData: false,
mailDrivers: () => [],
}
)
const emit = defineEmits<{
'submit-data': [config: MailgunConfig]
'on-change-driver': [driver: string]
}>()
const { t } = useI18n()
const isShowPassword = ref<boolean>(false)
const mailgunConfig = reactive<MailgunConfig>({
mail_driver: 'mailgun',
mail_mailgun_domain: '',
mail_mailgun_secret: '',
mail_mailgun_endpoint: '',
from_mail: '',
from_name: '',
})
const getInputType = computed<string>(() =>
isShowPassword.value ? 'text' : 'password'
)
const rules = computed(() => ({
mail_driver: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_mailgun_domain: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_mailgun_endpoint: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_mailgun_secret: {
required: helpers.withMessage(t('validation.required'), required),
},
from_mail: {
required: helpers.withMessage(t('validation.required'), required),
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
from_name: {
required: helpers.withMessage(t('validation.required'), required),
},
}))
const v$ = useVuelidate(rules, mailgunConfig)
onMounted(() => {
for (const key in mailgunConfig) {
if (Object.prototype.hasOwnProperty.call(props.configData, key)) {
;(mailgunConfig as Record<string, unknown>)[key] = props.configData[key]
}
}
})
async function saveEmailConfig(): Promise<void> {
v$.value.$touch()
if (!v$.value.$invalid) {
emit('submit-data', { ...mailgunConfig })
}
}
function onChangeDriver(): void {
v$.value.mail_driver.$touch()
emit('on-change-driver', mailgunConfig.mail_driver)
}
</script>
<template>
<form @submit.prevent="saveEmailConfig">
<BaseInputGrid>
<BaseInputGroup
:label="$t('settings.mail.driver')"
:content-loading="isFetchingInitialData"
:error="
v$.mail_driver.$error && v$.mail_driver.$errors[0].$message
"
required
>
<BaseMultiselect
v-model="mailgunConfig.mail_driver"
:content-loading="isFetchingInitialData"
:options="mailDrivers"
:can-deselect="false"
:invalid="v$.mail_driver.$error"
@update:model-value="onChangeDriver"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.mailgun_domain')"
:content-loading="isFetchingInitialData"
:error="
v$.mail_mailgun_domain.$error &&
v$.mail_mailgun_domain.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="mailgunConfig.mail_mailgun_domain"
:content-loading="isFetchingInitialData"
type="text"
name="mailgun_domain"
:invalid="v$.mail_mailgun_domain.$error"
@input="v$.mail_mailgun_domain.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.mailgun_secret')"
:content-loading="isFetchingInitialData"
:error="
v$.mail_mailgun_secret.$error &&
v$.mail_mailgun_secret.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="mailgunConfig.mail_mailgun_secret"
:content-loading="isFetchingInitialData"
:type="getInputType"
name="mailgun_secret"
autocomplete="off"
:invalid="v$.mail_mailgun_secret.$error"
@input="v$.mail_mailgun_secret.$touch()"
>
<template #right>
<BaseIcon
:name="isShowPassword ? 'EyeIcon' : 'EyeSlashIcon'"
class="mr-1 text-muted cursor-pointer"
@click="isShowPassword = !isShowPassword"
/>
</template>
</BaseInput>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.mailgun_endpoint')"
:content-loading="isFetchingInitialData"
:error="
v$.mail_mailgun_endpoint.$error &&
v$.mail_mailgun_endpoint.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="mailgunConfig.mail_mailgun_endpoint"
:content-loading="isFetchingInitialData"
type="text"
name="mailgun_endpoint"
:invalid="v$.mail_mailgun_endpoint.$error"
@input="v$.mail_mailgun_endpoint.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.from_mail')"
:content-loading="isFetchingInitialData"
:error="
v$.from_mail.$error && v$.from_mail.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="mailgunConfig.from_mail"
:content-loading="isFetchingInitialData"
type="text"
name="from_mail"
:invalid="v$.from_mail.$error"
@input="v$.from_mail.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.from_name')"
:content-loading="isFetchingInitialData"
:error="
v$.from_name.$error && v$.from_name.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="mailgunConfig.from_name"
:content-loading="isFetchingInitialData"
type="text"
name="from_name"
:invalid="v$.from_name.$error"
@input="v$.from_name.$touch()"
/>
</BaseInputGroup>
</BaseInputGrid>
<div class="flex my-10">
<BaseButton
:disabled="isSaving"
:content-loading="isFetchingInitialData"
:loading="isSaving"
variant="primary"
type="submit"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
name="ArrowDownOnSquareIcon"
:class="slotProps.class"
/>
</template>
{{ $t('general.save') }}
</BaseButton>
<slot />
</div>
</form>
</template>

View File

@@ -0,0 +1,104 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useDialogStore } from '@/scripts/stores/dialog.store'
import { useNotificationStore } from '@/scripts/stores/notification.store'
import { useUserStore } from '@/scripts/stores/user.store'
import { useModalStore } from '@/scripts/stores/modal.store'
import { noteService } from '@/scripts/api/services/note.service'
import { ABILITIES } from '@/scripts/config/abilities'
interface NoteRow {
id: number
name: string
[key: string]: unknown
}
const props = defineProps<{
row: NoteRow
table?: { refresh: () => void } | null
loadData?: (() => void) | null
}>()
const dialogStore = useDialogStore()
const notificationStore = useNotificationStore()
const { t } = useI18n()
const route = useRoute()
const userStore = useUserStore()
const modalStore = useModalStore()
function editNote(id: number): void {
modalStore.openModal({
title: t('settings.customization.notes.edit_note'),
componentName: 'NoteModal',
size: 'md',
data: id,
refreshData: props.loadData ?? undefined,
})
}
function removeNote(id: number): void {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('settings.customization.notes.note_confirm_delete'),
yesLabel: t('general.yes'),
noLabel: t('general.no'),
variant: 'danger',
hideNoButton: false,
size: 'lg',
})
.then(async (confirmed: boolean) => {
if (!confirmed) {
return
}
const response = await noteService.delete(id)
if (response.success) {
notificationStore.showNotification({
type: 'success',
message: t('settings.customization.notes.deleted_message'),
})
} else {
notificationStore.showNotification({
type: 'error',
message: t('settings.customization.notes.already_in_use'),
})
}
props.loadData?.()
})
}
</script>
<template>
<BaseDropdown>
<template #activator>
<BaseButton v-if="route.name === 'settings.notes'" variant="primary">
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-white" />
</BaseButton>
<BaseIcon v-else name="EllipsisHorizontalIcon" class="h-5 text-muted" />
</template>
<BaseDropdownItem
v-if="userStore.hasAbilities(ABILITIES.MANAGE_NOTE)"
@click="editNote(row.id)"
>
<BaseIcon
name="PencilIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.edit') }}
</BaseDropdownItem>
<BaseDropdownItem
v-if="userStore.hasAbilities(ABILITIES.MANAGE_NOTE)"
@click="removeNote(row.id)"
>
<BaseIcon
name="TrashIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</template>

View File

@@ -0,0 +1,287 @@
<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { required, minLength, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useModalStore } from '@/scripts/stores/modal.store'
import { useNotificationStore } from '@/scripts/stores/notification.store'
import { noteService } from '@/scripts/api/services/note.service'
import type { CreateNotePayload } from '@/scripts/api/services/note.service'
interface NoteForm {
id: number | null
name: string
type: string
notes: string
is_default: boolean
}
const modalStore = useModalStore()
const notificationStore = useNotificationStore()
const route = useRoute()
const { t } = useI18n()
const isSaving = ref<boolean>(false)
const isEdit = ref<boolean>(false)
const currentNote = ref<NoteForm>({
id: null,
name: '',
type: 'Invoice',
notes: '',
is_default: false,
})
const types = reactive([
{ label: t('settings.customization.notes.types.invoice'), value: 'Invoice' },
{ label: t('settings.customization.notes.types.estimate'), value: 'Estimate' },
{ label: t('settings.customization.notes.types.payment'), value: 'Payment' },
])
const fields = ref<string[]>(['customer', 'customerCustom'])
const modalActive = computed<boolean>(
() => modalStore.active && modalStore.componentName === 'NoteModal'
)
const rules = computed(() => ({
name: {
required: helpers.withMessage(t('validation.required'), required),
minLength: helpers.withMessage(
t('validation.name_min_length', { count: 3 }),
minLength(3)
),
},
notes: {
required: helpers.withMessage(t('validation.required'), required),
},
type: {
required: helpers.withMessage(t('validation.required'), required),
},
}))
const v$ = useVuelidate(rules, currentNote)
watch(
() => currentNote.value.type,
() => {
setFields()
}
)
onMounted(() => {
if (route.name === 'estimates.create') {
currentNote.value.type = 'Estimate'
} else if (
route.name === 'invoices.create' ||
route.name === 'recurring-invoices.create'
) {
currentNote.value.type = 'Invoice'
} else {
currentNote.value.type = 'Payment'
}
})
function setFields(): void {
fields.value = ['customer', 'customerCustom']
if (currentNote.value.type === 'Invoice') {
fields.value.push('invoice', 'invoiceCustom')
}
if (currentNote.value.type === 'Estimate') {
fields.value.push('estimate', 'estimateCustom')
}
if (currentNote.value.type === 'Payment') {
fields.value.push('payment', 'paymentCustom')
}
}
async function setInitialData(): Promise<void> {
if (modalStore.data && typeof modalStore.data === 'number') {
isEdit.value = true
const response = await noteService.get(modalStore.data)
if (response.data) {
const note = response.data
currentNote.value = {
id: note.id,
name: note.name,
type: note.type,
notes: note.notes,
is_default: note.is_default ?? false,
}
}
} else {
isEdit.value = false
resetForm()
}
setFields()
}
async function submitNote(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
isSaving.value = true
const payload: CreateNotePayload = {
name: currentNote.value.name,
type: currentNote.value.type,
notes: currentNote.value.notes,
is_default: currentNote.value.is_default,
}
try {
if (isEdit.value && currentNote.value.id) {
const res = await noteService.update(currentNote.value.id, payload)
if (res.data) {
notificationStore.showNotification({
type: 'success',
message: t('settings.customization.notes.note_updated'),
})
}
} else {
const res = await noteService.create(payload)
if (res) {
notificationStore.showNotification({
type: 'success',
message: t('settings.customization.notes.note_added'),
})
}
}
isSaving.value = false
if (modalStore.refreshData) {
modalStore.refreshData()
}
closeNoteModal()
} catch {
isSaving.value = false
}
}
function resetForm(): void {
currentNote.value = {
id: null,
name: '',
type: 'Invoice',
notes: '',
is_default: false,
}
}
function closeNoteModal(): void {
modalStore.closeModal()
setTimeout(() => {
resetForm()
v$.value.$reset()
}, 300)
}
</script>
<template>
<BaseModal
:show="modalActive"
@close="closeNoteModal"
@open="setInitialData"
>
<template #header>
<div class="flex justify-between w-full">
{{ modalStore.title }}
<BaseIcon
name="XMarkIcon"
class="h-6 w-6 text-muted cursor-pointer"
@click="closeNoteModal"
/>
</div>
</template>
<form action="" @submit.prevent="submitNote">
<div class="px-8 py-8 sm:p-6">
<BaseInputGrid layout="one-column">
<BaseInputGroup
:label="$t('settings.customization.notes.name')"
variant="vertical"
:error="v$.name.$error && v$.name.$errors[0].$message"
required
>
<BaseInput
v-model="currentNote.name"
:invalid="v$.name.$error"
type="text"
@input="v$.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.customization.notes.type')"
:error="v$.type.$error && v$.type.$errors[0].$message"
required
>
<BaseMultiselect
v-model="currentNote.type"
:options="types"
value-prop="value"
class="mt-2"
/>
</BaseInputGroup>
<BaseSwitchSection
v-model="currentNote.is_default"
:title="$t('settings.customization.notes.is_default')"
:description="
$t('settings.customization.notes.is_default_description')
"
/>
<BaseInputGroup
:label="$t('settings.customization.notes.notes')"
:error="v$.notes.$error && v$.notes.$errors[0].$message"
required
>
<BaseCustomInput
v-model="currentNote.notes"
:invalid="v$.notes.$error"
:fields="fields"
@input="v$.notes.$touch()"
/>
</BaseInputGroup>
</BaseInputGrid>
</div>
<div
class="z-0 flex justify-end px-4 py-4 border-t border-solid border-line-default"
>
<BaseButton
class="mr-2"
variant="primary-outline"
type="button"
@click="closeNoteModal"
>
{{ $t('general.cancel') }}
</BaseButton>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
variant="primary"
type="submit"
>
<template #left="slotProps">
<BaseIcon name="ArrowDownOnSquareIcon" :class="slotProps.class" />
</template>
{{ isEdit ? $t('general.update') : $t('general.save') }}
</BaseButton>
</div>
</form>
</BaseModal>
</template>
<style>
.note-modal {
.header-editior .editor-menu-bar {
margin-left: 0.5px;
margin-right: 0px;
}
}
</style>

View File

@@ -0,0 +1,398 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import draggable from 'vuedraggable'
import Guid from 'guid'
import { useCompanyStore } from '../../../../stores/company.store'
import { useGlobalStore } from '../../../../stores/global.store'
import DragIcon from '@/scripts/components/icons/DragIcon.vue'
interface NumberField {
id: string
label: string
description: string
name: string
paramLabel: string
value: string
inputDisabled: boolean
inputType: string
allowMultiple: boolean
}
interface TypeStore {
getNextNumber: (data: {
key: string
format: string
}) => Promise<{ data?: { nextNumber: string } }>
}
interface Props {
type: string
typeStore: TypeStore
defaultSeries?: string
}
const props = withDefaults(defineProps<Props>(), {
defaultSeries: 'INV',
})
const { t } = useI18n()
const companyStore = useCompanyStore()
const globalStore = useGlobalStore()
const selectedFields = ref<NumberField[]>([])
const isSaving = ref<boolean>(false)
const nextNumber = ref<string>('')
const isFetchingNextNumber = ref<boolean>(false)
const isLoadingPlaceholders = ref<boolean>(false)
const allFields = ref<Omit<NumberField, 'id'>[]>([
{
label: t('settings.customization.series'),
description: t('settings.customization.series_description'),
name: 'SERIES',
paramLabel: t('settings.customization.series_param_label'),
value: props.defaultSeries,
inputDisabled: false,
inputType: 'text',
allowMultiple: false,
},
{
label: t('settings.customization.sequence'),
description: t('settings.customization.sequence_description'),
name: 'SEQUENCE',
paramLabel: t('settings.customization.sequence_param_label'),
value: '6',
inputDisabled: false,
inputType: 'number',
allowMultiple: false,
},
{
label: t('settings.customization.delimiter'),
description: t('settings.customization.delimiter_description'),
name: 'DELIMITER',
paramLabel: t('settings.customization.delimiter_param_label'),
value: '-',
inputDisabled: false,
inputType: 'text',
allowMultiple: true,
},
{
label: t('settings.customization.customer_series'),
description: t('settings.customization.customer_series_description'),
name: 'CUSTOMER_SERIES',
paramLabel: '',
value: '',
inputDisabled: true,
inputType: 'text',
allowMultiple: false,
},
{
label: t('settings.customization.customer_sequence'),
description: t('settings.customization.customer_sequence_description'),
name: 'CUSTOMER_SEQUENCE',
paramLabel: t('settings.customization.customer_sequence_param_label'),
value: '6',
inputDisabled: false,
inputType: 'number',
allowMultiple: false,
},
{
label: t('settings.customization.date_format'),
description: t('settings.customization.date_format_description'),
name: 'DATE_FORMAT',
paramLabel: t('settings.customization.date_format_param_label'),
value: 'Y',
inputDisabled: false,
inputType: 'text',
allowMultiple: true,
},
{
label: t('settings.customization.random_sequence'),
description: t('settings.customization.random_sequence_description'),
name: 'RANDOM_SEQUENCE',
paramLabel: t('settings.customization.random_sequence_param_label'),
value: '6',
inputDisabled: false,
inputType: 'number',
allowMultiple: false,
},
])
const computedFields = computed<Omit<NumberField, 'id'>[]>(() => {
return allFields.value.filter((obj) => {
return !selectedFields.value.some((obj2) => {
if (obj.allowMultiple) return false
return obj.name === obj2.name
})
})
})
const getNumberFormat = computed<string>(() => {
let format = ''
selectedFields.value.forEach((field) => {
let fieldString = `{{${field.name}`
if (field.value) {
fieldString += `:${field.value}`
}
format += `${fieldString}}}`
})
return format
})
watch(selectedFields, () => {
fetchNextNumber()
})
setInitialFields()
async function setInitialFields(): Promise<void> {
const data = {
format: companyStore.selectedCompanySettings[`${props.type}_number_format`],
}
isLoadingPlaceholders.value = true
const res = await globalStore.fetchPlaceholders(data as { key: string })
res.placeholders.forEach((placeholder) => {
const found = allFields.value.find((field) => field.name === placeholder.name)
if (!found) return
selectedFields.value.push({
...found,
value: placeholder.value ?? found.value,
id: Guid.raw(),
})
})
isLoadingPlaceholders.value = false
fetchNextNumber()
}
function isFieldAdded(field: Omit<NumberField, 'id'>): boolean {
return !!selectedFields.value.find((v) => v.name === field.name)
}
function onSelectField(field: Omit<NumberField, 'id'>): void {
if (isFieldAdded(field) && !field.allowMultiple) return
selectedFields.value.push({ ...field, id: Guid.raw() })
fetchNextNumber()
}
function removeComponent(component: NumberField): void {
selectedFields.value = selectedFields.value.filter((el) => component.id !== el.id)
}
function onUpdate(val: string, element: NumberField): void {
switch (element.name) {
case 'SERIES':
if (val.length >= 6) val = val.substring(0, 6)
break
case 'DELIMITER':
if (val.length >= 1) val = val.substring(0, 1)
break
}
setTimeout(() => {
element.value = val
fetchNextNumber()
}, 100)
}
const fetchNextNumber = useDebounceFn(() => {
getNextNumber()
}, 500)
async function getNextNumber(): Promise<void> {
if (!getNumberFormat.value) {
nextNumber.value = ''
return
}
const data = {
key: props.type,
format: getNumberFormat.value,
}
isFetchingNextNumber.value = true
const res = await props.typeStore.getNextNumber(data)
isFetchingNextNumber.value = false
if (res.data) {
nextNumber.value = (res.data as Record<string, string>).nextNumber
}
}
async function submitForm(): Promise<boolean> {
if (isFetchingNextNumber.value || isLoadingPlaceholders.value) return false
isSaving.value = true
const data: { settings: Record<string, string> } = { settings: {} }
data.settings[props.type + '_number_format'] = getNumberFormat.value
await companyStore.updateCompanySettings({
data,
message: `settings.customization.${props.type}s.${props.type}_settings_updated`,
})
isSaving.value = false
return true
}
</script>
<template>
<h6 class="text-heading text-lg font-medium">
{{ $t(`settings.customization.${type}s.${type}_number_format`) }}
</h6>
<p class="mt-1 text-sm text-muted">
{{ $t(`settings.customization.${type}s.${type}_number_format_description`) }}
</p>
<div class="overflow-x-auto">
<table class="w-full mt-6 table-fixed">
<colgroup>
<col style="width: 4%" />
<col style="width: 45%" />
<col style="width: 27%" />
<col style="width: 24%" />
</colgroup>
<thead>
<tr>
<th
class="px-5 py-3 text-sm not-italic font-medium leading-5 text-left text-body border-t border-b border-line-default border-solid"
/>
<th
class="px-5 py-3 text-sm not-italic font-medium leading-5 text-left text-body border-t border-b border-line-default border-solid"
>
{{ $t('settings.customization.component') }}
</th>
<th
class="px-5 py-3 text-sm not-italic font-medium leading-5 text-left text-body border-t border-b border-line-default border-solid"
>
{{ $t('settings.customization.Parameter') }}
</th>
<th
class="px-5 py-3 text-sm not-italic font-medium leading-5 text-left text-body border-t border-b border-line-default border-solid"
/>
</tr>
</thead>
<draggable
v-model="selectedFields"
class="divide-y divide-line-default"
item-key="id"
tag="tbody"
handle=".handle"
filter=".ignore-element"
>
<template #item="{ element }">
<tr class="relative">
<td class="text-subtle cursor-move handle align-middle">
<DragIcon />
</td>
<td class="px-5 py-4">
<label
class="block text-sm not-italic font-medium text-primary-500 whitespace-nowrap mr-2 min-w-[200px]"
>
{{ element.label }}
</label>
<p class="text-xs text-muted mt-1">
{{ element.description }}
</p>
</td>
<td class="px-5 py-4 text-left align-middle">
<BaseInputGroup
:label="element.paramLabel"
class="lg:col-span-3"
required
>
<BaseInput
v-model="element.value"
:disabled="element.inputDisabled"
:type="element.inputType"
@update:model-value="onUpdate($event, element)"
/>
</BaseInputGroup>
</td>
<td class="px-5 py-4 text-right align-middle pt-10">
<BaseButton
variant="white"
@click.prevent="removeComponent(element)"
>
{{ $t('general.remove') }}
<template #left="slotProps">
<BaseIcon
name="XMarkIcon"
class="!sm:m-0"
:class="slotProps.class"
/>
</template>
</BaseButton>
</td>
</tr>
</template>
<template #footer>
<tr>
<td colspan="2" class="px-5 py-4">
<BaseInputGroup
:label="$t(`settings.customization.${type}s.preview_${type}_number`)"
>
<BaseInput
v-model="nextNumber"
disabled
:loading="isFetchingNextNumber"
/>
</BaseInputGroup>
</td>
<td class="px-5 py-4 text-right align-middle" colspan="2">
<BaseDropdown wrapper-class="flex items-center justify-end mt-5">
<template #activator>
<BaseButton variant="primary-outline">
<template #left="slotProps">
<BaseIcon :class="slotProps.class" name="PlusIcon" />
</template>
{{ $t('settings.customization.add_new_component') }}
</BaseButton>
</template>
<BaseDropdownItem
v-for="field in computedFields"
:key="field.label"
@click.prevent="onSelectField(field)"
>
{{ field.label }}
</BaseDropdownItem>
</BaseDropdown>
</td>
</tr>
</template>
</draggable>
</table>
</div>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
variant="primary"
type="submit"
class="mt-4"
@click="submitForm"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
:class="slotProps.class"
name="ArrowDownOnSquareIcon"
/>
</template>
{{ $t('settings.customization.save') }}
</BaseButton>
</template>

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useDialogStore } from '@/scripts/stores/dialog.store'
import { useModalStore } from '@/scripts/stores/modal.store'
import { paymentService } from '@/scripts/api/services/payment.service'
interface PaymentModeRow {
id: number
name: string
[key: string]: unknown
}
const props = defineProps<{
row: PaymentModeRow
table?: { refresh: () => void } | null
loadData?: (() => void) | null
}>()
const dialogStore = useDialogStore()
const { t } = useI18n()
const route = useRoute()
const modalStore = useModalStore()
function editPaymentMode(id: number): void {
modalStore.openModal({
title: t('settings.payment_modes.edit_payment_mode'),
componentName: 'PaymentModeModal',
data: id,
refreshData: props.loadData ?? undefined,
size: 'sm',
})
}
function removePaymentMode(id: number): void {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('settings.payment_modes.payment_mode_confirm_delete'),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'danger',
hideNoButton: false,
size: 'lg',
})
.then(async (res: boolean) => {
if (res) {
await paymentService.deleteMethod(id)
props.loadData?.()
}
})
}
</script>
<template>
<BaseDropdown>
<template #activator>
<BaseButton v-if="route.name === 'settings.payment-modes'" variant="primary">
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-white" />
</BaseButton>
<BaseIcon v-else name="EllipsisHorizontalIcon" class="h-5 text-muted" />
</template>
<BaseDropdownItem @click="editPaymentMode(row.id)">
<BaseIcon
name="PencilIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.edit') }}
</BaseDropdownItem>
<BaseDropdownItem @click="removePaymentMode(row.id)">
<BaseIcon
name="TrashIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</template>

View File

@@ -0,0 +1,170 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { required, minLength, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useModalStore } from '@/scripts/stores/modal.store'
import { useNotificationStore } from '@/scripts/stores/notification.store'
import { paymentService } from '@/scripts/api/services/payment.service'
interface PaymentModeForm {
id: number | null
name: string
}
const modalStore = useModalStore()
const notificationStore = useNotificationStore()
const { t } = useI18n()
const isSaving = ref<boolean>(false)
const currentPaymentMode = ref<PaymentModeForm>({
id: null,
name: '',
})
const modalActive = computed<boolean>(
() => modalStore.active && modalStore.componentName === 'PaymentModeModal'
)
const rules = computed(() => ({
name: {
required: helpers.withMessage(t('validation.required'), required),
minLength: helpers.withMessage(
t('validation.name_min_length', { count: 3 }),
minLength(3)
),
},
}))
const v$ = useVuelidate(rules, currentPaymentMode)
async function setInitialData(): Promise<void> {
if (modalStore.data && typeof modalStore.data === 'number') {
const response = await paymentService.getMethod(modalStore.data)
if (response.data) {
currentPaymentMode.value = {
id: response.data.id,
name: response.data.name,
}
}
} else {
resetForm()
}
}
async function submitPaymentMode(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
isSaving.value = true
try {
if (currentPaymentMode.value.id) {
await paymentService.updateMethod(currentPaymentMode.value.id, {
name: currentPaymentMode.value.name,
})
notificationStore.showNotification({
type: 'success',
message: 'settings.payment_modes.updated_message',
})
} else {
await paymentService.createMethod({
name: currentPaymentMode.value.name,
})
notificationStore.showNotification({
type: 'success',
message: 'settings.payment_modes.created_message',
})
}
isSaving.value = false
if (modalStore.refreshData) {
modalStore.refreshData()
}
closePaymentModeModal()
} catch {
isSaving.value = false
}
}
function resetForm(): void {
currentPaymentMode.value = {
id: null,
name: '',
}
}
function closePaymentModeModal(): void {
modalStore.closeModal()
setTimeout(() => {
resetForm()
v$.value.$reset()
}, 300)
}
</script>
<template>
<BaseModal
:show="modalActive"
@close="closePaymentModeModal"
@open="setInitialData"
>
<template #header>
<div class="flex justify-between w-full">
{{ modalStore.title }}
<BaseIcon
name="XMarkIcon"
class="w-6 h-6 text-muted cursor-pointer"
@click="closePaymentModeModal"
/>
</div>
</template>
<form action="" @submit.prevent="submitPaymentMode">
<div class="p-4 sm:p-6">
<BaseInputGroup
:label="$t('settings.payment_modes.mode_name')"
:error="v$.name.$error && v$.name.$errors[0].$message"
required
>
<BaseInput
v-model="currentPaymentMode.name"
:invalid="v$.name.$error"
@input="v$.name.$touch()"
/>
</BaseInputGroup>
</div>
<div
class="z-0 flex justify-end p-4 border-t border-line-default border-solid"
>
<BaseButton
variant="primary-outline"
class="mr-3"
type="button"
@click="closePaymentModeModal"
>
{{ $t('general.cancel') }}
</BaseButton>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
variant="primary"
type="submit"
>
<template #left="slotProps">
<BaseIcon name="ArrowDownOnSquareIcon" :class="slotProps.class" />
</template>
{{
currentPaymentMode.id
? $t('general.update')
: $t('general.save')
}}
</BaseButton>
</div>
</form>
</BaseModal>
</template>

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
import { computed, reactive, inject } from 'vue'
import { useCompanyStore } from '@/scripts/stores/company.store'
import { usePaymentStore } from '@/scripts/features/company/payments/store'
import NumberCustomizer from './NumberCustomizer.vue'
import PaymentsTabDefaultFormats from './PaymentsTabDefaultFormats.vue'
interface Utils {
mergeSettings: (target: Record<string, unknown>, source: Record<string, unknown>) => void
}
const utils = inject<Utils>('utils')!
const companyStore = useCompanyStore()
const paymentStore = usePaymentStore()
const paymentSettings = reactive<{ payment_email_attachment: string | null }>({
payment_email_attachment: null,
})
utils.mergeSettings(
paymentSettings as unknown as Record<string, unknown>,
{ ...companyStore.selectedCompanySettings }
)
const sendAsAttachmentField = computed<boolean>({
get: () => paymentSettings.payment_email_attachment === 'YES',
set: async (newValue: boolean) => {
const value = newValue ? 'YES' : 'NO'
const data = {
settings: {
payment_email_attachment: value,
},
}
paymentSettings.payment_email_attachment = value
await companyStore.updateCompanySettings({
data,
message: 'general.setting_updated',
})
},
})
</script>
<template>
<NumberCustomizer type="payment" :type-store="paymentStore" />
<BaseDivider class="mt-6 mb-2" />
<PaymentsTabDefaultFormats />
<BaseDivider class="mt-6 mb-2" />
<ul class="divide-y divide-line-default">
<BaseSwitchSection
v-model="sendAsAttachmentField"
:title="$t('settings.customization.payments.payment_email_attachment')"
:description="
$t(
'settings.customization.payments.payment_email_attachment_setting_description'
)
"
/>
</ul>
</template>

View File

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

View File

@@ -0,0 +1,98 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useDialogStore } from '@/scripts/stores/dialog.store'
import { useUserStore } from '@/scripts/stores/user.store'
import { useModalStore } from '@/scripts/stores/modal.store'
import { useNotificationStore } from '@/scripts/stores/notification.store'
import { roleService } from '@/scripts/api/services/role.service'
interface RoleRow {
id: number
name: string
[key: string]: unknown
}
const props = defineProps<{
row: RoleRow
table?: { refresh: () => void } | null
loadData?: (() => void) | null
}>()
const dialogStore = useDialogStore()
const notificationStore = useNotificationStore()
const { t } = useI18n()
const route = useRoute()
const userStore = useUserStore()
const modalStore = useModalStore()
const PROTECTED_ROLES = ['owner', 'super admin']
async function editRole(id: number): Promise<void> {
if (PROTECTED_ROLES.includes(props.row.name)) return
modalStore.openModal({
title: t('settings.roles.edit_role'),
componentName: 'RolesModal',
size: 'lg',
data: id,
refreshData: props.loadData ?? undefined,
})
}
async function removeRole(id: number): Promise<void> {
if (PROTECTED_ROLES.includes(props.row.name)) return
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('settings.roles.confirm_delete'),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'danger',
hideNoButton: false,
size: 'lg',
})
.then(async (res: boolean) => {
if (res) {
await roleService.delete(id)
notificationStore.showNotification({
type: 'success',
message: 'settings.roles.deleted_message',
})
props.loadData?.()
}
})
}
</script>
<template>
<BaseDropdown>
<template #activator>
<BaseButton v-if="route.name === 'settings.roles'" variant="primary">
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-white" />
</BaseButton>
<BaseIcon v-else name="EllipsisHorizontalIcon" class="h-5 text-muted" />
</template>
<BaseDropdownItem
v-if="userStore.currentUser?.is_owner"
@click="editRole(row.id)"
>
<BaseIcon
name="PencilIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.edit') }}
</BaseDropdownItem>
<BaseDropdownItem
v-if="userStore.currentUser?.is_owner"
@click="removeRole(row.id)"
>
<BaseIcon
name="TrashIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</template>

View File

@@ -0,0 +1,384 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { required, minLength, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useModalStore } from '@/scripts/stores/modal.store'
import { useNotificationStore } from '@/scripts/stores/notification.store'
import { roleService } from '@/scripts/api/services/role.service'
import type { CreateRolePayload } from '@/scripts/api/services/role.service'
import type { Ability } from '@/scripts/types/domain/role'
interface AbilityItem {
name: string
ability: string
disabled: boolean
depends_on?: string[]
model?: string
}
interface AbilitiesList {
[group: string]: AbilityItem[]
}
interface RoleForm {
id: number | null
name: string
abilities: AbilityItem[]
}
const modalStore = useModalStore()
const notificationStore = useNotificationStore()
const { t } = useI18n()
const isSaving = ref<boolean>(false)
const isFetchingInitialData = ref<boolean>(false)
const isEdit = ref<boolean>(false)
const currentRole = ref<RoleForm>({
id: null,
name: '',
abilities: [],
})
const abilitiesList = ref<AbilitiesList>({})
const modalActive = computed<boolean>(
() => modalStore.active && modalStore.componentName === 'RolesModal'
)
const rules = computed(() => ({
name: {
required: helpers.withMessage(t('validation.required'), required),
minLength: helpers.withMessage(
t('validation.name_min_length', { count: 3 }),
minLength(3)
),
},
abilities: {
required: helpers.withMessage(
t('validation.at_least_one_ability'),
required
),
},
}))
const v$ = useVuelidate(rules, currentRole)
async function setInitialData(): Promise<void> {
isFetchingInitialData.value = true
const abilitiesRes = await roleService.getAbilities()
if (abilitiesRes.abilities) {
const grouped: AbilitiesList = {}
abilitiesRes.abilities.forEach((a: Record<string, unknown>) => {
// Extract model name from PHP class path (e.g., "App\Models\Customer" → "Customer")
const modelPath = (a.model as string) ?? ''
const modelName = modelPath
? modelPath.substring(modelPath.lastIndexOf('\\') + 1)
: 'Common'
if (!grouped[modelName]) grouped[modelName] = []
grouped[modelName].push({
name: a.name as string,
ability: a.ability as string,
disabled: false,
depends_on: (a.depends_on as string[]) ?? [],
} as AbilityItem)
})
abilitiesList.value = grouped
}
if (modalStore.data && typeof modalStore.data === 'number') {
isEdit.value = true
const response = await roleService.get(modalStore.data)
if (response.data) {
currentRole.value = {
id: response.data.id,
name: response.data.name,
abilities: [],
}
// Match role's abilities with the full ability objects from abilitiesList
const roleAbilities = (response.data.abilities ?? []) as Array<Record<string, unknown>>
roleAbilities.forEach((ra) => {
Object.keys(abilitiesList.value).forEach((group) => {
abilitiesList.value[group].forEach((_p) => {
if (_p.ability === ra.name) {
currentRole.value.abilities.push(_p)
}
})
})
})
// Set disabled state for dependent abilities
currentRole.value.abilities.forEach((ab) => {
ab.depends_on?.forEach((_d) => {
Object.keys(abilitiesList.value).forEach((group) => {
abilitiesList.value[group].forEach((_a) => {
if (_d === _a.ability) {
_a.disabled = true
}
})
})
})
})
}
} else {
isEdit.value = false
currentRole.value = { id: null, name: '', abilities: [] }
}
isFetchingInitialData.value = false
}
async function submitRoleData(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
isSaving.value = true
try {
const payload: CreateRolePayload = {
name: currentRole.value.name,
abilities: currentRole.value.abilities.map((a) => ({
ability: a.ability,
})),
}
if (isEdit.value && currentRole.value.id) {
await roleService.update(currentRole.value.id, payload)
notificationStore.showNotification({
type: 'success',
message: 'settings.roles.updated_message',
})
} else {
await roleService.create(payload)
notificationStore.showNotification({
type: 'success',
message: 'settings.roles.created_message',
})
}
isSaving.value = false
if (modalStore.refreshData) {
modalStore.refreshData()
}
closeRolesModal()
} catch {
isSaving.value = false
}
}
function onUpdateAbility(currentAbility: AbilityItem): void {
const fd = currentRole.value.abilities.find(
(_abl) => _abl.ability === currentAbility.ability
)
if (!fd && currentAbility.depends_on?.length) {
enableAbilities(currentAbility)
return
}
currentAbility.depends_on?.forEach((_d) => {
Object.keys(abilitiesList.value).forEach((group) => {
abilitiesList.value[group].forEach((_a) => {
if (_d === _a.ability) {
_a.disabled = true
const found = currentRole.value.abilities.find(
(_af) => _af.ability === _d
)
if (!found) {
currentRole.value.abilities.push(_a)
}
}
})
})
})
}
function setSelectAll(checked: boolean): void {
const dependList: string[] = []
Object.keys(abilitiesList.value).forEach((group) => {
abilitiesList.value[group].forEach((_a) => {
if (_a.depends_on) {
dependList.push(..._a.depends_on)
}
})
})
Object.keys(abilitiesList.value).forEach((group) => {
abilitiesList.value[group].forEach((_a) => {
if (dependList.includes(_a.ability)) {
_a.disabled = checked
}
currentRole.value.abilities.push(_a)
})
})
if (!checked) {
currentRole.value.abilities = []
}
}
function enableAbilities(ability: AbilityItem): void {
ability.depends_on?.forEach((_d) => {
Object.keys(abilitiesList.value).forEach((group) => {
abilitiesList.value[group].forEach((_a) => {
const found = currentRole.value.abilities.find((_r) =>
_r.depends_on?.includes(_a.ability)
)
if (_d === _a.ability && !found) {
_a.disabled = false
}
})
})
})
}
function closeRolesModal(): void {
modalStore.closeModal()
setTimeout(() => {
currentRole.value = { id: null, name: '', abilities: [] }
isEdit.value = false
Object.keys(abilitiesList.value).forEach((group) => {
abilitiesList.value[group].forEach((_a) => {
_a.disabled = false
})
})
v$.value.$reset()
}, 300)
}
</script>
<template>
<BaseModal
:show="modalActive"
@close="closeRolesModal"
@open="setInitialData"
>
<template #header>
<div class="flex justify-between w-full">
{{ modalStore.title }}
<BaseIcon
name="XMarkIcon"
class="w-6 h-6 text-muted cursor-pointer"
@click="closeRolesModal"
/>
</div>
</template>
<form @submit.prevent="submitRoleData">
<div class="px-4 md:px-8 py-4 md:py-6">
<BaseInputGroup
:label="$t('settings.roles.name')"
class="mt-3"
:error="v$.name.$error && v$.name.$errors[0].$message"
required
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model="currentRole.name"
:invalid="v$.name.$error"
type="text"
:content-loading="isFetchingInitialData"
@input="v$.name.$touch()"
/>
</BaseInputGroup>
</div>
<div class="flex justify-between">
<h6
class="text-sm not-italic font-medium text-heading px-4 md:px-8 py-1.5"
>
{{ $t('settings.roles.permission', 2) }}
<span class="text-sm text-red-500"> *</span>
</h6>
<div
class="text-sm not-italic font-medium text-subtle px-4 md:px-8 py-1.5"
>
<a
class="cursor-pointer text-primary-400"
@click="setSelectAll(true)"
>
{{ $t('settings.roles.select_all') }}
</a>
/
<a
class="cursor-pointer text-primary-400"
@click="setSelectAll(false)"
>
{{ $t('settings.roles.none') }}
</a>
</div>
</div>
<div class="border-t border-line-default py-3">
<div
class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 px-8 sm:px-8"
>
<div
v-for="(abilityGroup, gIndex) in abilitiesList"
:key="gIndex"
class="flex flex-col space-y-1"
>
<p
class="text-sm text-muted border-b border-line-default pb-1 mb-2"
>
{{ gIndex }}
</p>
<div
v-for="(ability, index) in abilityGroup"
:key="index"
class="flex"
>
<BaseCheckbox
v-model="currentRole.abilities"
:set-initial-value="true"
variant="primary"
:disabled="ability.disabled"
:label="ability.name"
:value="ability"
@update:model-value="onUpdateAbility(ability)"
/>
</div>
</div>
<span
v-if="v$.abilities.$error"
class="block mt-0.5 text-sm text-red-500"
>
{{ v$.abilities.$errors[0].$message }}
</span>
</div>
</div>
<div
class="z-0 flex justify-end p-4 border-t border-solid border-line-default"
>
<BaseButton
class="mr-3 text-sm"
variant="primary-outline"
type="button"
@click="closeRolesModal"
>
{{ $t('general.cancel') }}
</BaseButton>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
variant="primary"
type="submit"
>
<template #left="slotProps">
<BaseIcon name="ArrowDownOnSquareIcon" :class="slotProps.class" />
</template>
{{ isEdit ? $t('general.update') : $t('general.save') }}
</BaseButton>
</div>
</form>
</BaseModal>
</template>

View File

@@ -0,0 +1,313 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { required, email, numeric, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
interface SesConfig {
mail_driver: string
mail_host: string
mail_port: string
mail_encryption: string
mail_ses_key: string
mail_ses_secret: string
mail_ses_region: string
from_mail: string
from_name: string
}
const props = withDefaults(
defineProps<{
configData?: Record<string, unknown>
isSaving?: boolean
isFetchingInitialData?: boolean
mailDrivers?: string[]
}>(),
{
configData: () => ({}),
isSaving: false,
isFetchingInitialData: false,
mailDrivers: () => [],
}
)
const emit = defineEmits<{
'submit-data': [config: SesConfig]
'on-change-driver': [driver: string]
}>()
const { t } = useI18n()
const isShowPassword = ref<boolean>(false)
const encryptions = reactive<string[]>(['tls', 'ssl', 'starttls'])
const sesConfig = reactive<SesConfig>({
mail_driver: 'ses',
mail_host: '',
mail_port: '',
mail_encryption: '',
mail_ses_key: '',
mail_ses_secret: '',
mail_ses_region: '',
from_mail: '',
from_name: '',
})
const getInputType = computed<string>(() =>
isShowPassword.value ? 'text' : 'password'
)
const rules = computed(() => ({
mail_driver: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_host: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_port: {
required: helpers.withMessage(t('validation.required'), required),
numeric: helpers.withMessage(t('validation.numbers_only'), numeric),
},
mail_ses_key: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_ses_secret: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_ses_region: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_encryption: {
required: helpers.withMessage(t('validation.required'), required),
},
from_mail: {
required: helpers.withMessage(t('validation.required'), required),
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
from_name: {
required: helpers.withMessage(t('validation.required'), required),
},
}))
const v$ = useVuelidate(rules, sesConfig)
onMounted(() => {
for (const key in sesConfig) {
if (Object.prototype.hasOwnProperty.call(props.configData, key)) {
;(sesConfig as Record<string, unknown>)[key] = props.configData[key]
}
}
})
async function saveEmailConfig(): Promise<void> {
v$.value.$touch()
if (!v$.value.$invalid) {
emit('submit-data', { ...sesConfig })
}
}
function onChangeDriver(): void {
v$.value.mail_driver.$touch()
emit('on-change-driver', sesConfig.mail_driver)
}
</script>
<template>
<form @submit.prevent="saveEmailConfig">
<BaseInputGrid>
<BaseInputGroup
:label="$t('settings.mail.driver')"
:content-loading="isFetchingInitialData"
:error="
v$.mail_driver.$error && v$.mail_driver.$errors[0].$message
"
required
>
<BaseMultiselect
v-model="sesConfig.mail_driver"
:content-loading="isFetchingInitialData"
:options="mailDrivers"
:can-deselect="false"
:invalid="v$.mail_driver.$error"
@update:model-value="onChangeDriver"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.host')"
:content-loading="isFetchingInitialData"
:error="
v$.mail_host.$error && v$.mail_host.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="sesConfig.mail_host"
:content-loading="isFetchingInitialData"
type="text"
name="mail_host"
:invalid="v$.mail_host.$error"
@input="v$.mail_host.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.port')"
:content-loading="isFetchingInitialData"
:error="
v$.mail_port.$error && v$.mail_port.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="sesConfig.mail_port"
:content-loading="isFetchingInitialData"
type="text"
name="mail_port"
:invalid="v$.mail_port.$error"
@input="v$.mail_port.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.encryption')"
:content-loading="isFetchingInitialData"
:error="
v$.mail_encryption.$error &&
v$.mail_encryption.$errors[0].$message
"
required
>
<BaseMultiselect
v-model.trim="sesConfig.mail_encryption"
:content-loading="isFetchingInitialData"
:options="encryptions"
:invalid="v$.mail_encryption.$error"
placeholder="Select option"
@input="v$.mail_encryption.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.from_mail')"
:content-loading="isFetchingInitialData"
:error="
v$.from_mail.$error && v$.from_mail.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="sesConfig.from_mail"
:content-loading="isFetchingInitialData"
type="text"
name="from_mail"
:invalid="v$.from_mail.$error"
@input="v$.from_mail.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.from_name')"
:content-loading="isFetchingInitialData"
:error="
v$.from_name.$error && v$.from_name.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="sesConfig.from_name"
:content-loading="isFetchingInitialData"
type="text"
name="name"
:invalid="v$.from_name.$error"
@input="v$.from_name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.ses_key')"
:content-loading="isFetchingInitialData"
:error="
v$.mail_ses_key.$error && v$.mail_ses_key.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="sesConfig.mail_ses_key"
:content-loading="isFetchingInitialData"
type="text"
name="mail_ses_key"
:invalid="v$.mail_ses_key.$error"
@input="v$.mail_ses_key.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.ses_secret')"
:content-loading="isFetchingInitialData"
:error="
v$.mail_ses_secret.$error &&
v$.mail_ses_secret.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="sesConfig.mail_ses_secret"
:content-loading="isFetchingInitialData"
:type="getInputType"
name="mail_ses_secret"
autocomplete="off"
:invalid="v$.mail_ses_secret.$error"
@input="v$.mail_ses_secret.$touch()"
>
<template #right>
<BaseIcon
:name="isShowPassword ? 'EyeIcon' : 'EyeSlashIcon'"
class="mr-1 text-muted cursor-pointer"
@click="isShowPassword = !isShowPassword"
/>
</template>
</BaseInput>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.ses_region')"
:content-loading="isFetchingInitialData"
:error="
v$.mail_ses_region.$error &&
v$.mail_ses_region.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="sesConfig.mail_ses_region"
:content-loading="isFetchingInitialData"
type="text"
name="mail_ses_region"
:invalid="v$.mail_ses_region.$error"
@input="v$.mail_ses_region.$touch()"
/>
</BaseInputGroup>
</BaseInputGrid>
<div class="flex my-10">
<BaseButton
:disabled="isSaving"
:content-loading="isFetchingInitialData"
:loading="isSaving"
variant="primary"
type="submit"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
name="ArrowDownOnSquareIcon"
:class="slotProps.class"
/>
</template>
{{ $t('general.save') }}
</BaseButton>
<slot />
</div>
</form>
</template>

View File

@@ -0,0 +1,261 @@
<script setup lang="ts">
import { reactive, onMounted, ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { required, email, numeric, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
interface SmtpConfig {
mail_driver: string
mail_host: string
mail_port: string
mail_username: string
mail_password: string
mail_encryption: string
from_mail: string
from_name: string
}
const props = withDefaults(
defineProps<{
configData?: Record<string, unknown>
isSaving?: boolean
isFetchingInitialData?: boolean
mailDrivers?: string[]
}>(),
{
configData: () => ({}),
isSaving: false,
isFetchingInitialData: false,
mailDrivers: () => [],
}
)
const emit = defineEmits<{
'submit-data': [config: SmtpConfig]
'on-change-driver': [driver: string]
}>()
const { t } = useI18n()
const isShowPassword = ref<boolean>(false)
const schemes = reactive<string[]>(['smtp', 'smtps', 'none'])
const smtpConfig = reactive<SmtpConfig>({
mail_driver: 'smtp',
mail_host: '',
mail_port: '',
mail_username: '',
mail_password: '',
mail_encryption: '',
from_mail: '',
from_name: '',
})
const getInputType = computed<string>(() =>
isShowPassword.value ? 'text' : 'password'
)
const rules = computed(() => ({
mail_driver: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_host: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_port: {
required: helpers.withMessage(t('validation.required'), required),
numeric: helpers.withMessage(t('validation.numbers_only'), numeric),
},
from_mail: {
required: helpers.withMessage(t('validation.required'), required),
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
from_name: {
required: helpers.withMessage(t('validation.required'), required),
},
}))
const v$ = useVuelidate(rules, smtpConfig)
onMounted(() => {
for (const key in smtpConfig) {
if (Object.prototype.hasOwnProperty.call(props.configData, key)) {
;(smtpConfig as Record<string, unknown>)[key] = props.configData[key]
}
}
})
async function saveEmailConfig(): Promise<void> {
v$.value.$touch()
if (!v$.value.$invalid) {
emit('submit-data', { ...smtpConfig })
}
}
function onChangeDriver(): void {
v$.value.mail_driver.$touch()
emit('on-change-driver', smtpConfig.mail_driver)
}
</script>
<template>
<form @submit.prevent="saveEmailConfig">
<BaseInputGrid>
<BaseInputGroup
:label="$t('settings.mail.driver')"
:content-loading="isFetchingInitialData"
:error="
v$.mail_driver.$error && v$.mail_driver.$errors[0].$message
"
required
>
<BaseMultiselect
v-model="smtpConfig.mail_driver"
:content-loading="isFetchingInitialData"
:options="mailDrivers"
:can-deselect="false"
:invalid="v$.mail_driver.$error"
@update:model-value="onChangeDriver"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.host')"
:content-loading="isFetchingInitialData"
:error="
v$.mail_host.$error && v$.mail_host.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="smtpConfig.mail_host"
:content-loading="isFetchingInitialData"
type="text"
name="mail_host"
:invalid="v$.mail_host.$error"
@input="v$.mail_host.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:content-loading="isFetchingInitialData"
:label="$t('settings.mail.username')"
>
<BaseInput
v-model.trim="smtpConfig.mail_username"
:content-loading="isFetchingInitialData"
type="text"
name="db_name"
/>
</BaseInputGroup>
<BaseInputGroup
:content-loading="isFetchingInitialData"
:label="$t('settings.mail.password')"
>
<BaseInput
v-model.trim="smtpConfig.mail_password"
:content-loading="isFetchingInitialData"
:type="getInputType"
name="password"
>
<template #right>
<BaseIcon
:name="isShowPassword ? 'EyeIcon' : 'EyeSlashIcon'"
class="mr-1 text-muted cursor-pointer"
@click="isShowPassword = !isShowPassword"
/>
</template>
</BaseInput>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.port')"
:content-loading="isFetchingInitialData"
:error="
v$.mail_port.$error && v$.mail_port.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="smtpConfig.mail_port"
:content-loading="isFetchingInitialData"
type="text"
name="mail_port"
:invalid="v$.mail_port.$error"
@input="v$.mail_port.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.encryption')"
:content-loading="isFetchingInitialData"
>
<BaseMultiselect
v-model.trim="smtpConfig.mail_encryption"
:content-loading="isFetchingInitialData"
:options="schemes"
:searchable="true"
:show-labels="false"
placeholder="Select option"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.from_mail')"
:content-loading="isFetchingInitialData"
:error="
v$.from_mail.$error && v$.from_mail.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="smtpConfig.from_mail"
:content-loading="isFetchingInitialData"
type="text"
name="from_mail"
:invalid="v$.from_mail.$error"
@input="v$.from_mail.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.from_name')"
:content-loading="isFetchingInitialData"
:error="
v$.from_name.$error && v$.from_name.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="smtpConfig.from_name"
:content-loading="isFetchingInitialData"
type="text"
name="from_name"
:invalid="v$.from_name.$error"
@input="v$.from_name.$touch()"
/>
</BaseInputGroup>
</BaseInputGrid>
<div class="flex my-10">
<BaseButton
:disabled="isSaving"
:content-loading="isFetchingInitialData"
:loading="isSaving"
type="submit"
variant="primary"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
name="ArrowDownOnSquareIcon"
:class="slotProps.class"
/>
</template>
{{ $t('general.save') }}
</BaseButton>
<slot />
</div>
</form>
</template>

View File

@@ -0,0 +1,101 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useDialogStore } from '@/scripts/stores/dialog.store'
import { useUserStore } from '@/scripts/stores/user.store'
import { useModalStore } from '@/scripts/stores/modal.store'
import { useNotificationStore } from '@/scripts/stores/notification.store'
import { taxTypeService } from '@/scripts/api/services/tax-type.service'
const ABILITIES = {
EDIT_TAX_TYPE: 'edit-tax-type',
DELETE_TAX_TYPE: 'delete-tax-type',
} as const
interface TaxTypeRow {
id: number
name: string
[key: string]: unknown
}
const props = defineProps<{
row: TaxTypeRow
table?: { refresh: () => void } | null
loadData?: (() => void) | null
}>()
const dialogStore = useDialogStore()
const notificationStore = useNotificationStore()
const { t } = useI18n()
const route = useRoute()
const userStore = useUserStore()
const modalStore = useModalStore()
async function editTaxType(id: number): Promise<void> {
modalStore.openModal({
title: t('settings.tax_types.edit_tax'),
componentName: 'TaxTypeModal',
size: 'sm',
data: id,
refreshData: props.loadData ?? undefined,
})
}
function removeTaxType(id: number): void {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('settings.tax_types.confirm_delete'),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'danger',
hideNoButton: false,
size: 'lg',
})
.then(async (res: boolean) => {
if (res) {
const response = await taxTypeService.delete(id)
if (response.success) {
notificationStore.showNotification({
type: 'success',
message: 'settings.tax_types.deleted_message',
})
props.loadData?.()
}
}
})
}
</script>
<template>
<BaseDropdown>
<template #activator>
<BaseButton v-if="route.name === 'settings.tax-types'" variant="primary">
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-white" />
</BaseButton>
<BaseIcon v-else name="EllipsisHorizontalIcon" class="h-5 text-muted" />
</template>
<BaseDropdownItem
v-if="userStore.hasAbilities(ABILITIES.EDIT_TAX_TYPE)"
@click="editTaxType(row.id)"
>
<BaseIcon
name="PencilIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.edit') }}
</BaseDropdownItem>
<BaseDropdownItem
v-if="userStore.hasAbilities(ABILITIES.DELETE_TAX_TYPE)"
@click="removeTaxType(row.id)"
>
<BaseIcon
name="TrashIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</template>

View File

@@ -0,0 +1,295 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
required,
minLength,
maxLength,
between,
helpers,
} from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useModalStore } from '@/scripts/stores/modal.store'
import { useCompanyStore } from '@/scripts/stores/company.store'
import { useNotificationStore } from '@/scripts/stores/notification.store'
import { taxTypeService } from '@/scripts/api/services/tax-type.service'
import type { CreateTaxTypePayload } from '@/scripts/api/services/tax-type.service'
interface TaxTypeForm {
id: number | null
name: string
calculation_type: string
percent: number
fixed_amount: number
description: string
}
const modalStore = useModalStore()
const companyStore = useCompanyStore()
const notificationStore = useNotificationStore()
const { t } = useI18n()
const isSaving = ref<boolean>(false)
const isEdit = ref<boolean>(false)
const currentTaxType = ref<TaxTypeForm>({
id: null,
name: '',
calculation_type: 'percentage',
percent: 0,
fixed_amount: 0,
description: '',
})
const defaultCurrency = computed(() => companyStore.selectedCompanyCurrency)
const modalActive = computed<boolean>(
() => modalStore.active && modalStore.componentName === 'TaxTypeModal'
)
const rules = computed(() => ({
name: {
required: helpers.withMessage(t('validation.required'), required),
minLength: helpers.withMessage(
t('validation.name_min_length', { count: 3 }),
minLength(3)
),
},
calculation_type: {
required: helpers.withMessage(t('validation.required'), required),
},
percent: {
required: helpers.withMessage(t('validation.required'), required),
between: helpers.withMessage(
t('validation.enter_valid_tax_rate'),
between(-100, 100)
),
},
fixed_amount: {
required: helpers.withMessage(t('validation.required'), required),
},
description: {
maxLength: helpers.withMessage(
t('validation.description_maxlength', { count: 255 }),
maxLength(255)
),
},
}))
const v$ = useVuelidate(rules, currentTaxType)
const fixedAmount = computed<number>({
get: () => currentTaxType.value.fixed_amount / 100,
set: (value: number) => {
currentTaxType.value.fixed_amount = Math.round(value * 100)
},
})
async function setInitialData(): Promise<void> {
if (modalStore.data && typeof modalStore.data === 'number') {
isEdit.value = true
const response = await taxTypeService.get(modalStore.data)
if (response.data) {
const tax = response.data
currentTaxType.value = {
id: tax.id,
name: tax.name,
calculation_type: tax.calculation_type ?? 'percentage',
percent: tax.percent,
fixed_amount: tax.fixed_amount,
description: tax.description ?? '',
}
}
} else {
isEdit.value = false
resetForm()
}
}
async function submitTaxTypeData(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
isSaving.value = true
try {
const payload: CreateTaxTypePayload = {
name: currentTaxType.value.name,
percent: currentTaxType.value.percent,
fixed_amount: currentTaxType.value.fixed_amount,
calculation_type: currentTaxType.value.calculation_type,
description: currentTaxType.value.description || null,
}
if (isEdit.value && currentTaxType.value.id) {
await taxTypeService.update(currentTaxType.value.id, payload)
notificationStore.showNotification({
type: 'success',
message: 'settings.tax_types.updated_message',
})
} else {
await taxTypeService.create(payload)
notificationStore.showNotification({
type: 'success',
message: 'settings.tax_types.created_message',
})
}
isSaving.value = false
if (modalStore.refreshData) {
modalStore.refreshData()
}
closeTaxTypeModal()
} catch {
isSaving.value = false
}
}
function resetForm(): void {
currentTaxType.value = {
id: null,
name: '',
calculation_type: 'percentage',
percent: 0,
fixed_amount: 0,
description: '',
}
}
function closeTaxTypeModal(): void {
modalStore.closeModal()
setTimeout(() => {
resetForm()
isEdit.value = false
v$.value.$reset()
}, 300)
}
</script>
<template>
<BaseModal
:show="modalActive"
@close="closeTaxTypeModal"
@open="setInitialData"
>
<template #header>
<div class="flex justify-between w-full">
{{ modalStore.title }}
<BaseIcon
name="XMarkIcon"
class="h-6 w-6 text-muted cursor-pointer"
@click="closeTaxTypeModal"
/>
</div>
</template>
<form action="" @submit.prevent="submitTaxTypeData">
<div class="p-4 sm:p-6">
<BaseInputGrid layout="one-column">
<BaseInputGroup
:label="$t('tax_types.name')"
variant="horizontal"
:error="v$.name.$error && v$.name.$errors[0].$message"
required
>
<BaseInput
v-model="currentTaxType.name"
:invalid="v$.name.$error"
type="text"
@input="v$.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('tax_types.tax_type')"
variant="horizontal"
required
>
<BaseSelectInput
v-model="currentTaxType.calculation_type"
:options="[
{ id: 'percentage', label: $t('tax_types.percentage') },
{ id: 'fixed', label: $t('tax_types.fixed_amount') },
]"
:allow-empty="false"
value-prop="id"
label-prop="label"
track-by="label"
:searchable="false"
/>
</BaseInputGroup>
<BaseInputGroup
v-if="currentTaxType.calculation_type === 'percentage'"
:label="$t('tax_types.percent')"
variant="horizontal"
required
>
<BaseMoney
v-model="currentTaxType.percent"
:currency="{
decimal: '.',
thousands: ',',
symbol: '% ',
precision: 2,
masked: false,
}"
/>
</BaseInputGroup>
<BaseInputGroup
v-else
:label="$t('tax_types.fixed_amount')"
variant="horizontal"
required
>
<BaseMoney v-model="fixedAmount" :currency="defaultCurrency" />
</BaseInputGroup>
<BaseInputGroup
:label="$t('tax_types.description')"
:error="
v$.description.$error && v$.description.$errors[0].$message
"
variant="horizontal"
>
<BaseTextarea
v-model="currentTaxType.description"
:invalid="v$.description.$error"
rows="4"
cols="50"
@input="v$.description.$touch()"
/>
</BaseInputGroup>
</BaseInputGrid>
</div>
<div
class="z-0 flex justify-end p-4 border-t border-solid border-line-default"
>
<BaseButton
class="mr-3 text-sm"
variant="primary-outline"
type="button"
@click="closeTaxTypeModal"
>
{{ $t('general.cancel') }}
</BaseButton>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
variant="primary"
type="submit"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
name="ArrowDownOnSquareIcon"
:class="slotProps.class"
/>
</template>
{{ isEdit ? $t('general.update') : $t('general.save') }}
</BaseButton>
</div>
</form>
</BaseModal>
</template>